From 8a6da99c3695b7b2461ea4b8511613e832cbd126 Mon Sep 17 00:00:00 2001 From: VolgaIgor <43250768+VolgaIgor@users.noreply.github.com> Date: Thu, 13 Nov 2025 12:23:56 +0300 Subject: [PATCH 1/3] Fixed incorrect identify of the current block if a nested editor is used in it --- src/components/modules/ui.ts | 48 ++++++++++++++++++++++++++++++------ 1 file changed, 41 insertions(+), 7 deletions(-) diff --git a/src/components/modules/ui.ts b/src/components/modules/ui.ts index a4d3baad3..2b4690aa8 100644 --- a/src/components/modules/ui.ts +++ b/src/components/modules/ui.ts @@ -418,11 +418,9 @@ export default class UI extends Module { /** * Used to not emit the same block multiple times to the 'block-hovered' event on every mousemove */ - let blockHoveredEmitted; + let blockHoveredEmitted: Element; this.readOnlyMutableListeners.on(this.nodes.redactor, 'mousemove', _.throttle((event: MouseEvent | TouchEvent) => { - const hoveredBlock = (event.target as Element).closest('.ce-block'); - /** * Do not trigger 'block-hovered' for cross-block selection */ @@ -430,18 +428,21 @@ export default class UI extends Module { return; } + const hoveredElement = event.target as Element; + const hoveredBlock = this.findByNodeBlockBelongsToCurrentInstance(hoveredElement); + if (!hoveredBlock) { return; } - if (blockHoveredEmitted === hoveredBlock) { + if (blockHoveredEmitted === hoveredBlock.holder) { return; } - blockHoveredEmitted = hoveredBlock; + blockHoveredEmitted = hoveredBlock.holder; this.eventsDispatcher.emit(BlockHovered, { - block: this.Editor.BlockManager.getBlockByChildNode(hoveredBlock), + block: hoveredBlock, }); // eslint-disable-next-line @typescript-eslint/no-magic-numbers }, 20), { @@ -734,7 +735,12 @@ export default class UI extends Module { * Select clicked Block as Current */ try { - this.Editor.BlockManager.setCurrentBlockByChildNode(clickedNode); + const blockNode = this.findByNodeBlockBelongsToCurrentInstance(clickedNode); + if (!blockNode) { + return; + } + + this.Editor.BlockManager.setCurrentBlockByChildNode(blockNode.holder); } catch (e) { /** * If clicked outside first-level Blocks and it is not RectSelection, set Caret to the last empty Block @@ -930,4 +936,32 @@ export default class UI extends Module { this.readOnlyMutableListeners.on(this.nodes.wrapper, 'focusin', handleInputOrFocusChange); this.readOnlyMutableListeners.on(this.nodes.wrapper, 'focusout', handleInputOrFocusChange); } + + /** + * Find a Block belonging to the current editor instance by element + * + * It is necessary for the case of nested editors. + * This allows each editor to send and process events only for blocks belong the current editor. + * And also allows for top level editors to identify in which block + * of their instance the event occurred in a nested editor block. + */ + private findByNodeBlockBelongsToCurrentInstance(node: Element): Block|null { + let blockNode; + + while (blockNode = node.closest(`.${Block.CSS.wrapper}`)) { + const blockInstance = this.Editor.BlockManager.getBlockByChildNode(blockNode); + if (blockInstance) { + return blockInstance; + } + + const editorWrapper = blockNode.closest(`.${this.CSS.editorWrapper}`); + if (!editorWrapper) { + return null; + } + + node = editorWrapper; + } + + return null; + } } From 2987fcc8957a30d97b5e4fc88b2da7302365ea77 Mon Sep 17 00:00:00 2001 From: VolgaIgor <43250768+VolgaIgor@users.noreply.github.com> Date: Sat, 3 Jan 2026 15:54:47 +0300 Subject: [PATCH 2/3] Added test to check the current block id in the nested editor --- .../fixtures/tools/NestedEditorTool.ts | 123 ++++++++++++++++++ test/cypress/tests/modules/Ui.cy.ts | 65 +++++++++ 2 files changed, 188 insertions(+) create mode 100644 test/cypress/fixtures/tools/NestedEditorTool.ts diff --git a/test/cypress/fixtures/tools/NestedEditorTool.ts b/test/cypress/fixtures/tools/NestedEditorTool.ts new file mode 100644 index 000000000..4ec7ee00d --- /dev/null +++ b/test/cypress/fixtures/tools/NestedEditorTool.ts @@ -0,0 +1,123 @@ +import type { + API, + EditorConfig, + BaseTool, + BlockAPI, + BlockToolConstructorOptions, + OutputData +} from '../../../../types'; +import EditorJS from '../../../../types'; + +export interface NestedEditorToolConfig { + editorLibrary: typeof EditorJS, + editorTools?: EditorConfig["tools"], + editorTunes?: EditorConfig["tunes"], +} + +export interface NestedEditorToolData { + nestedEditor: OutputData, +} + +/** + * Simplified Header for testing + */ +export class NestedEditorTool implements BaseTool { + private api: API; + private readOnly: boolean; + private block: BlockAPI; + + private config: NestedEditorToolConfig; + private _data: NestedEditorToolData; + + private nestedEditor?: EditorJS; + + /** + * This flag tells core that current tool supports the read-only mode + * @link https://editorjs.io/tools-api#isreadonlysupported + */ + static get isReadOnlySupported(): boolean { + return true; + } + + /** + * With this option, Editor.js won't handle Enter keydowns + * @link https://editorjs.io/tools-api#enablelinebreaks + */ + static get enableLineBreaks(): boolean { + return true; + } + + /** + * + * @param options - constructor options + */ + constructor({ data, api, config, readOnly, block }: BlockToolConstructorOptions) { + this.api = api; + this._data = data; + this.config = { + editorLibrary: config.editorLibrary, + editorTools: config.editorTools || {}, + editorTunes: config.editorTunes || [], + }; + this.readOnly = readOnly; + this.block = block; + } + + /** + * Return Tool's view + */ + public render(): HTMLElement { + const rootNode = document.createElement('div'); + rootNode.classList.add(this.api.styles.block); + + const editorNode = document.createElement('div'); + editorNode.style.border = '1px solid' + editorNode.style.padding = '12px 20px' + rootNode.appendChild(editorNode); + + this.nestedEditor = new this.config.editorLibrary({ + holder: editorNode, + data: this.data.nestedEditor, + tools: this.config.editorTools, + tunes: this.config.editorTunes, + minHeight: 150, + readOnly: this.readOnly, + onChange: () => { + this.block.dispatchChange() + } + }); + + return rootNode; + } + + /** + * Return Tool data + */ + get data(): NestedEditorToolData { + return this._data; + } + + /** + * Stores all Tool's data + */ + set data(data: NestedEditorToolData) { + this._data.nestedEditor = data.nestedEditor || {}; + if (this.nestedEditor) { + this.nestedEditor.blocks.render(this.data.nestedEditor); + } + } + + /** + * Extracts Block data from the UI + */ + public async save(): Promise { + let savedData: OutputData = { blocks: [] } + if (this.nestedEditor) { + savedData = await this.nestedEditor.save(); + } + + return { + nestedEditor: savedData + }; + } +} diff --git a/test/cypress/tests/modules/Ui.cy.ts b/test/cypress/tests/modules/Ui.cy.ts index eaf2246a8..afa63426c 100644 --- a/test/cypress/tests/modules/Ui.cy.ts +++ b/test/cypress/tests/modules/Ui.cy.ts @@ -1,5 +1,6 @@ import { createEditorWithTextBlocks } from '../../support/utils/createEditorWithTextBlocks'; import type EditorJS from '../../../../types/index'; +import { NestedEditorTool } from '../../fixtures/tools/NestedEditorTool'; describe('Ui module', function () { describe('documentKeydown', function () { @@ -139,5 +140,69 @@ describe('Ui module', function () { expect(currentBlockIndex).to.eq(1); }); }); + + it('click on block of nested editor should also set the block of parent editor as current', function () { + cy.window() + .then((window) => { + const editorJsClass = window.EditorJS; + + cy.createEditor({ + tools: { + nestedEditor: { + class: NestedEditorTool, + config: { + editorLibrary: editorJsClass + } + }, + }, + data: { + blocks: [ + { + id: 'block1', + type: 'paragraph', + data: { + text: 'First block of parent editor', + }, + }, + { + id: 'block2', + type: 'paragraph', + data: { + text: 'Second block of parent editor', + }, + }, + { + id: 'block3', + type: 'nestedEditor', + data: { + nestedEditor: { + blocks: [ + { + id: 'nestedBlock1', + type: 'paragraph', + data: { + text: 'First block of nested editor', + }, + } + ] + } + } + } + ], + }, + }).as('editorInstance'); + }); + + cy.get('[data-id=nestedBlock1]') + .find('.ce-paragraph') + .click(); + + cy.get('@editorInstance') + .then(async (editor) => { + const currentBlockIndex = editor.blocks.getCurrentBlockIndex(); + + expect(currentBlockIndex).to.eq(2); // 3 block in numbering from 0 + }); + }); }); }); From 4423629b0002fcddd1ab4797e39faf24167f0c15 Mon Sep 17 00:00:00 2001 From: VolgaIgor <43250768+VolgaIgor@users.noreply.github.com> Date: Fri, 9 Jan 2026 14:21:25 +0300 Subject: [PATCH 3/3] Instead of a new class for NestedEditor an existing was used --- .../fixtures/tools/NestedEditorTool.ts | 123 ------------------ test/cypress/tests/modules/Ui.cy.ts | 77 ++++------- 2 files changed, 28 insertions(+), 172 deletions(-) delete mode 100644 test/cypress/fixtures/tools/NestedEditorTool.ts diff --git a/test/cypress/fixtures/tools/NestedEditorTool.ts b/test/cypress/fixtures/tools/NestedEditorTool.ts deleted file mode 100644 index 4ec7ee00d..000000000 --- a/test/cypress/fixtures/tools/NestedEditorTool.ts +++ /dev/null @@ -1,123 +0,0 @@ -import type { - API, - EditorConfig, - BaseTool, - BlockAPI, - BlockToolConstructorOptions, - OutputData -} from '../../../../types'; -import EditorJS from '../../../../types'; - -export interface NestedEditorToolConfig { - editorLibrary: typeof EditorJS, - editorTools?: EditorConfig["tools"], - editorTunes?: EditorConfig["tunes"], -} - -export interface NestedEditorToolData { - nestedEditor: OutputData, -} - -/** - * Simplified Header for testing - */ -export class NestedEditorTool implements BaseTool { - private api: API; - private readOnly: boolean; - private block: BlockAPI; - - private config: NestedEditorToolConfig; - private _data: NestedEditorToolData; - - private nestedEditor?: EditorJS; - - /** - * This flag tells core that current tool supports the read-only mode - * @link https://editorjs.io/tools-api#isreadonlysupported - */ - static get isReadOnlySupported(): boolean { - return true; - } - - /** - * With this option, Editor.js won't handle Enter keydowns - * @link https://editorjs.io/tools-api#enablelinebreaks - */ - static get enableLineBreaks(): boolean { - return true; - } - - /** - * - * @param options - constructor options - */ - constructor({ data, api, config, readOnly, block }: BlockToolConstructorOptions) { - this.api = api; - this._data = data; - this.config = { - editorLibrary: config.editorLibrary, - editorTools: config.editorTools || {}, - editorTunes: config.editorTunes || [], - }; - this.readOnly = readOnly; - this.block = block; - } - - /** - * Return Tool's view - */ - public render(): HTMLElement { - const rootNode = document.createElement('div'); - rootNode.classList.add(this.api.styles.block); - - const editorNode = document.createElement('div'); - editorNode.style.border = '1px solid' - editorNode.style.padding = '12px 20px' - rootNode.appendChild(editorNode); - - this.nestedEditor = new this.config.editorLibrary({ - holder: editorNode, - data: this.data.nestedEditor, - tools: this.config.editorTools, - tunes: this.config.editorTunes, - minHeight: 150, - readOnly: this.readOnly, - onChange: () => { - this.block.dispatchChange() - } - }); - - return rootNode; - } - - /** - * Return Tool data - */ - get data(): NestedEditorToolData { - return this._data; - } - - /** - * Stores all Tool's data - */ - set data(data: NestedEditorToolData) { - this._data.nestedEditor = data.nestedEditor || {}; - if (this.nestedEditor) { - this.nestedEditor.blocks.render(this.data.nestedEditor); - } - } - - /** - * Extracts Block data from the UI - */ - public async save(): Promise { - let savedData: OutputData = { blocks: [] } - if (this.nestedEditor) { - savedData = await this.nestedEditor.save(); - } - - return { - nestedEditor: savedData - }; - } -} diff --git a/test/cypress/tests/modules/Ui.cy.ts b/test/cypress/tests/modules/Ui.cy.ts index afa63426c..c23268977 100644 --- a/test/cypress/tests/modules/Ui.cy.ts +++ b/test/cypress/tests/modules/Ui.cy.ts @@ -1,6 +1,6 @@ import { createEditorWithTextBlocks } from '../../support/utils/createEditorWithTextBlocks'; import type EditorJS from '../../../../types/index'; -import { NestedEditorTool } from '../../fixtures/tools/NestedEditorTool'; +import NestedEditor, { NESTED_EDITOR_ID } from '../../support/utils/nestedEditorInstance'; describe('Ui module', function () { describe('documentKeydown', function () { @@ -142,58 +142,37 @@ describe('Ui module', function () { }); it('click on block of nested editor should also set the block of parent editor as current', function () { - cy.window() - .then((window) => { - const editorJsClass = window.EditorJS; - - cy.createEditor({ - tools: { - nestedEditor: { - class: NestedEditorTool, - config: { - editorLibrary: editorJsClass - } + cy.createEditor({ + tools: { + nestedEditor: { + class: NestedEditor, + }, + }, + data: { + blocks: [ + { + type: 'paragraph', + data: { + text: 'First block of parent editor', }, }, - data: { - blocks: [ - { - id: 'block1', - type: 'paragraph', - data: { - text: 'First block of parent editor', - }, - }, - { - id: 'block2', - type: 'paragraph', - data: { - text: 'Second block of parent editor', - }, - }, - { - id: 'block3', - type: 'nestedEditor', - data: { - nestedEditor: { - blocks: [ - { - id: 'nestedBlock1', - type: 'paragraph', - data: { - text: 'First block of nested editor', - }, - } - ] - } - } - } - ], + { + type: 'paragraph', + data: { + text: 'Second block of parent editor', + }, }, - }).as('editorInstance'); - }); + { + type: 'nestedEditor', + data: { + text: 'First block of nested editor', + }, + }, + ], + }, + }).as('editorInstance'); - cy.get('[data-id=nestedBlock1]') + cy.get(`[data-cy=${NESTED_EDITOR_ID}]`) .find('.ce-paragraph') .click();