Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions docs/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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`
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
2 changes: 1 addition & 1 deletion src/components/modules/toolbar/inline.ts
Original file line number Diff line number Diff line change
Expand Up @@ -110,7 +110,7 @@ export default class InlineToolbar extends Module<InlineToolbarNodes> {
const shortcut = this.getToolShortcut(tool.name);

if (shortcut !== undefined) {
Shortcuts.remove(this.Editor.UI.nodes.redactor, shortcut);
Shortcuts.remove(document, shortcut);
}

/**
Expand Down
16 changes: 4 additions & 12 deletions src/components/utils/shortcuts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,24 +41,16 @@ class Shortcuts {
/**
* All registered shortcuts
*
* @type {Map<Element, Shortcut[]>}
* @type {Map<HTMLElement | Document, Shortcut[]>}
*/
private registeredShortcuts: Map<Element, Shortcut[]> = new Map();
private registeredShortcuts: Map<HTMLElement | Document, Shortcut[]> = new Map();

/**
* Register shortcut
*
* @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,
Expand All @@ -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) {
Expand Down Expand Up @@ -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);
Expand Down
144 changes: 132 additions & 12 deletions test/cypress/tests/ui/InlineToolbar.cy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<void>((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');
Comment on lines +293 to +294
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@copilot Look like if you will remove your changes from "src/components/utils/shortcuts.ts", this test will still be passed

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in c5bff13. The test now uses cy.clock() to freeze the selectionchange debounce after editor 1's shortcut is registered, then calls editor2.inlineToolbar.open() via the public API before that debounce fires. At that moment editor 1's CMD+SHIFT+M handler is still live on document, so enableShortcuts() hits the duplicate-registration path. Without the shortcuts.ts fix (the removed throw) it silently fails to register editor 2's shortcut → toolActivated2 never fires → test fails. Ticking past the debounce afterwards also exercises the inline.ts fix, confirming editor 1's shortcut is removed correctly.

});

it('should work in read-only mode', () => {
const toolSurround = cy.stub().as('toolSurround');

Expand Down Expand Up @@ -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');
});
Expand Down
Loading