diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 72acee3cb..bf41ae978 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -6,6 +6,7 @@ - `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` - Invoke tool `reset()` methods on `Tools.destroy()`, catching and logging any errors as warnings +- `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..78be3f418 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,156 @@ 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'); + + /** 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() + .type('{selectall}') + .wait(200) + .type('{cmd}b'); + + cy.get('[data-cy=editorjs]') + .find('.ce-paragraph b') + .should('have.text', 'First editor text'); + + cy.get('[data-cy=second-editorjs]') + .find('.ce-paragraph') + .click() + .type('{selectall}') + .wait(200) + .type('{cmd}b'); + + cy.get('[data-cy=second-editorjs]') + .find('.ce-paragraph b') + .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]') + .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() + .type('{selectall}') + .wait(200) + .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() + .type('{selectall}') + .wait(200) + .type('{cmd}b'); + + cy.get('[data-cy=second-editorjs]') + .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); + }); + }); + }); });