From 10b7045a1c6ac2149b3bf75feccc57a9d55c69f1 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 6 May 2026 13:33:23 +0000 Subject: [PATCH 1/8] Initial plan From bd5c631538541937d53c041df6feba0396fd5cf2 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 6 May 2026 13:42:32 +0000 Subject: [PATCH 2/8] fix: allow multiple editor instances to register inline shortcuts on document Two bugs caused inline shortcuts to fail when multiple EditorJS instances were on the same page: 1. Mismatch: shortcuts were registered on `document` but removed from `this.Editor.UI.nodes.redactor`, so they were never actually removed. 2. Because shortcuts accumulated on `document` forever, the second editor's attempt to register the same shortcuts threw an error (silently caught), meaning the second editor's inline shortcuts were never registered at all. Fixes: - Remove the throw in `Shortcuts.add()` to allow multiple editors to each register their own handler for the same shortcut on `document`. Each handler already guards via `if (!currentBlock) return`, so only the focused editor responds. - Fix `close()` to call `Shortcuts.remove(document, shortcut)` matching where they were registered. - Update type signatures from `Element` to `HTMLElement | Document` to match actual usage. Agent-Logs-Url: https://github.com/codex-team/editor.js/sessions/6c11dfbe-e93a-4fd6-a88e-ef0409aa3d73 Co-authored-by: neSpecc <3684889+neSpecc@users.noreply.github.com> --- src/components/modules/toolbar/inline.ts | 2 +- src/components/utils/shortcuts.ts | 16 ++++------------ 2 files changed, 5 insertions(+), 13 deletions(-) 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); From 95e9659a461becae39eb54febca78ce3d6f9f921 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 6 May 2026 14:03:43 +0000 Subject: [PATCH 3/8] test: add multi-instance inline shortcut test, bump version to 2.31.7, update CHANGELOG Agent-Logs-Url: https://github.com/codex-team/editor.js/sessions/5d1a01a1-141e-4fa2-8a45-1dee91f7c195 Co-authored-by: neSpecc <3684889+neSpecc@users.noreply.github.com> --- docs/CHANGELOG.md | 4 + package.json | 2 +- test/cypress/tests/ui/InlineToolbar.cy.ts | 90 +++++++++++++++++++++++ 3 files changed, 95 insertions(+), 1 deletion(-) 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/test/cypress/tests/ui/InlineToolbar.cy.ts b/test/cypress/tests/ui/InlineToolbar.cy.ts index 5c337b196..aa0ddbcc9 100644 --- a/test/cypress/tests/ui/InlineToolbar.cy.ts +++ b/test/cypress/tests/ui/InlineToolbar.cy.ts @@ -176,6 +176,96 @@ describe('Inline Toolbar', () => { }); describe('Shortcuts', () => { + it('should work when multiple editor instances are present on the same 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 first editor to open its inline toolbar and register its shortcuts */ + cy.get('[data-cy=editorjs]') + .find('.ce-paragraph') + .selectText('editor'); + + /** Select text in second editor — closes the first editor's toolbar and opens the second's */ + cy.get('[data-cy=editorjs2]') + .find('.ce-paragraph') + .selectText('editor'); + + /** Wait for the second editor's inline toolbar to be visible before dispatching the shortcut */ + cy.get('[data-cy=editorjs2] [data-cy="inline-toolbar"] .ce-popover__container') + .should('be.visible'); + + 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, + })); + }); + + /** 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'); From dbef3a51e804b192427559c5ec48fc8e62f6115b Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 6 May 2026 14:09:15 +0000 Subject: [PATCH 4/8] test: rename multi-instance shortcut test to describe exact behaviour Agent-Logs-Url: https://github.com/codex-team/editor.js/sessions/1188405a-9b5c-490f-a89f-5613434f0858 Co-authored-by: neSpecc <3684889+neSpecc@users.noreply.github.com> --- test/cypress/tests/ui/InlineToolbar.cy.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/cypress/tests/ui/InlineToolbar.cy.ts b/test/cypress/tests/ui/InlineToolbar.cy.ts index aa0ddbcc9..8c0d963f1 100644 --- a/test/cypress/tests/ui/InlineToolbar.cy.ts +++ b/test/cypress/tests/ui/InlineToolbar.cy.ts @@ -176,7 +176,7 @@ describe('Inline Toolbar', () => { }); describe('Shortcuts', () => { - it('should work when multiple editor instances are present on the same page', () => { + 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'); From c5bff13b2c3ce408b900c233b957580be66134af Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 6 May 2026 14:36:54 +0000 Subject: [PATCH 5/8] test: use cy.clock() to exercise shortcuts.ts duplicate-registration fix Agent-Logs-Url: https://github.com/codex-team/editor.js/sessions/82e67750-b875-4aeb-a1d8-495885d68f39 Co-authored-by: neSpecc <3684889+neSpecc@users.noreply.github.com> --- test/cypress/tests/ui/InlineToolbar.cy.ts | 62 ++++++++++++++++++++--- 1 file changed, 54 insertions(+), 8 deletions(-) diff --git a/test/cypress/tests/ui/InlineToolbar.cy.ts b/test/cypress/tests/ui/InlineToolbar.cy.ts index 8c0d963f1..61abb9151 100644 --- a/test/cypress/tests/ui/InlineToolbar.cy.ts +++ b/test/cypress/tests/ui/InlineToolbar.cy.ts @@ -213,7 +213,9 @@ describe('Inline Toolbar', () => { tools: { marker: Marker1 }, }); - /** Create second editor with a different holder */ + /** Create second editor with a different holder; keep a reference for the API call below */ + let editor2Ref: any; + cy.window().then((win) => { const holder = win.document.createElement('div'); @@ -222,7 +224,7 @@ describe('Inline Toolbar', () => { win.document.body.appendChild(holder); return new Promise((resolve) => { - const editor2 = new win.EditorJS({ + editor2Ref = new win.EditorJS({ holder: 'editorjs2', data: { blocks: [ { type: 'paragraph', data: { text: 'Second editor text' } } ], @@ -230,21 +232,65 @@ describe('Inline Toolbar', () => { tools: { marker: Marker2 }, }); - editor2.isReady.then(() => resolve()); + editor2Ref.isReady.then(() => resolve()); }); }); - /** Select text in first editor to open its inline toolbar and register its shortcuts */ + /** + * Freeze the browser clock after both editors are ready. + * This gives us deterministic control over the selectionchange debounce (180 ms). + */ + cy.clock(); + + /** Select text in editor 1 to open its inline toolbar and register its CMD+SHIFT+M shortcut */ cy.get('[data-cy=editorjs]') .find('.ce-paragraph') - .selectText('editor'); + .selectText('First'); - /** Select text in second editor — closes the first editor's toolbar and opens the second's */ + /** + * Advance past the selectionchange debounce. + * Editor 1's toolbar is now open and its CMD+SHIFT+M handler is registered on document. + */ + cy.tick(200); + + cy.get('[data-cy=editorjs] [data-cy="inline-toolbar"] .ce-popover__container') + .should('be.visible'); + + /** + * Select text in editor 2. The selectionchange debounce is queued but the clock is still + * frozen, so editor 1's CMD+SHIFT+M handler has NOT been removed yet — it is still live + * on document. + */ cy.get('[data-cy=editorjs2]') .find('.ce-paragraph') - .selectText('editor'); + .selectText('Second'); + + /** + * Open editor 2's inline toolbar programmatically BEFORE the pending debounce fires. + * Because the selection is in editor 2, allowedToShow() returns true. + * enableShortcuts() then tries to register CMD+SHIFT+M while editor 1's handler is still + * present — a simultaneous duplicate-registration. + * + * Without the shortcuts.ts fix (which removed the throw on duplicate): this throws and is + * silently swallowed by the try/catch in getPopoverItems(), leaving editor 2 with no + * shortcut handler at all. + * With the fix: both handlers are registered; each guards with `if (!currentBlock) return` + * so only the focused editor's handler actually does anything. + */ + cy.window().then(() => { + editor2Ref.inlineToolbar.open(); + }); + + /** + * Advance past the pending debounces: + * - Editor 1's selectionchange handler calls close(), which removes its shortcut via + * Shortcuts.remove(document, …) — this is the inline.ts fix; before the fix it called + * remove(redactor, …) which was a no-op, so editor 1's shortcut would have remained. + * - Editor 2's selectionchange handler calls tryToShow(true), re-opening its toolbar cleanly. + */ + cy.tick(200); - /** Wait for the second editor's inline toolbar to be visible before dispatching the shortcut */ + /** Wait for editor 2's toolbar to be visible */ cy.get('[data-cy=editorjs2] [data-cy="inline-toolbar"] .ce-popover__container') .should('be.visible'); From 3abfa3cbd244c42f1a4ad4b4870451a36ff7a266 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 6 May 2026 16:16:45 +0000 Subject: [PATCH 6/8] test: simplify multi-instance shortcut test; remove cy.clock() (Firefox incompatible) Agent-Logs-Url: https://github.com/codex-team/editor.js/sessions/be0e5329-07dd-4f2e-b7f2-b052fb52e3b1 Co-authored-by: neSpecc <3684889+neSpecc@users.noreply.github.com> --- test/cypress/tests/ui/InlineToolbar.cy.ts | 63 +++++++---------------- 1 file changed, 18 insertions(+), 45 deletions(-) diff --git a/test/cypress/tests/ui/InlineToolbar.cy.ts b/test/cypress/tests/ui/InlineToolbar.cy.ts index 61abb9151..3654054f2 100644 --- a/test/cypress/tests/ui/InlineToolbar.cy.ts +++ b/test/cypress/tests/ui/InlineToolbar.cy.ts @@ -213,9 +213,7 @@ describe('Inline Toolbar', () => { tools: { marker: Marker1 }, }); - /** Create second editor with a different holder; keep a reference for the API call below */ - let editor2Ref: any; - + /** Create second editor with a different holder */ cy.window().then((win) => { const holder = win.document.createElement('div'); @@ -224,7 +222,7 @@ describe('Inline Toolbar', () => { win.document.body.appendChild(holder); return new Promise((resolve) => { - editor2Ref = new win.EditorJS({ + const editor2 = new win.EditorJS({ holder: 'editorjs2', data: { blocks: [ { type: 'paragraph', data: { text: 'Second editor text' } } ], @@ -232,65 +230,40 @@ describe('Inline Toolbar', () => { tools: { marker: Marker2 }, }); - editor2Ref.isReady.then(() => resolve()); + editor2.isReady.then(() => resolve()); }); }); /** - * Freeze the browser clock after both editors are ready. - * This gives us deterministic control over the selectionchange debounce (180 ms). + * 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.clock(); - - /** Select text in editor 1 to open its inline toolbar and register its CMD+SHIFT+M shortcut */ cy.get('[data-cy=editorjs]') .find('.ce-paragraph') .selectText('First'); - /** - * Advance past the selectionchange debounce. - * Editor 1's toolbar is now open and its CMD+SHIFT+M handler is registered on document. - */ - cy.tick(200); - + /** 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'); /** - * Select text in editor 2. The selectionchange debounce is queued but the clock is still - * frozen, so editor 1's CMD+SHIFT+M handler has NOT been removed yet — it is still live - * on document. + * 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'); - /** - * Open editor 2's inline toolbar programmatically BEFORE the pending debounce fires. - * Because the selection is in editor 2, allowedToShow() returns true. - * enableShortcuts() then tries to register CMD+SHIFT+M while editor 1's handler is still - * present — a simultaneous duplicate-registration. - * - * Without the shortcuts.ts fix (which removed the throw on duplicate): this throws and is - * silently swallowed by the try/catch in getPopoverItems(), leaving editor 2 with no - * shortcut handler at all. - * With the fix: both handlers are registered; each guards with `if (!currentBlock) return` - * so only the focused editor's handler actually does anything. - */ - cy.window().then(() => { - editor2Ref.inlineToolbar.open(); - }); - - /** - * Advance past the pending debounces: - * - Editor 1's selectionchange handler calls close(), which removes its shortcut via - * Shortcuts.remove(document, …) — this is the inline.ts fix; before the fix it called - * remove(redactor, …) which was a no-op, so editor 1's shortcut would have remained. - * - Editor 2's selectionchange handler calls tryToShow(true), re-opening its toolbar cleanly. - */ - cy.tick(200); - - /** Wait for editor 2's toolbar to be visible */ + /** 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'); From 882fa3d1605ab4f86a1b0509abb9e46e6463cfa8 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 6 May 2026 17:09:10 +0000 Subject: [PATCH 7/8] test: dispatch shortcut keydown on paragraph element, not document Agent-Logs-Url: https://github.com/codex-team/editor.js/sessions/d68076f4-6a6c-4f5f-8cc6-791e21fad6a9 Co-authored-by: neSpecc <3684889+neSpecc@users.noreply.github.com> --- test/cypress/tests/ui/InlineToolbar.cy.ts | 33 ++++++++++++++--------- 1 file changed, 21 insertions(+), 12 deletions(-) diff --git a/test/cypress/tests/ui/InlineToolbar.cy.ts b/test/cypress/tests/ui/InlineToolbar.cy.ts index 3654054f2..59747f8df 100644 --- a/test/cypress/tests/ui/InlineToolbar.cy.ts +++ b/test/cypress/tests/ui/InlineToolbar.cy.ts @@ -267,18 +267,27 @@ describe('Inline Toolbar', () => { cy.get('[data-cy=editorjs2] [data-cy="inline-toolbar"] .ce-popover__container') .should('be.visible'); - 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, - })); - }); + /** + * 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'); From be18268755a9dac4b34278259e80711ce19939be Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 6 May 2026 17:09:50 +0000 Subject: [PATCH 8/8] test: dispatch keydown on paragraph element in read-only mode test too Agent-Logs-Url: https://github.com/codex-team/editor.js/sessions/d68076f4-6a6c-4f5f-8cc6-791e21fad6a9 Co-authored-by: neSpecc <3684889+neSpecc@users.noreply.github.com> --- test/cypress/tests/ui/InlineToolbar.cy.ts | 26 ++++++++++++----------- 1 file changed, 14 insertions(+), 12 deletions(-) diff --git a/test/cypress/tests/ui/InlineToolbar.cy.ts b/test/cypress/tests/ui/InlineToolbar.cy.ts index 59747f8df..88a1f5c24 100644 --- a/test/cypress/tests/ui/InlineToolbar.cy.ts +++ b/test/cypress/tests/ui/InlineToolbar.cy.ts @@ -329,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'); });