From f913ed0db50ef8716d62a2d957a2f91c9ac146fa Mon Sep 17 00:00:00 2001 From: Shaswat Raj Date: Fri, 13 Nov 2026 23:37:05 +0530 Subject: [PATCH 1/3] fix(inline-toolbar): register tool shortcuts per Editor instance Inline Tools shortcuts are bound to the document so they work in read-only mode, but they were stored in the page-wide Shortcuts registry, which allows one handler per shortcut and element. The second Editor on a page failed to register its shortcuts (the error was swallowed), so CMD+B and others did nothing there. The same happened to an Editor created after another one was destroyed, because close() removed the shortcuts from the redactor, where they were never bound, and destroy() did not remove them at all. Each Inline Toolbar now keeps its own shortcuts, handles one only when the selection is inside its Editor, and removes them on destroy. Co-Authored-By: Claude Fable 5.2 --- docs/CHANGELOG.md | 1 + package.json | 2 +- src/components/modules/toolbar/inline.ts | 43 ++++++--- .../cypress/tests/modules/InlineToolbar.cy.ts | 91 +++++++++++++++++++ 4 files changed, 123 insertions(+), 14 deletions(-) diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 480a12c7f..4d748d551 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -5,6 +5,7 @@ - `Improvement` - Removed "Narrow" ("Thin") mode. On non-mobile viewports, Plus and Block Tunes controls now use a responsive action rail beside the editor content. - `Improvement` - `paste` config supports filter functions for tag-based substitution - `Fix` - "Convert to" is not shown in the Inline Toolbar when there are no tools to convert to +- `Fix` - Inline Tools shortcuts work in every Editor instance on a page and are released when an Editor is destroyed ### 2.31.7 diff --git a/package.json b/package.json index b57e61968..9e8894910 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@editorjs/editorjs", - "version": "2.32.0-rc.3", + "version": "2.32.0-rc.4", "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 36a4d05e0..0e26cb390 100644 --- a/src/components/modules/toolbar/inline.ts +++ b/src/components/modules/toolbar/inline.ts @@ -6,7 +6,7 @@ import * as _ from '../../utils'; import type { InlineTool as IInlineTool } from '../../../../types'; import I18n from '../../i18n'; import { I18nInternalNS } from '../../i18n/namespace-internal'; -import Shortcuts from '../../utils/shortcuts'; +import Shortcut from '@codexteam/shortcuts'; import type { ModuleConfig } from '../../../types-internal/module-config'; import { CommonInternalSettings } from '../../tools/base'; import type { Popover, PopoverItemHtmlParams, PopoverItemParams, WithChildren } from '../../utils/popover'; @@ -57,6 +57,13 @@ export default class InlineToolbar extends Module { */ private tools: Map = new Map(); + /** + * Inline Tools shortcuts registered by this Editor instance, by shortcut name. + * They are bound to the document, which several Editors on a page share, + * so each instance keeps and removes its own. + */ + private registeredShortcuts: Map = new Map(); + /** * @param moduleConfiguration - Module Configuration * @param moduleConfiguration.config - Editor's config @@ -106,13 +113,7 @@ export default class InlineToolbar extends Module { return; } - for (const [tool, toolInstance] of this.tools) { - const shortcut = this.getToolShortcut(tool.name); - - if (shortcut !== undefined) { - Shortcuts.remove(this.Editor.UI.nodes.redactor, shortcut); - } - + for (const toolInstance of this.tools.values()) { /** * @todo replace 'clear' with 'destroy' */ @@ -148,6 +149,9 @@ export default class InlineToolbar extends Module { * Removes UI and its components */ public destroy(): void { + this.registeredShortcuts.forEach((shortcut) => shortcut.remove()); + this.registeredShortcuts.clear(); + this.removeAllNodes(); this.popover?.destroy(); this.popover = null; @@ -374,9 +378,7 @@ export default class InlineToolbar extends Module { const shortcut = this.getToolShortcut(tool.name); if (shortcut !== undefined) { - try { - this.enableShortcuts(tool.name, shortcut); - } catch (e) {} + this.enableShortcuts(tool.name, shortcut); } const shortcutBeautified = shortcut !== undefined ? _.beautifyShortcut(shortcut) : undefined; @@ -523,9 +525,22 @@ export default class InlineToolbar extends Module { * @param shortcut - shortcut according to the ShortcutData Module format */ private enableShortcuts(toolName: string, shortcut: string): void { - Shortcuts.add({ + if (this.registeredShortcuts.has(shortcut)) { + return; + } + + const registeredShortcut = new Shortcut({ name: shortcut, - handler: (event) => { + callback: (event: KeyboardEvent) => { + /** + * The document is shared with other Editors on the page: handle the shortcut only when the selection is in this one + */ + const { anchorNode } = SelectionUtils; + + if (anchorNode === null || !this.Editor.UI.nodes.wrapper.contains(anchorNode)) { + return; + } + const { currentBlock } = this.Editor.BlockManager; /** @@ -555,6 +570,8 @@ export default class InlineToolbar extends Module { */ on: document, }); + + this.registeredShortcuts.set(shortcut, registeredShortcut); } /** diff --git a/test/cypress/tests/modules/InlineToolbar.cy.ts b/test/cypress/tests/modules/InlineToolbar.cy.ts index f688975f4..aa01df708 100644 --- a/test/cypress/tests/modules/InlineToolbar.cy.ts +++ b/test/cypress/tests/modules/InlineToolbar.cy.ts @@ -1,6 +1,7 @@ import Header from '@editorjs/header'; import NestedEditor, { NESTED_EDITOR_ID } from '../../support/utils/nestedEditorInstance'; import type { MenuConfig } from '@/types/tools'; +import type EditorJS from '../../../../types/index'; import { createEditorWithTextBlocks } from '../../support/utils/createEditorWithTextBlocks'; describe('Inline Toolbar', () => { @@ -347,4 +348,94 @@ describe('Inline Toolbar', () => { }); }); }); + + describe('Shortcuts', () => { + const paragraph = (text: string): { blocks: { type: string; data: { text: string } }[] } => ({ + blocks: [ + { + type: 'paragraph', + data: { text }, + }, + ], + }); + + /** + * Creates one more Editor on the page, in its own holder + * + * @param text - text of the only paragraph + */ + const createSecondEditor = (text: string): void => { + cy.window().then((window) => { + const holder = window.document.createElement('div'); + + holder.dataset.cy = 'second-editorjs'; + window.document.body.appendChild(holder); + + const editor = new window.EditorJS({ + holder, + data: paragraph(text), + }); + + return editor.isReady; + }); + }; + + it('should apply an inline tool by shortcut in each Editor on the page', () => { + cy.createEditor({ data: paragraph('First editor text') }); + createSecondEditor('Second editor text'); + + /** Open the Inline Toolbar of the first Editor, so it registers its shortcuts first */ + cy.get('[data-cy=editorjs]') + .find('.ce-paragraph') + .click() + .selectText('First') + .type('{cmd}b'); + + cy.get('[data-cy=editorjs]') + .find('.ce-paragraph b') + .should('have.text', 'First'); + + cy.get('[data-cy=second-editorjs]') + .find('.ce-paragraph') + .click() + .selectText('Second') + .type('{cmd}b'); + + cy.get('[data-cy=second-editorjs]') + .find('.ce-paragraph b') + .should('have.text', 'Second'); + + /** The shortcut pressed in the second Editor must not format the first one again */ + cy.get('[data-cy=editorjs]') + .find('.ce-paragraph b') + .should('have.length', 1); + }); + + it('should apply an inline tool by shortcut in an Editor created after another one was destroyed', () => { + cy.createEditor({ data: paragraph('First editor text') }) + .as('firstEditor'); + + cy.get('[data-cy=editorjs]') + .find('.ce-paragraph') + .click() + .selectText('First') + .type('{cmd}b'); + + cy.get('@firstEditor').then((editor) => { + (editor as unknown as EditorJS).destroy(); + }); + + createSecondEditor('Second editor text'); + + cy.get('[data-cy=second-editorjs]') + .find('.ce-paragraph') + .click() + .selectText('Second') + .type('{cmd}b'); + + cy.get('[data-cy=second-editorjs]') + .find('.ce-paragraph b') + .should('have.text', 'Second'); + }); + }); }); From 0835156a9ecc104897f4c4256472fafe00b4fa52 Mon Sep 17 00:00:00 2001 From: Shaswat Raj Date: Sat, 14 Nov 2026 12:32:10 +0530 Subject: [PATCH 2/3] test(inline-toolbar): wait for the toolbar before pressing the shortcut Follow the pattern of the Link tool tests: select the text, give the Inline Toolbar time to open and register its shortcuts, then press the shortcut. Co-Authored-By: Claude Fable 5.2 --- .../cypress/tests/modules/InlineToolbar.cy.ts | 20 +++++++++++-------- 1 file changed, 12 insertions(+), 8 deletions(-) diff --git a/test/cypress/tests/modules/InlineToolbar.cy.ts b/test/cypress/tests/modules/InlineToolbar.cy.ts index aa01df708..47343e13e 100644 --- a/test/cypress/tests/modules/InlineToolbar.cy.ts +++ b/test/cypress/tests/modules/InlineToolbar.cy.ts @@ -384,26 +384,28 @@ describe('Inline Toolbar', () => { cy.createEditor({ data: paragraph('First editor text') }); createSecondEditor('Second editor text'); - /** Open the Inline Toolbar of the first Editor, so it registers its shortcuts first */ + /** Select the text and wait for the Inline Toolbar of the first Editor, so it registers its shortcuts first */ cy.get('[data-cy=editorjs]') .find('.ce-paragraph') .click() - .selectText('First') + .type('{selectall}') + .wait(200) .type('{cmd}b'); cy.get('[data-cy=editorjs]') .find('.ce-paragraph b') - .should('have.text', 'First'); + .should('have.text', 'First editor text'); cy.get('[data-cy=second-editorjs]') .find('.ce-paragraph') .click() - .selectText('Second') + .type('{selectall}') + .wait(200) .type('{cmd}b'); cy.get('[data-cy=second-editorjs]') .find('.ce-paragraph b') - .should('have.text', 'Second'); + .should('have.text', 'Second editor text'); /** The shortcut pressed in the second Editor must not format the first one again */ cy.get('[data-cy=editorjs]') @@ -418,7 +420,8 @@ describe('Inline Toolbar', () => { cy.get('[data-cy=editorjs]') .find('.ce-paragraph') .click() - .selectText('First') + .type('{selectall}') + .wait(200) .type('{cmd}b'); cy.get('@firstEditor').then((editor) => { @@ -430,12 +433,13 @@ describe('Inline Toolbar', () => { cy.get('[data-cy=second-editorjs]') .find('.ce-paragraph') .click() - .selectText('Second') + .type('{selectall}') + .wait(200) .type('{cmd}b'); cy.get('[data-cy=second-editorjs]') .find('.ce-paragraph b') - .should('have.text', 'Second'); + .should('have.text', 'Second editor text'); }); }); }); From 03f4e9183529822127ac3624af355df31a031f1e Mon Sep 17 00:00:00 2001 From: Shaswat Raj Date: Sun, 15 Nov 2026 00:50:45 +0530 Subject: [PATCH 3/3] test(inline-toolbar): check that destroy removes shortcut listeners from the document Co-Authored-By: Claude Fable 5.2 --- .../cypress/tests/modules/InlineToolbar.cy.ts | 58 +++++++++++++++++++ 1 file changed, 58 insertions(+) diff --git a/test/cypress/tests/modules/InlineToolbar.cy.ts b/test/cypress/tests/modules/InlineToolbar.cy.ts index 47343e13e..78be3f418 100644 --- a/test/cypress/tests/modules/InlineToolbar.cy.ts +++ b/test/cypress/tests/modules/InlineToolbar.cy.ts @@ -441,5 +441,63 @@ describe('Inline Toolbar', () => { .find('.ce-paragraph b') .should('have.text', 'Second editor text'); }); + + it('should remove shortcut listeners from the document when the Editor is destroyed', () => { + /** + * Keydown listeners that are bound to the document at the moment + */ + const keydownListeners = new Set(); + + cy.window().then((window) => { + const { document } = window; + const addEventListener = document.addEventListener.bind(document); + const removeEventListener = document.removeEventListener.bind(document); + + cy.stub(document, 'addEventListener').callsFake((type: string, listener: EventListenerOrEventListenerObject, options?: boolean | AddEventListenerOptions) => { + if (type === 'keydown') { + keydownListeners.add(listener); + } + + addEventListener(type, listener, options); + }); + + cy.stub(document, 'removeEventListener').callsFake((type: string, listener: EventListenerOrEventListenerObject, options?: boolean | EventListenerOptions) => { + if (type === 'keydown') { + keydownListeners.delete(listener); + } + + removeEventListener(type, listener, options); + }); + }); + + cy.createEditor({ data: paragraph('First editor text') }) + .as('firstEditor'); + + let listenersBeforeShortcuts = 0; + + cy.get('@firstEditor').then(() => { + listenersBeforeShortcuts = keydownListeners.size; + }); + + /** Inline Tools shortcuts are registered when the Inline Toolbar opens */ + cy.get('[data-cy=editorjs]') + .find('.ce-paragraph') + .click() + .type('{selectall}') + .wait(200) + .type('{cmd}b'); + + cy.get('[data-cy=editorjs]') + .find('.ce-paragraph b') + .should('have.text', 'First editor text'); + + cy.get('@firstEditor').then((editor) => { + expect(keydownListeners.size).to.be.greaterThan(listenersBeforeShortcuts); + + (editor as unknown as EditorJS).destroy(); + + expect(keydownListeners.size).to.eq(0); + }); + }); }); });