improvement(caret): caret.setToBlock() offset argument improved (#2922)

* chore(caret): caret.setToBlock offset improved

* handle empty block

* Update caret.cy.ts

* fix eslint
This commit is contained in:
Peter 2025-04-30 19:48:20 +03:00 committed by GitHub
commit 7da61e98ff
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 338 additions and 111 deletions

View file

@ -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

View file

@ -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",

View file

@ -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,
};
}
}
/**

View file

@ -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;
}
/**

View file

@ -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 },
};
}

View file

@ -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<EditorJS>('@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<EditorJS>('@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<EditorJS>('@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<EditorJS>('@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<EditorJS>('@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<EditorJS>('@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('1234<b>567</b>!');
it('should set caret to a block (and return true) if Block API is passed as argument', () => {
cy.get<EditorJS>('@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<EditorJS>('@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<EditorJS>('@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('123<b>456<i>789</i></b>!');
cy.createEditor({
data: {
blocks: [
paragraphDataMock,
],
},
}).as('editorInstance');
cy.get<EditorJS>('@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);
});
});
});
});
});
});