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
1 change: 1 addition & 0 deletions docs/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

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.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",
Expand Down
43 changes: 30 additions & 13 deletions src/components/modules/toolbar/inline.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -57,6 +57,13 @@ export default class InlineToolbar extends Module<InlineToolbarNodes> {
*/
private tools: Map<InlineToolAdapter, IInlineTool> = 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<string, Shortcut> = new Map();

/**
* @param moduleConfiguration - Module Configuration
* @param moduleConfiguration.config - Editor's config
Expand Down Expand Up @@ -106,13 +113,7 @@ export default class InlineToolbar extends Module<InlineToolbarNodes> {
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'
*/
Expand Down Expand Up @@ -148,6 +149,9 @@ export default class InlineToolbar extends Module<InlineToolbarNodes> {
* Removes UI and its components
*/
public destroy(): void {
this.registeredShortcuts.forEach((shortcut) => shortcut.remove());
this.registeredShortcuts.clear();
Comment on lines +152 to +153

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Added in 03f4e91: the new test records the keydown listeners bound to the document, checks that opening the Inline Toolbar adds some, and expects none to be left after destroy(). With the two cleanup lines removed, three listeners stay bound, so the test fails without them.


this.removeAllNodes();
this.popover?.destroy();
this.popover = null;
Expand Down Expand Up @@ -374,9 +378,7 @@ export default class InlineToolbar extends Module<InlineToolbarNodes> {
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;
Expand Down Expand Up @@ -523,9 +525,22 @@ export default class InlineToolbar extends Module<InlineToolbarNodes> {
* @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;

/**
Expand Down Expand Up @@ -555,6 +570,8 @@ export default class InlineToolbar extends Module<InlineToolbarNodes> {
*/
on: document,
});

this.registeredShortcuts.set(shortcut, registeredShortcut);
}

/**
Expand Down
153 changes: 153 additions & 0 deletions test/cypress/tests/modules/InlineToolbar.cy.ts
Original file line number Diff line number Diff line change
@@ -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', () => {
Expand Down Expand Up @@ -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<EventListenerOrEventListenerObject>();

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);
});
});
});
});
Loading