diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 82aab6541..5770a1e74 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 172be4105..8f60f1a0d 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 241041315..675735555 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 276eef4b0..db8a4f3b0 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 000000000..30166e87e --- /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 882bc1534..53a7d1fc4 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'); - 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; - }); - }); + /** + * 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); - 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; - }); + /** + * 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; - }); + describe('offset', () => { + it('should set caret at specific offset in text content', () => { + const paragraphDataMock = createParagraphMock('Plain text content.'); + + 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 + editor.caret.setToBlock(block!, 'default', 5); + + cy.window() + .then((window) => { + const selection = window.getSelection(); + + if (!selection) { + throw new Error('Selection not found'); + } + const range = selection.getRangeAt(0); + + expect(range.startOffset).to.equal(5); + }); + }); + }); + + it('should set caret at correct offset when text contains HTML elements', () => { + const paragraphDataMock = createParagraphMock('1234567!'); + + cy.createEditor({ + data: { + blocks: [ + paragraphDataMock, + ], + }, + }).as('editorInstance'); + + cy.get('@editorInstance') + .then(async (editor) => { + const block = editor.blocks.getById(paragraphDataMock.id); + + // Set caret after "12345" + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + editor.caret.setToBlock(block!, 'default', 6); + + 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); + }); + }); + }); }); }); });