From 7da61e98ff02d392888317371cb7cdc7b1002cae Mon Sep 17 00:00:00 2001 From: Peter Date: Wed, 30 Apr 2025 19:48:20 +0300 Subject: [PATCH] improvement(caret): caret.setToBlock() offset argument improved (#2922) * chore(caret): caret.setToBlock offset improved * handle empty block * Update caret.cy.ts * fix eslint --- docs/CHANGELOG.md | 2 +- package.json | 2 +- src/components/dom.ts | 63 ++++ src/components/modules/caret.ts | 35 +- .../support/utils/createParagraphMock.ts | 19 ++ test/cypress/tests/api/caret.cy.ts | 310 +++++++++++++----- 6 files changed, 329 insertions(+), 102 deletions(-) create mode 100644 test/cypress/support/utils/createParagraphMock.ts diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 82aab654..5770a1e7 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -15,7 +15,7 @@ - `Fix` - Fix when / overides selected text outside of the editor - `DX` - Tools submodules removed from the repository - `Improvement` - Shift + Down/Up will allow to select next/previous line instead of Inline Toolbar flipping - +- `Improvement` - The API `caret.setToBlock()` offset now works across the entire block content, not just the first or last node. ### 2.30.7 diff --git a/package.json b/package.json index 172be410..8f60f1a0 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@editorjs/editorjs", - "version": "2.31.0-rc.9", + "version": "2.31.0-rc.10", "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/dom.ts b/src/components/dom.ts index 24104131..67573555 100644 --- a/src/components/dom.ts +++ b/src/components/dom.ts @@ -587,6 +587,69 @@ export default class Dom { right: left + rect.width, }; } + + /** + * Find text node and offset by total content offset + * + * @param {Node} root - root node to start search from + * @param {number} totalOffset - offset relative to the root node content + * @returns {{node: Node | null, offset: number}} - node and offset inside node + */ + public static getNodeByOffset(root: Node, totalOffset: number): {node: Node | null; offset: number} { + let currentOffset = 0; + let lastTextNode: Node | null = null; + + const walker = document.createTreeWalker( + root, + NodeFilter.SHOW_TEXT, + null + ); + + let node: Node | null = walker.nextNode(); + + while (node) { + const textContent = node.textContent; + const nodeLength = textContent === null ? 0 : textContent.length; + + lastTextNode = node; + + if (currentOffset + nodeLength >= totalOffset) { + break; + } + + currentOffset += nodeLength; + node = walker.nextNode(); + } + + /** + * If no node found or last node is empty, return null + */ + if (!lastTextNode) { + return { + node: null, + offset: 0, + }; + } + + const textContent = lastTextNode.textContent; + + if (textContent === null || textContent.length === 0) { + return { + node: null, + offset: 0, + }; + } + + /** + * Calculate offset inside found node + */ + const nodeOffset = Math.min(totalOffset - currentOffset, textContent.length); + + return { + node: lastTextNode, + offset: nodeOffset, + }; + } } /** diff --git a/src/components/modules/caret.ts b/src/components/modules/caret.ts index 276eef4b..db8a4f3b 100644 --- a/src/components/modules/caret.ts +++ b/src/components/modules/caret.ts @@ -43,7 +43,7 @@ export default class Caret extends Module { * @param {Block} block - Block class * @param {string} position - position where to set caret. * If default - leave default behaviour and apply offset if it's passed - * @param {number} offset - caret offset regarding to the text node + * @param {number} offset - caret offset regarding to the block content */ public setToBlock(block: Block, position: string = this.positions.DEFAULT, offset = 0): void { const { BlockManager, BlockSelection } = this.Editor; @@ -88,23 +88,32 @@ export default class Caret extends Module { return; } - const nodeToSet = $.getDeepestNode(element, position === this.positions.END); - const contentLength = $.getContentLength(nodeToSet); + let nodeToSet: Node; + let offsetToSet = offset; - switch (true) { - case position === this.positions.START: - offset = 0; - break; - case position === this.positions.END: - case offset > contentLength: - offset = contentLength; - break; + if (position === this.positions.START) { + nodeToSet = $.getDeepestNode(element, false) as Node; + offsetToSet = 0; + } else if (position === this.positions.END) { + nodeToSet = $.getDeepestNode(element, true) as Node; + offsetToSet = $.getContentLength(nodeToSet); + } else { + const { node, offset: nodeOffset } = $.getNodeByOffset(element, offset); + + if (node) { + nodeToSet = node; + offsetToSet = nodeOffset; + } else { // case for empty block's input + nodeToSet = $.getDeepestNode(element, false) as Node; + offsetToSet = 0; + } } - this.set(nodeToSet as HTMLElement, offset); + this.set(nodeToSet as HTMLElement, offsetToSet); BlockManager.setCurrentBlockByChildNode(block.holder); - BlockManager.currentBlock.currentInput = element; + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + BlockManager.currentBlock!.currentInput = element; } /** diff --git a/test/cypress/support/utils/createParagraphMock.ts b/test/cypress/support/utils/createParagraphMock.ts new file mode 100644 index 00000000..30166e87 --- /dev/null +++ b/test/cypress/support/utils/createParagraphMock.ts @@ -0,0 +1,19 @@ +import { nanoid } from 'nanoid'; + +/** + * Creates a paragraph mock + * + * @param text - text for the paragraph + * @returns paragraph mock + */ +export function createParagraphMock(text: string): { + id: string; + type: string; + data: { text: string }; +} { + return { + id: nanoid(), + type: 'paragraph', + data: { text }, + }; +} \ No newline at end of file diff --git a/test/cypress/tests/api/caret.cy.ts b/test/cypress/tests/api/caret.cy.ts index 882bc153..53a7d1fc 100644 --- a/test/cypress/tests/api/caret.cy.ts +++ b/test/cypress/tests/api/caret.cy.ts @@ -1,113 +1,249 @@ +import { createParagraphMock } from '../../support/utils/createParagraphMock'; import type EditorJS from '../../../../types'; /** * Test cases for Caret API */ describe('Caret API', () => { - const paragraphDataMock = { - id: 'bwnFX5LoX7', - type: 'paragraph', - data: { - text: 'The first block content mock.', - }, - }; - describe('.setToBlock()', () => { - /** - * The arrange part of the following tests are the same: - * - create an editor - * - move caret out of the block by default - */ - beforeEach(() => { - cy.createEditor({ - data: { - blocks: [ - paragraphDataMock, - ], - }, - }).as('editorInstance'); + describe('first argument', () => { + const paragraphDataMock = createParagraphMock('The first block content mock.'); /** - * Blur caret from the block before setting via api + * The arrange part of the following tests are the same: + * - create an editor + * - move caret out of the block by default */ - cy.get('[data-cy=editorjs]') - .click(); + beforeEach(() => { + cy.createEditor({ + data: { + blocks: [ + paragraphDataMock, + ], + }, + }).as('editorInstance'); + + /** + * Blur caret from the block before setting via api + */ + cy.get('[data-cy=editorjs]') + .click(); + }); + it('should set caret to a block (and return true) if block index is passed as argument', () => { + cy.get('@editorInstance') + .then(async (editor) => { + const returnedValue = editor.caret.setToBlock(0); + + /** + * Check that caret belongs block + */ + cy.window() + .then((window) => { + const selection = window.getSelection(); + const range = selection.getRangeAt(0); + + cy.get('[data-cy=editorjs]') + .find('.ce-block') + .first() + .should(($block) => { + expect($block[0].contains(range.startContainer)).to.be.true; + }); + }); + + expect(returnedValue).to.be.true; + }); + }); + + it('should set caret to a block (and return true) if block id is passed as argument', () => { + cy.get('@editorInstance') + .then(async (editor) => { + const returnedValue = editor.caret.setToBlock(paragraphDataMock.id); + + /** + * Check that caret belongs block + */ + cy.window() + .then((window) => { + const selection = window.getSelection(); + const range = selection.getRangeAt(0); + + cy.get('[data-cy=editorjs]') + .find('.ce-block') + .first() + .should(($block) => { + expect($block[0].contains(range.startContainer)).to.be.true; + }); + }); + + expect(returnedValue).to.be.true; + }); + }); + + it('should set caret to a block (and return true) if Block API is passed as argument', () => { + cy.get('@editorInstance') + .then(async (editor) => { + const block = editor.blocks.getById(paragraphDataMock.id); + const returnedValue = editor.caret.setToBlock(block); + + /** + * Check that caret belongs block + */ + cy.window() + .then((window) => { + const selection = window.getSelection(); + const range = selection.getRangeAt(0); + + cy.get('[data-cy=editorjs]') + .find('.ce-block') + .first() + .should(($block) => { + expect($block[0].contains(range.startContainer)).to.be.true; + }); + }); + + expect(returnedValue).to.be.true; + }); + }); }); - it('should set caret to a block (and return true) if block index is passed as argument', () => { - cy.get('@editorInstance') - .then(async (editor) => { - const returnedValue = editor.caret.setToBlock(0); + describe('offset', () => { + it('should set caret at specific offset in text content', () => { + const paragraphDataMock = createParagraphMock('Plain text content.'); - /** - * Check that caret belongs block - */ - cy.window() - .then((window) => { - const selection = window.getSelection(); - const range = selection.getRangeAt(0); + cy.createEditor({ + data: { + blocks: [ + paragraphDataMock, + ], + }, + }).as('editorInstance'); - cy.get('[data-cy=editorjs]') - .find('.ce-block') - .first() - .should(($block) => { - expect($block[0].contains(range.startContainer)).to.be.true; - }); - }); + cy.get('@editorInstance') + .then(async (editor) => { + const block = editor.blocks.getById(paragraphDataMock.id); - expect(returnedValue).to.be.true; - }); - }); + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + editor.caret.setToBlock(block!, 'default', 5); - it('should set caret to a block (and return true) if block id is passed as argument', () => { - cy.get('@editorInstance') - .then(async (editor) => { - const returnedValue = editor.caret.setToBlock(paragraphDataMock.id); + cy.window() + .then((window) => { + const selection = window.getSelection(); - /** - * Check that caret belongs block - */ - cy.window() - .then((window) => { - const selection = window.getSelection(); - const range = selection.getRangeAt(0); + if (!selection) { + throw new Error('Selection not found'); + } + const range = selection.getRangeAt(0); - cy.get('[data-cy=editorjs]') - .find('.ce-block') - .first() - .should(($block) => { - expect($block[0].contains(range.startContainer)).to.be.true; - }); - }); + expect(range.startOffset).to.equal(5); + }); + }); + }); - expect(returnedValue).to.be.true; - }); - }); + it('should set caret at correct offset when text contains HTML elements', () => { + const paragraphDataMock = createParagraphMock('1234567!'); - it('should set caret to a block (and return true) if Block API is passed as argument', () => { - cy.get('@editorInstance') - .then(async (editor) => { - const block = editor.blocks.getById(paragraphDataMock.id); - const returnedValue = editor.caret.setToBlock(block); + cy.createEditor({ + data: { + blocks: [ + paragraphDataMock, + ], + }, + }).as('editorInstance'); - /** - * Check that caret belongs block - */ - cy.window() - .then((window) => { - const selection = window.getSelection(); - const range = selection.getRangeAt(0); + cy.get('@editorInstance') + .then(async (editor) => { + const block = editor.blocks.getById(paragraphDataMock.id); - cy.get('[data-cy=editorjs]') - .find('.ce-block') - .first() - .should(($block) => { - expect($block[0].contains(range.startContainer)).to.be.true; - }); - }); + // Set caret after "12345" + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + editor.caret.setToBlock(block!, 'default', 6); - expect(returnedValue).to.be.true; - }); + cy.window() + .then((window) => { + const selection = window.getSelection(); + + if (!selection) { + throw new Error('Selection not found'); + } + const range = selection.getRangeAt(0); + + expect(range.startContainer.textContent).to.equal('567'); + expect(range.startOffset).to.equal(2); + }); + }); + }); + + it('should handle offset beyond content length', () => { + const paragraphDataMock = createParagraphMock('1234567890'); + + cy.createEditor({ + data: { + blocks: [ + paragraphDataMock, + ], + }, + }).as('editorInstance'); + + cy.get('@editorInstance') + .then(async (editor) => { + const block = editor.blocks.getById(paragraphDataMock.id); + + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + const contentLength = block!.holder.textContent?.length ?? 0; + + // Set caret beyond content length + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + editor.caret.setToBlock(block!, 'default', contentLength + 10); + + cy.window() + .then((window) => { + const selection = window.getSelection(); + + if (!selection) { + throw new Error('Selection not found'); + } + const range = selection.getRangeAt(0); + + // Should be at the end of content + expect(range.startOffset).to.equal(contentLength); + }); + }); + }); + + it('should handle offset in nested HTML structure', () => { + const paragraphDataMock = createParagraphMock('123456789!'); + + cy.createEditor({ + data: { + blocks: [ + paragraphDataMock, + ], + }, + }).as('editorInstance'); + + cy.get('@editorInstance') + .then(async (editor) => { + const block = editor.blocks.getById(paragraphDataMock.id); + + + // Set caret after "8" + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + editor.caret.setToBlock(block!, 'default', 8); + + cy.window() + .then((window) => { + const selection = window.getSelection(); + + if (!selection) { + throw new Error('Selection not found'); + } + const range = selection.getRangeAt(0); + + expect(range.startContainer.textContent).to.equal('789'); + expect(range.startOffset).to.equal(2); + }); + }); + }); }); }); });