mirror of
https://github.com/codex-team/editor.js
synced 2026-03-14 14:45:47 +01:00
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:
parent
fdaef55e5f
commit
7da61e98ff
6 changed files with 338 additions and 111 deletions
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
19
test/cypress/support/utils/createParagraphMock.ts
Normal file
19
test/cypress/support/utils/createParagraphMock.ts
Normal 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 },
|
||||
};
|
||||
}
|
||||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue