diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index bca6f9236..f02f4bd13 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -1,5 +1,9 @@ # Changelog +### 2.31.7 + +- `Fix` - Multiple EditorJS instances on the same page now properly register inline tool shortcuts + ### 2.31.6 - `Fix` - Widen `sanitize` type on `BlockTool` and `BaseToolConstructable` to accept per-field `SanitizerConfig` diff --git a/package.json b/package.json index 3d964610b..2bc48489f 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@editorjs/editorjs", - "version": "2.31.6", + "version": "2.31.7", "description": "Editor.js — open source block-style WYSIWYG editor with JSON output", "main": "dist/editorjs.umd.js", "module": "dist/editorjs.mjs", diff --git a/src/components/modules/toolbar/inline.ts b/src/components/modules/toolbar/inline.ts index 5aa5f7dab..955ecedcc 100644 --- a/src/components/modules/toolbar/inline.ts +++ b/src/components/modules/toolbar/inline.ts @@ -110,7 +110,7 @@ export default class InlineToolbar extends Module { const shortcut = this.getToolShortcut(tool.name); if (shortcut !== undefined) { - Shortcuts.remove(this.Editor.UI.nodes.redactor, shortcut); + Shortcuts.remove(document, shortcut); } /** diff --git a/src/components/utils/shortcuts.ts b/src/components/utils/shortcuts.ts index 967243d27..6b73d7791 100644 --- a/src/components/utils/shortcuts.ts +++ b/src/components/utils/shortcuts.ts @@ -41,9 +41,9 @@ class Shortcuts { /** * All registered shortcuts * - * @type {Map} + * @type {Map} */ - private registeredShortcuts: Map = new Map(); + private registeredShortcuts: Map = new Map(); /** * Register shortcut @@ -51,14 +51,6 @@ class Shortcuts { * @param shortcut - shortcut options */ public add(shortcut: ShortcutData): void { - const foundShortcut = this.findShortcut(shortcut.on, shortcut.name); - - if (foundShortcut) { - throw Error( - `Shortcut ${shortcut.name} is already registered for ${shortcut.on}. Please remove it before add a new handler.` - ); - } - const newShortcut = new Shortcut({ name: shortcut.name, on: shortcut.on, @@ -75,7 +67,7 @@ class Shortcuts { * @param element - Element shortcut is set for * @param name - shortcut name */ - public remove(element: Element, name: string): void { + public remove(element: HTMLElement | Document, name: string): void { const shortcut = this.findShortcut(element, name); if (!shortcut) { @@ -104,7 +96,7 @@ class Shortcuts { * @param shortcut - shortcut name * @returns {number} index - shortcut index if exist */ - private findShortcut(element: Element, shortcut: string): Shortcut | void { + private findShortcut(element: HTMLElement | Document, shortcut: string): Shortcut | void { const shortcuts = this.registeredShortcuts.get(element) || []; return shortcuts.find(({ name }) => name === shortcut); diff --git a/test/cypress/tests/ui/InlineToolbar.cy.ts b/test/cypress/tests/ui/InlineToolbar.cy.ts index 5c337b196..88a1f5c24 100644 --- a/test/cypress/tests/ui/InlineToolbar.cy.ts +++ b/test/cypress/tests/ui/InlineToolbar.cy.ts @@ -176,6 +176,124 @@ describe('Inline Toolbar', () => { }); describe('Shortcuts', () => { + it('should activate the focused editor\'s tool when shortcut is pressed with multiple instances on the page', () => { + const toolActivated1 = cy.stub().as('toolActivated1'); + const toolActivated2 = cy.stub().as('toolActivated2'); + + /* eslint-disable jsdoc/require-jsdoc */ + class Marker1 implements InlineTool { + public static isInline = true; + public static shortcut = 'CMD+SHIFT+M'; + public render(): MenuConfig { + return { + icon: 'm', + title: 'Marker', + onActivate: () => { toolActivated1(); }, + }; + } + } + class Marker2 implements InlineTool { + public static isInline = true; + public static shortcut = 'CMD+SHIFT+M'; + public render(): MenuConfig { + return { + icon: 'm', + title: 'Marker', + onActivate: () => { toolActivated2(); }, + }; + } + } + /* eslint-enable jsdoc/require-jsdoc */ + + /** Create first editor */ + cy.createEditor({ + data: { + blocks: [ { type: 'paragraph', data: { text: 'First editor text' } } ], + }, + tools: { marker: Marker1 }, + }); + + /** Create second editor with a different holder */ + cy.window().then((win) => { + const holder = win.document.createElement('div'); + + holder.id = 'editorjs2'; + holder.dataset.cy = 'editorjs2'; + win.document.body.appendChild(holder); + + return new Promise((resolve) => { + const editor2 = new win.EditorJS({ + holder: 'editorjs2', + data: { + blocks: [ { type: 'paragraph', data: { text: 'Second editor text' } } ], + }, + tools: { marker: Marker2 }, + }); + + editor2.isReady.then(() => resolve()); + }); + }); + + /** + * Select text in editor 1 first to open its inline toolbar. + * This causes editor 1's CMD+SHIFT+M shortcut to be registered on document. + * Without the inline.ts fix, this shortcut would never be removed from document, + * so any later attempt by editor 2 to register the same shortcut would throw a + * duplicate-registration error (silently swallowed), leaving editor 2 with no shortcut. + */ + cy.get('[data-cy=editorjs]') + .find('.ce-paragraph') + .selectText('First'); + + /** Wait for editor 1's inline toolbar to appear (confirms its shortcut is now registered) */ + cy.get('[data-cy=editorjs] [data-cy="inline-toolbar"] .ce-popover__container') + .should('be.visible'); + + /** + * Now select text in editor 2. + * The selectionchange event fires, which (after the 180 ms debounce): + * 1. Calls editor 1's InlineToolbar.close() — with the inline.ts fix this correctly + * calls Shortcuts.remove(document, shortcut), removing editor 1's handler from document. + * 2. Calls editor 2's InlineToolbar.open() — registers editor 2's handler on document. + * Without the inline.ts fix, step 1 was a no-op (wrong target element), so editor 2's + * registration in step 2 always hit the duplicate guard and threw, leaving editor 2 with + * no working shortcut at all. + */ + cy.get('[data-cy=editorjs2]') + .find('.ce-paragraph') + .selectText('Second'); + + /** Wait for editor 2's inline toolbar to appear (confirms its shortcut is now registered) */ + cy.get('[data-cy=editorjs2] [data-cy="inline-toolbar"] .ce-popover__container') + .should('be.visible'); + + /** + * Dispatch the shortcut key event on the editor 2 paragraph element (not on document). + * Dispatching directly on document makes event.target === document, which does not have + * .closest() — causing a TypeError in ui.ts defaultBehaviour. + * Dispatching on the focused element gives event.target an HTMLElement with .closest(), + * and the event still bubbles up to document where the shortcut handler is registered. + */ + cy.get('[data-cy=editorjs2]') + .find('.ce-paragraph') + .then(($el) => { + $el[0].dispatchEvent(new KeyboardEvent('keydown', { + bubbles: true, + cancelable: true, + key: 'M', + code: 'KeyM', + keyCode: 77, + which: 77, + metaKey: true, + shiftKey: true, + })); + }); + + /** Second editor's shortcut should fire, first editor's should not */ + cy.get('@toolActivated2').should('have.been.called'); + cy.get('@toolActivated1').should('not.have.been.called'); + }); + it('should work in read-only mode', () => { const toolSurround = cy.stub().as('toolSurround'); @@ -211,18 +329,20 @@ describe('Inline Toolbar', () => { cy.wait(300); - cy.document().then((doc) => { - doc.dispatchEvent(new KeyboardEvent('keydown', { - bubbles: true, - cancelable: true, - key: 'M', - code: 'KeyM', - keyCode: 77, - which: 77, - metaKey: true, - shiftKey: true, - })); - }); + cy.get('[data-cy=editorjs]') + .find('.ce-paragraph') + .then(($el) => { + $el[0].dispatchEvent(new KeyboardEvent('keydown', { + bubbles: true, + cancelable: true, + key: 'M', + code: 'KeyM', + keyCode: 77, + which: 77, + metaKey: true, + shiftKey: true, + })); + }); cy.get('@toolSurround').should('have.been.called'); });