mirror of
https://github.com/codex-team/editor.js
synced 2026-03-16 15:45:47 +01:00
fix: add tests and fix lint issues in caret.ts
This commit is contained in:
parent
cce5037113
commit
cae0b37779
5 changed files with 1562 additions and 345 deletions
|
|
@ -4,6 +4,81 @@ import type Block from '../block';
|
|||
import * as caretUtils from '../utils/caret';
|
||||
import $ from '../dom';
|
||||
|
||||
const ASCII_MAX_CODE_POINT = 0x7f;
|
||||
|
||||
/**
|
||||
* Determines whether the provided text is comprised only of punctuation and whitespace characters.
|
||||
*
|
||||
* @param text - text to check
|
||||
*/
|
||||
const isPunctuationOnly = (text: string): boolean => {
|
||||
for (const character of text) {
|
||||
if (character.trim().length === 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (character >= '0' && character <= '9') {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (character.toLowerCase() !== character.toUpperCase()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const codePoint = character.codePointAt(0);
|
||||
|
||||
if (typeof codePoint === 'number' && codePoint > ASCII_MAX_CODE_POINT) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
const collectTextNodes = (node: Node): Text[] => {
|
||||
if (node.nodeType === Node.TEXT_NODE) {
|
||||
return [ node as Text ];
|
||||
}
|
||||
|
||||
if (!node.hasChildNodes?.()) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return Array.from(node.childNodes).flatMap((child) => collectTextNodes(child));
|
||||
};
|
||||
|
||||
/**
|
||||
* Finds last text node suitable for placing caret near the end of the element.
|
||||
*
|
||||
* Prefers nodes that contain more than just punctuation so caret remains inside formatting nodes
|
||||
* whenever possible.
|
||||
*
|
||||
* @param root - element to search within
|
||||
*/
|
||||
const findLastMeaningfulTextNode = (root: HTMLElement): Text | null => {
|
||||
const textNodes = collectTextNodes(root);
|
||||
|
||||
if (textNodes.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const lastTextNode = textNodes[textNodes.length - 1];
|
||||
const lastMeaningfulNode = [ ...textNodes ]
|
||||
.reverse()
|
||||
.find((node) => !isPunctuationOnly(node.textContent ?? '')) ?? null;
|
||||
|
||||
if (
|
||||
lastMeaningfulNode &&
|
||||
lastMeaningfulNode !== lastTextNode &&
|
||||
isPunctuationOnly(lastTextNode.textContent ?? '') &&
|
||||
lastMeaningfulNode.parentNode !== root
|
||||
) {
|
||||
return lastMeaningfulNode;
|
||||
}
|
||||
|
||||
return lastTextNode;
|
||||
};
|
||||
|
||||
/**
|
||||
* Caret
|
||||
* Contains methods for working Caret
|
||||
|
|
@ -71,42 +146,84 @@ export default class Caret extends Module {
|
|||
return;
|
||||
}
|
||||
|
||||
let element;
|
||||
const getElement = (): HTMLElement | undefined => {
|
||||
if (position === this.positions.START) {
|
||||
return block.firstInput;
|
||||
}
|
||||
|
||||
switch (position) {
|
||||
case this.positions.START:
|
||||
element = block.firstInput;
|
||||
break;
|
||||
case this.positions.END:
|
||||
element = block.lastInput;
|
||||
break;
|
||||
default:
|
||||
element = block.currentInput;
|
||||
}
|
||||
if (position === this.positions.END) {
|
||||
return block.lastInput;
|
||||
}
|
||||
|
||||
return block.currentInput;
|
||||
};
|
||||
|
||||
const element = getElement();
|
||||
|
||||
if (!element) {
|
||||
return;
|
||||
}
|
||||
|
||||
let nodeToSet: Node;
|
||||
let offsetToSet = offset;
|
||||
const getNodeAndOffset = (el: HTMLElement): { node: Node | null; offset: number } => {
|
||||
if (position === this.positions.START) {
|
||||
return {
|
||||
node: $.getDeepestNode(el, false),
|
||||
offset: 0,
|
||||
};
|
||||
}
|
||||
|
||||
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 (position === this.positions.END) {
|
||||
const nodeToSet = $.getDeepestNode(el, true);
|
||||
|
||||
if (nodeToSet instanceof HTMLElement && $.isNativeInput(nodeToSet)) {
|
||||
return {
|
||||
node: nodeToSet,
|
||||
offset: $.getContentLength(nodeToSet),
|
||||
};
|
||||
}
|
||||
|
||||
const meaningfulTextNode = findLastMeaningfulTextNode(el);
|
||||
|
||||
if (meaningfulTextNode) {
|
||||
return {
|
||||
node: meaningfulTextNode,
|
||||
offset: meaningfulTextNode.textContent?.length ?? 0,
|
||||
};
|
||||
}
|
||||
|
||||
if (nodeToSet) {
|
||||
return {
|
||||
node: nodeToSet,
|
||||
offset: $.getContentLength(nodeToSet),
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
node: null,
|
||||
offset: 0,
|
||||
};
|
||||
}
|
||||
|
||||
const { node, offset: nodeOffset } = $.getNodeByOffset(el, offset);
|
||||
|
||||
if (node) {
|
||||
nodeToSet = node;
|
||||
offsetToSet = nodeOffset;
|
||||
} else { // case for empty block's input
|
||||
nodeToSet = $.getDeepestNode(element, false) as Node;
|
||||
offsetToSet = 0;
|
||||
return {
|
||||
node,
|
||||
offset: nodeOffset,
|
||||
};
|
||||
}
|
||||
|
||||
// case for empty block's input
|
||||
return {
|
||||
node: $.getDeepestNode(el, false),
|
||||
offset: 0,
|
||||
};
|
||||
};
|
||||
|
||||
const { node: nodeToSet, offset: offsetToSet } = getNodeAndOffset(element);
|
||||
|
||||
if (!nodeToSet) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.set(nodeToSet as HTMLElement, offsetToSet);
|
||||
|
|
@ -134,7 +251,9 @@ export default class Caret extends Module {
|
|||
break;
|
||||
|
||||
case this.positions.END:
|
||||
this.set(nodeToSet as HTMLElement, $.getContentLength(nodeToSet));
|
||||
if (nodeToSet) {
|
||||
this.set(nodeToSet as HTMLElement, $.getContentLength(nodeToSet));
|
||||
}
|
||||
break;
|
||||
|
||||
default:
|
||||
|
|
@ -143,7 +262,9 @@ export default class Caret extends Module {
|
|||
}
|
||||
}
|
||||
|
||||
currentBlock.currentInput = input;
|
||||
if (currentBlock) {
|
||||
currentBlock.currentInput = input;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -197,39 +318,50 @@ export default class Caret extends Module {
|
|||
public extractFragmentFromCaretPosition(): void|DocumentFragment {
|
||||
const selection = Selection.get();
|
||||
|
||||
if (selection.rangeCount) {
|
||||
const selectRange = selection.getRangeAt(0);
|
||||
const currentBlockInput = this.Editor.BlockManager.currentBlock.currentInput;
|
||||
|
||||
selectRange.deleteContents();
|
||||
|
||||
if (currentBlockInput) {
|
||||
if ($.isNativeInput(currentBlockInput)) {
|
||||
/**
|
||||
* If input is native text input we need to use it's value
|
||||
* Text before the caret stays in the input,
|
||||
* while text after the caret is returned as a fragment to be inserted after the block.
|
||||
*/
|
||||
const input = currentBlockInput as HTMLInputElement | HTMLTextAreaElement;
|
||||
const newFragment = document.createDocumentFragment();
|
||||
|
||||
const inputRemainingText = input.value.substring(0, input.selectionStart);
|
||||
const fragmentText = input.value.substring(input.selectionStart);
|
||||
|
||||
newFragment.textContent = fragmentText;
|
||||
input.value = inputRemainingText;
|
||||
|
||||
return newFragment;
|
||||
} else {
|
||||
const range = selectRange.cloneRange();
|
||||
|
||||
range.selectNodeContents(currentBlockInput);
|
||||
range.setStart(selectRange.endContainer, selectRange.endOffset);
|
||||
|
||||
return range.extractContents();
|
||||
}
|
||||
}
|
||||
if (!selection || !selection.rangeCount) {
|
||||
return;
|
||||
}
|
||||
|
||||
const selectRange = selection.getRangeAt(0);
|
||||
const currentBlock = this.Editor.BlockManager.currentBlock;
|
||||
|
||||
if (!currentBlock) {
|
||||
return;
|
||||
}
|
||||
|
||||
const currentBlockInput = currentBlock.currentInput;
|
||||
|
||||
selectRange.deleteContents();
|
||||
|
||||
if (!currentBlockInput) {
|
||||
return;
|
||||
}
|
||||
|
||||
if ($.isNativeInput(currentBlockInput)) {
|
||||
/**
|
||||
* If input is native text input we need to use it's value
|
||||
* Text before the caret stays in the input,
|
||||
* while text after the caret is returned as a fragment to be inserted after the block.
|
||||
*/
|
||||
const input = currentBlockInput as HTMLInputElement | HTMLTextAreaElement;
|
||||
const newFragment = document.createDocumentFragment();
|
||||
const selectionStart = input.selectionStart ?? 0;
|
||||
|
||||
const inputRemainingText = input.value.substring(0, selectionStart);
|
||||
const fragmentText = input.value.substring(selectionStart);
|
||||
|
||||
newFragment.textContent = fragmentText;
|
||||
input.value = inputRemainingText;
|
||||
|
||||
return newFragment;
|
||||
}
|
||||
|
||||
const range = selectRange.cloneRange();
|
||||
|
||||
range.selectNodeContents(currentBlockInput);
|
||||
range.setStart(selectRange.endContainer, selectRange.endOffset);
|
||||
|
||||
return range.extractContents();
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -250,8 +382,6 @@ export default class Caret extends Module {
|
|||
const { nextInput, currentInput } = currentBlock;
|
||||
const isAtEnd = currentInput !== undefined ? caretUtils.isCaretAtEndOfInput(currentInput) : undefined;
|
||||
|
||||
let blockToNavigate = nextBlock;
|
||||
|
||||
/**
|
||||
* We should jump to the next block if:
|
||||
* - 'force' is true (Tab-navigation)
|
||||
|
|
@ -267,7 +397,11 @@ export default class Caret extends Module {
|
|||
return true;
|
||||
}
|
||||
|
||||
if (blockToNavigate === null) {
|
||||
const getBlockToNavigate = (): Block | null => {
|
||||
if (nextBlock !== null) {
|
||||
return nextBlock;
|
||||
}
|
||||
|
||||
/**
|
||||
* This code allows to exit from the last non-initial tool:
|
||||
* https://github.com/codex-team/editor.js/issues/1103
|
||||
|
|
@ -279,17 +413,19 @@ export default class Caret extends Module {
|
|||
* (https://github.com/codex-team/editor.js/issues/1414)
|
||||
*/
|
||||
if (currentBlock.tool.isDefault || !navigationAllowed) {
|
||||
return false;
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* If there is no nextBlock, but currentBlock is not default,
|
||||
* insert new default block at the end and navigate to it
|
||||
*/
|
||||
blockToNavigate = BlockManager.insertAtEnd() as Block;
|
||||
}
|
||||
return BlockManager.insertAtEnd() as Block;
|
||||
};
|
||||
|
||||
if (navigationAllowed) {
|
||||
const blockToNavigate = getBlockToNavigate();
|
||||
|
||||
if (blockToNavigate !== null && navigationAllowed) {
|
||||
this.setToBlock(blockToNavigate, this.positions.START);
|
||||
|
||||
return true;
|
||||
|
|
@ -392,6 +528,10 @@ export default class Caret extends Module {
|
|||
const selection = Selection.get();
|
||||
const range = Selection.range;
|
||||
|
||||
if (!selection || !range) {
|
||||
return;
|
||||
}
|
||||
|
||||
wrapper.innerHTML = content;
|
||||
|
||||
Array.from(wrapper.childNodes).forEach((child: Node) => fragment.appendChild(child));
|
||||
|
|
|
|||
|
|
@ -7,17 +7,17 @@ import $, { isCollapsedWhitespaces } from '../dom';
|
|||
* Handles a case when focusNode is an ElementNode and focusOffset is a child index,
|
||||
* returns child node with focusOffset index as a new focusNode
|
||||
*/
|
||||
export function getCaretNodeAndOffset(): [ Node | null, number ] {
|
||||
export const getCaretNodeAndOffset = (): [ Node | null, number ] => {
|
||||
const selection = window.getSelection();
|
||||
|
||||
if (selection === null) {
|
||||
return [null, 0];
|
||||
}
|
||||
|
||||
let focusNode = selection.focusNode;
|
||||
let focusOffset = selection.focusOffset;
|
||||
const initialFocusNode = selection.focusNode;
|
||||
const initialFocusOffset = selection.focusOffset;
|
||||
|
||||
if (focusNode === null) {
|
||||
if (initialFocusNode === null) {
|
||||
return [null, 0];
|
||||
}
|
||||
|
||||
|
|
@ -29,24 +29,25 @@ export function getCaretNodeAndOffset(): [ Node | null, number ] {
|
|||
*
|
||||
*
|
||||
*/
|
||||
if (focusNode.nodeType !== Node.TEXT_NODE && focusNode.childNodes.length > 0) {
|
||||
if (initialFocusNode.nodeType !== Node.TEXT_NODE && initialFocusNode.childNodes.length > 0) {
|
||||
/**
|
||||
* In normal cases, focusOffset is a child index.
|
||||
*/
|
||||
if (focusNode.childNodes[focusOffset]) {
|
||||
focusNode = focusNode.childNodes[focusOffset];
|
||||
focusOffset = 0;
|
||||
if (initialFocusNode.childNodes[initialFocusOffset]) {
|
||||
return [initialFocusNode.childNodes[initialFocusOffset], 0];
|
||||
/**
|
||||
* But in Firefox, focusOffset can be 1 with the single child.
|
||||
*/
|
||||
} else {
|
||||
focusNode = focusNode.childNodes[focusOffset - 1];
|
||||
focusOffset = focusNode.textContent.length;
|
||||
}
|
||||
|
||||
const childNode = initialFocusNode.childNodes[initialFocusOffset - 1];
|
||||
const textContent = childNode.textContent;
|
||||
|
||||
return [childNode, textContent !== null ? textContent.length : 0];
|
||||
}
|
||||
|
||||
return [focusNode, focusOffset];
|
||||
}
|
||||
return [initialFocusNode, initialFocusOffset];
|
||||
};
|
||||
|
||||
/**
|
||||
* Checks content at left or right of the passed node for emptiness.
|
||||
|
|
@ -57,7 +58,7 @@ export function getCaretNodeAndOffset(): [ Node | null, number ] {
|
|||
* @param direction - The direction to check ('left' or 'right').
|
||||
* @returns true if adjacent content is empty, false otherwise.
|
||||
*/
|
||||
export function checkContenteditableSliceForEmptiness(contenteditable: HTMLElement, fromNode: Node, offsetInsideNode: number, direction: 'left' | 'right'): boolean {
|
||||
export const checkContenteditableSliceForEmptiness = (contenteditable: HTMLElement, fromNode: Node, offsetInsideNode: number, direction: 'left' | 'right'): boolean => {
|
||||
const range = document.createRange();
|
||||
|
||||
/**
|
||||
|
|
@ -95,7 +96,7 @@ export function checkContenteditableSliceForEmptiness(contenteditable: HTMLEleme
|
|||
* If text contains only invisible whitespaces, it is considered to be empty
|
||||
*/
|
||||
return isCollapsedWhitespaces(textContent);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Checks if caret is at the start of the passed input
|
||||
|
|
@ -111,7 +112,7 @@ export function checkContenteditableSliceForEmptiness(contenteditable: HTMLEleme
|
|||
*
|
||||
* @param input - input where caret should be checked
|
||||
*/
|
||||
export function isCaretAtStartOfInput(input: HTMLElement): boolean {
|
||||
export const isCaretAtStartOfInput = (input: HTMLElement): boolean => {
|
||||
const firstNode = $.getDeepestNode(input);
|
||||
|
||||
if (firstNode === null || $.isEmpty(input)) {
|
||||
|
|
@ -142,7 +143,7 @@ export function isCaretAtStartOfInput(input: HTMLElement): boolean {
|
|||
* If there is nothing visible to the left of the caret, it is considered to be at the start
|
||||
*/
|
||||
return checkContenteditableSliceForEmptiness(input, caretNode, caretOffset, 'left');
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Checks if caret is at the end of the passed input
|
||||
|
|
@ -158,7 +159,7 @@ export function isCaretAtStartOfInput(input: HTMLElement): boolean {
|
|||
*
|
||||
* @param input - input where caret should be checked
|
||||
*/
|
||||
export function isCaretAtEndOfInput(input: HTMLElement): boolean {
|
||||
export const isCaretAtEndOfInput = (input: HTMLElement): boolean => {
|
||||
const lastNode = $.getDeepestNode(input, true);
|
||||
|
||||
if (lastNode === null) {
|
||||
|
|
@ -185,4 +186,4 @@ export function isCaretAtEndOfInput(input: HTMLElement): boolean {
|
|||
* If there is nothing visible to the right of the caret, it is considered to be at the end
|
||||
*/
|
||||
return checkContenteditableSliceForEmptiness(input, caretNode, caretOffset, 'right');
|
||||
}
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,266 +0,0 @@
|
|||
import { createParagraphMock } from '../../support/utils/createParagraphMock';
|
||||
import type EditorJS from '../../../../types';
|
||||
import { EDITOR_INTERFACE_SELECTOR } from '../../../../src/components/constants';
|
||||
|
||||
/**
|
||||
* Test cases for Caret API
|
||||
*/
|
||||
describe('Caret API', () => {
|
||||
describe('.setToBlock()', () => {
|
||||
describe('first argument', () => {
|
||||
const paragraphDataMock = createParagraphMock('The first block content mock.');
|
||||
|
||||
/**
|
||||
* 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');
|
||||
|
||||
/**
|
||||
* Blur caret from the block before setting via api
|
||||
*/
|
||||
cy.get(EDITOR_INTERFACE_SELECTOR)
|
||||
.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();
|
||||
|
||||
if (!selection) {
|
||||
throw new Error('Selection not found');
|
||||
}
|
||||
const range = selection.getRangeAt(0);
|
||||
|
||||
cy.get(EDITOR_INTERFACE_SELECTOR)
|
||||
.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();
|
||||
|
||||
if (!selection) {
|
||||
throw new Error('Selection not found');
|
||||
}
|
||||
const range = selection.getRangeAt(0);
|
||||
|
||||
cy.get(EDITOR_INTERFACE_SELECTOR)
|
||||
.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);
|
||||
|
||||
if (!block) {
|
||||
throw new Error('Block not found');
|
||||
}
|
||||
const returnedValue = editor.caret.setToBlock(block);
|
||||
|
||||
/**
|
||||
* Check that caret belongs block
|
||||
*/
|
||||
cy.window()
|
||||
.then((window) => {
|
||||
const selection = window.getSelection();
|
||||
|
||||
if (!selection) {
|
||||
throw new Error('Selection not found');
|
||||
}
|
||||
const range = selection.getRangeAt(0);
|
||||
|
||||
cy.get(EDITOR_INTERFACE_SELECTOR)
|
||||
.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<EditorJS>('@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('1234<b>567</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 "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<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);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
688
test/playwright/tests/api/caret.spec.ts
Normal file
688
test/playwright/tests/api/caret.spec.ts
Normal file
|
|
@ -0,0 +1,688 @@
|
|||
import { expect, test } from '@playwright/test';
|
||||
import type { Page } from '@playwright/test';
|
||||
import path from 'node:path';
|
||||
import { pathToFileURL } from 'node:url';
|
||||
import { EDITOR_INTERFACE_SELECTOR } from '../../../../src/components/constants';
|
||||
import { ensureEditorBundleBuilt } from '../helpers/ensure-build';
|
||||
|
||||
const TEST_PAGE_URL = pathToFileURL(
|
||||
path.resolve(__dirname, '../../../cypress/fixtures/test.html')
|
||||
).href;
|
||||
|
||||
const HOLDER_ID = 'editorjs';
|
||||
const BLOCK_SELECTOR = `${EDITOR_INTERFACE_SELECTOR} .ce-block`;
|
||||
const BLOCK_SELECTED_CLASS = 'ce-block--selected';
|
||||
|
||||
type ToolDefinition = {
|
||||
name: string;
|
||||
classSource: string;
|
||||
config?: Record<string, unknown>;
|
||||
};
|
||||
|
||||
type EditorSetupOptions = {
|
||||
data?: Record<string, unknown>;
|
||||
config?: Record<string, unknown>;
|
||||
tools?: ToolDefinition[];
|
||||
};
|
||||
|
||||
const resetEditor = async (page: Page): Promise<void> => {
|
||||
await page.evaluate(async ({ holderId }) => {
|
||||
if (window.editorInstance) {
|
||||
await window.editorInstance.destroy?.();
|
||||
window.editorInstance = undefined;
|
||||
}
|
||||
|
||||
document.getElementById(holderId)?.remove();
|
||||
|
||||
const container = document.createElement('div');
|
||||
|
||||
container.id = holderId;
|
||||
container.dataset.cy = holderId;
|
||||
container.style.border = '1px dotted #388AE5';
|
||||
|
||||
document.body.appendChild(container);
|
||||
}, { holderId: HOLDER_ID });
|
||||
};
|
||||
|
||||
const createEditor = async (page: Page, options: EditorSetupOptions = {}): Promise<void> => {
|
||||
const { data, config, tools = [] } = options;
|
||||
|
||||
await resetEditor(page);
|
||||
|
||||
await page.evaluate(
|
||||
async ({ holderId, rawData, rawConfig, serializedTools }) => {
|
||||
const reviveToolClass = (classSource: string): unknown => {
|
||||
return new Function(`return (${classSource});`)();
|
||||
};
|
||||
|
||||
const revivedTools = serializedTools.reduce<Record<string, unknown>>((accumulator, toolConfig) => {
|
||||
const revivedClass = reviveToolClass(toolConfig.classSource);
|
||||
|
||||
return {
|
||||
...accumulator,
|
||||
[toolConfig.name]: toolConfig.config
|
||||
? {
|
||||
...toolConfig.config,
|
||||
class: revivedClass,
|
||||
}
|
||||
: revivedClass,
|
||||
};
|
||||
}, {});
|
||||
|
||||
const editorConfig = {
|
||||
holder: holderId,
|
||||
...rawConfig,
|
||||
...(serializedTools.length > 0 ? { tools: revivedTools } : {}),
|
||||
...(rawData ? { data: rawData } : {}),
|
||||
};
|
||||
|
||||
const editor = new window.EditorJS(editorConfig);
|
||||
|
||||
window.editorInstance = editor;
|
||||
await editor.isReady;
|
||||
},
|
||||
{
|
||||
holderId: HOLDER_ID,
|
||||
rawData: data ?? null,
|
||||
rawConfig: config ?? {},
|
||||
serializedTools: tools,
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
const clearSelection = async (page: Page): Promise<void> => {
|
||||
await page.evaluate(() => {
|
||||
window.getSelection()?.removeAllRanges();
|
||||
|
||||
const activeElement = document.activeElement as HTMLElement | null;
|
||||
|
||||
activeElement?.blur?.();
|
||||
});
|
||||
};
|
||||
|
||||
const createParagraphBlock = (id: string, text: string): Record<string, unknown> => {
|
||||
return {
|
||||
id,
|
||||
type: 'paragraph',
|
||||
data: { text },
|
||||
};
|
||||
};
|
||||
|
||||
const NonFocusableBlockTool = class {
|
||||
/**
|
||||
*
|
||||
*/
|
||||
public static get toolbox(): { title: string } {
|
||||
return {
|
||||
title: 'Static',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
*/
|
||||
public render(): HTMLElement {
|
||||
const wrapper = document.createElement('div');
|
||||
|
||||
wrapper.textContent = 'Static block without inputs';
|
||||
|
||||
return wrapper;
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
*/
|
||||
public save(): Record<string, never> {
|
||||
return {};
|
||||
}
|
||||
};
|
||||
|
||||
const NON_FOCUSABLE_TOOL_SOURCE = NonFocusableBlockTool.toString();
|
||||
|
||||
test.describe('Caret API', () => {
|
||||
test.beforeAll(() => {
|
||||
ensureEditorBundleBuilt();
|
||||
});
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto(TEST_PAGE_URL);
|
||||
});
|
||||
|
||||
test.describe('.setToBlock', () => {
|
||||
test('sets caret to a block when block index is passed', async ({ page }) => {
|
||||
const blockId = 'block-index';
|
||||
const paragraphBlock = createParagraphBlock(blockId, 'The first block content mock.');
|
||||
|
||||
await createEditor(page, {
|
||||
data: {
|
||||
blocks: [ paragraphBlock ],
|
||||
},
|
||||
});
|
||||
|
||||
await clearSelection(page);
|
||||
|
||||
const result = await page.evaluate(({ blockSelector }) => {
|
||||
if (!window.editorInstance) {
|
||||
throw new Error('Editor instance not found');
|
||||
}
|
||||
|
||||
const returnedValue = window.editorInstance.caret.setToBlock(0);
|
||||
const selection = window.getSelection();
|
||||
const range = selection && selection.rangeCount > 0 ? selection.getRangeAt(0) : null;
|
||||
const blockElement = document.querySelectorAll(blockSelector).item(0) as HTMLElement | null;
|
||||
|
||||
return {
|
||||
returnedValue,
|
||||
rangeExists: !!range,
|
||||
selectionInBlock: !!(range && blockElement && blockElement.contains(range.startContainer)),
|
||||
};
|
||||
}, { blockSelector: BLOCK_SELECTOR });
|
||||
|
||||
expect(result.returnedValue).toBe(true);
|
||||
expect(result.rangeExists).toBe(true);
|
||||
expect(result.selectionInBlock).toBe(true);
|
||||
});
|
||||
|
||||
test('sets caret to a block when block id is passed', async ({ page }) => {
|
||||
const blockId = 'block-id';
|
||||
const paragraphBlock = createParagraphBlock(blockId, 'Paragraph content.');
|
||||
|
||||
await createEditor(page, {
|
||||
data: {
|
||||
blocks: [ paragraphBlock ],
|
||||
},
|
||||
});
|
||||
|
||||
await clearSelection(page);
|
||||
|
||||
const result = await page.evaluate(({ blockSelector, id }) => {
|
||||
if (!window.editorInstance) {
|
||||
throw new Error('Editor instance not found');
|
||||
}
|
||||
|
||||
const returnedValue = window.editorInstance.caret.setToBlock(id);
|
||||
const selection = window.getSelection();
|
||||
const range = selection && selection.rangeCount > 0 ? selection.getRangeAt(0) : null;
|
||||
const blockElement = document.querySelectorAll(blockSelector).item(0) as HTMLElement | null;
|
||||
|
||||
return {
|
||||
returnedValue,
|
||||
rangeExists: !!range,
|
||||
selectionInBlock: !!(range && blockElement && blockElement.contains(range.startContainer)),
|
||||
};
|
||||
}, { blockSelector: BLOCK_SELECTOR,
|
||||
id: blockId });
|
||||
|
||||
expect(result.returnedValue).toBe(true);
|
||||
expect(result.rangeExists).toBe(true);
|
||||
expect(result.selectionInBlock).toBe(true);
|
||||
});
|
||||
|
||||
test('sets caret to a block when Block API is passed', async ({ page }) => {
|
||||
const blockId = 'block-api';
|
||||
const paragraphBlock = createParagraphBlock(blockId, 'Paragraph api block.');
|
||||
|
||||
await createEditor(page, {
|
||||
data: {
|
||||
blocks: [ paragraphBlock ],
|
||||
},
|
||||
});
|
||||
|
||||
await clearSelection(page);
|
||||
|
||||
const result = await page.evaluate(({ blockSelector }) => {
|
||||
if (!window.editorInstance) {
|
||||
throw new Error('Editor instance not found');
|
||||
}
|
||||
|
||||
const block = window.editorInstance.blocks.getBlockByIndex(0);
|
||||
|
||||
if (!block) {
|
||||
throw new Error('Block not found');
|
||||
}
|
||||
|
||||
const returnedValue = window.editorInstance.caret.setToBlock(block);
|
||||
const selection = window.getSelection();
|
||||
const range = selection && selection.rangeCount > 0 ? selection.getRangeAt(0) : null;
|
||||
const blockElement = document.querySelectorAll(blockSelector).item(0) as HTMLElement | null;
|
||||
|
||||
return {
|
||||
returnedValue,
|
||||
rangeExists: !!range,
|
||||
selectionInBlock: !!(range && blockElement && blockElement.contains(range.startContainer)),
|
||||
};
|
||||
}, { blockSelector: BLOCK_SELECTOR });
|
||||
|
||||
expect(result.returnedValue).toBe(true);
|
||||
expect(result.rangeExists).toBe(true);
|
||||
expect(result.selectionInBlock).toBe(true);
|
||||
});
|
||||
|
||||
test('sets caret at specific offset in text content', async ({ page }) => {
|
||||
const blockId = 'offset-text';
|
||||
const paragraphBlock = createParagraphBlock(blockId, 'Plain text content.');
|
||||
|
||||
await createEditor(page, {
|
||||
data: {
|
||||
blocks: [ paragraphBlock ],
|
||||
},
|
||||
});
|
||||
|
||||
await clearSelection(page);
|
||||
|
||||
const result = await page.evaluate(({ id, offset }) => {
|
||||
if (!window.editorInstance) {
|
||||
throw new Error('Editor instance not found');
|
||||
}
|
||||
|
||||
const block = window.editorInstance.blocks.getById(id);
|
||||
|
||||
if (!block) {
|
||||
throw new Error('Block not found');
|
||||
}
|
||||
|
||||
window.editorInstance.caret.setToBlock(block, 'default', offset);
|
||||
|
||||
const selection = window.getSelection();
|
||||
|
||||
if (!selection || selection.rangeCount === 0) {
|
||||
throw new Error('Selection was not set');
|
||||
}
|
||||
|
||||
const range = selection.getRangeAt(0);
|
||||
|
||||
return {
|
||||
startOffset: range.startOffset,
|
||||
startContainerText: range.startContainer.textContent,
|
||||
};
|
||||
}, { id: blockId,
|
||||
offset: 5 });
|
||||
|
||||
expect(result.startOffset).toBe(5);
|
||||
expect(result.startContainerText).toBe('Plain text content.');
|
||||
});
|
||||
|
||||
test('sets caret at correct offset when text contains HTML elements', async ({ page }) => {
|
||||
const blockId = 'offset-html';
|
||||
const paragraphBlock = createParagraphBlock(blockId, '1234<b>567</b>!');
|
||||
|
||||
await createEditor(page, {
|
||||
data: {
|
||||
blocks: [ paragraphBlock ],
|
||||
},
|
||||
});
|
||||
|
||||
await clearSelection(page);
|
||||
|
||||
const result = await page.evaluate(({ id, offset }) => {
|
||||
if (!window.editorInstance) {
|
||||
throw new Error('Editor instance not found');
|
||||
}
|
||||
|
||||
const block = window.editorInstance.blocks.getById(id);
|
||||
|
||||
if (!block) {
|
||||
throw new Error('Block not found');
|
||||
}
|
||||
|
||||
window.editorInstance.caret.setToBlock(block, 'default', offset);
|
||||
|
||||
const selection = window.getSelection();
|
||||
|
||||
if (!selection || selection.rangeCount === 0) {
|
||||
throw new Error('Selection was not set');
|
||||
}
|
||||
|
||||
const range = selection.getRangeAt(0);
|
||||
|
||||
return {
|
||||
startOffset: range.startOffset,
|
||||
startContainerText: range.startContainer.textContent,
|
||||
};
|
||||
}, { id: blockId,
|
||||
offset: 6 });
|
||||
|
||||
expect(result.startContainerText).toBe('567');
|
||||
expect(result.startOffset).toBe(2);
|
||||
});
|
||||
|
||||
test('limits caret position to the end when offset exceeds content length', async ({ page }) => {
|
||||
const blockId = 'offset-beyond';
|
||||
const paragraphBlock = createParagraphBlock(blockId, '1234567890');
|
||||
|
||||
await createEditor(page, {
|
||||
data: {
|
||||
blocks: [ paragraphBlock ],
|
||||
},
|
||||
});
|
||||
|
||||
await clearSelection(page);
|
||||
|
||||
const contentLength = '1234567890'.length;
|
||||
|
||||
const result = await page.evaluate(({ id, offset }) => {
|
||||
if (!window.editorInstance) {
|
||||
throw new Error('Editor instance not found');
|
||||
}
|
||||
|
||||
const block = window.editorInstance.blocks.getById(id);
|
||||
|
||||
if (!block) {
|
||||
throw new Error('Block not found');
|
||||
}
|
||||
|
||||
window.editorInstance.caret.setToBlock(block, 'default', offset);
|
||||
|
||||
const selection = window.getSelection();
|
||||
|
||||
if (!selection || selection.rangeCount === 0) {
|
||||
throw new Error('Selection was not set');
|
||||
}
|
||||
|
||||
const range = selection.getRangeAt(0);
|
||||
|
||||
return {
|
||||
startOffset: range.startOffset,
|
||||
};
|
||||
}, { id: blockId,
|
||||
offset: contentLength + 10 });
|
||||
|
||||
expect(result.startOffset).toBe(contentLength);
|
||||
});
|
||||
|
||||
test('handles offsets within nested HTML structure', async ({ page }) => {
|
||||
const blockId = 'offset-nested';
|
||||
const paragraphBlock = createParagraphBlock(blockId, '123<b>456<i>789</i></b>!');
|
||||
|
||||
await createEditor(page, {
|
||||
data: {
|
||||
blocks: [ paragraphBlock ],
|
||||
},
|
||||
});
|
||||
|
||||
await clearSelection(page);
|
||||
|
||||
const result = await page.evaluate(({ id, offset }) => {
|
||||
if (!window.editorInstance) {
|
||||
throw new Error('Editor instance not found');
|
||||
}
|
||||
|
||||
const block = window.editorInstance.blocks.getById(id);
|
||||
|
||||
if (!block) {
|
||||
throw new Error('Block not found');
|
||||
}
|
||||
|
||||
window.editorInstance.caret.setToBlock(block, 'default', offset);
|
||||
|
||||
const selection = window.getSelection();
|
||||
|
||||
if (!selection || selection.rangeCount === 0) {
|
||||
throw new Error('Selection was not set');
|
||||
}
|
||||
|
||||
const range = selection.getRangeAt(0);
|
||||
|
||||
return {
|
||||
startOffset: range.startOffset,
|
||||
startContainerText: range.startContainer.textContent,
|
||||
};
|
||||
}, { id: blockId,
|
||||
offset: 8 });
|
||||
|
||||
expect(result.startContainerText).toBe('789');
|
||||
expect(result.startOffset).toBe(2);
|
||||
});
|
||||
|
||||
test('respects "start" position regardless of provided offset', async ({ page }) => {
|
||||
const blockId = 'position-start';
|
||||
const paragraphBlock = createParagraphBlock(blockId, 'Starts at the beginning.');
|
||||
|
||||
await createEditor(page, {
|
||||
data: {
|
||||
blocks: [ paragraphBlock ],
|
||||
},
|
||||
});
|
||||
|
||||
await clearSelection(page);
|
||||
|
||||
const result = await page.evaluate(({ id, offset }) => {
|
||||
if (!window.editorInstance) {
|
||||
throw new Error('Editor instance not found');
|
||||
}
|
||||
|
||||
const block = window.editorInstance.blocks.getById(id);
|
||||
|
||||
if (!block) {
|
||||
throw new Error('Block not found');
|
||||
}
|
||||
|
||||
window.editorInstance.caret.setToBlock(block, 'start', offset);
|
||||
|
||||
const selection = window.getSelection();
|
||||
|
||||
if (!selection || selection.rangeCount === 0) {
|
||||
throw new Error('Selection was not set');
|
||||
}
|
||||
|
||||
const range = selection.getRangeAt(0);
|
||||
|
||||
return {
|
||||
startOffset: range.startOffset,
|
||||
startContainerText: range.startContainer.textContent,
|
||||
};
|
||||
}, { id: blockId,
|
||||
offset: 10 });
|
||||
|
||||
expect(result.startOffset).toBe(0);
|
||||
expect(result.startContainerText).toBe('Starts at the beginning.');
|
||||
});
|
||||
|
||||
test('places caret at the last text node when "end" position is used', async ({ page }) => {
|
||||
const blockId = 'position-end';
|
||||
const paragraphBlock = createParagraphBlock(blockId, 'Hello <b>world</b>!');
|
||||
|
||||
await createEditor(page, {
|
||||
data: {
|
||||
blocks: [ paragraphBlock ],
|
||||
},
|
||||
});
|
||||
|
||||
await clearSelection(page);
|
||||
|
||||
const result = await page.evaluate(({ id }) => {
|
||||
if (!window.editorInstance) {
|
||||
throw new Error('Editor instance not found');
|
||||
}
|
||||
|
||||
const block = window.editorInstance.blocks.getById(id);
|
||||
|
||||
if (!block) {
|
||||
throw new Error('Block not found');
|
||||
}
|
||||
|
||||
window.editorInstance.caret.setToBlock(block, 'end');
|
||||
|
||||
const selection = window.getSelection();
|
||||
|
||||
if (!selection || selection.rangeCount === 0) {
|
||||
throw new Error('Selection was not set');
|
||||
}
|
||||
|
||||
const range = selection.getRangeAt(0);
|
||||
|
||||
return {
|
||||
startOffset: range.startOffset,
|
||||
startContainerText: range.startContainer.textContent,
|
||||
};
|
||||
}, { id: blockId });
|
||||
|
||||
expect(result.startContainerText).toBe('world');
|
||||
expect(result.startOffset).toBe('world'.length);
|
||||
});
|
||||
|
||||
test('does not change selection when block index cannot be resolved', async ({ page }) => {
|
||||
const blockId = 'invalid-index';
|
||||
const paragraphBlock = createParagraphBlock(blockId, 'Block index invalid.');
|
||||
|
||||
await createEditor(page, {
|
||||
data: {
|
||||
blocks: [ paragraphBlock ],
|
||||
},
|
||||
});
|
||||
|
||||
await clearSelection(page);
|
||||
|
||||
const result = await page.evaluate(({ blockSelector, selectedClass }) => {
|
||||
if (!window.editorInstance) {
|
||||
throw new Error('Editor instance not found');
|
||||
}
|
||||
|
||||
window.getSelection()?.removeAllRanges();
|
||||
|
||||
const returnedValue = window.editorInstance.caret.setToBlock(99);
|
||||
const selection = window.getSelection();
|
||||
const selectedBlocks = document.querySelectorAll(`${blockSelector}.${selectedClass}`).length;
|
||||
|
||||
return {
|
||||
returnedValue,
|
||||
rangeCount: selection?.rangeCount ?? 0,
|
||||
selectedBlocks,
|
||||
};
|
||||
}, { blockSelector: BLOCK_SELECTOR,
|
||||
selectedClass: BLOCK_SELECTED_CLASS });
|
||||
|
||||
expect(result.returnedValue).toBe(false);
|
||||
expect(result.rangeCount).toBe(0);
|
||||
expect(result.selectedBlocks).toBe(0);
|
||||
});
|
||||
|
||||
test('does not change selection when block id cannot be resolved', async ({ page }) => {
|
||||
const blockId = 'invalid-id';
|
||||
const paragraphBlock = createParagraphBlock(blockId, 'Block id invalid.');
|
||||
|
||||
await createEditor(page, {
|
||||
data: {
|
||||
blocks: [ paragraphBlock ],
|
||||
},
|
||||
});
|
||||
|
||||
await clearSelection(page);
|
||||
|
||||
const result = await page.evaluate(({ blockSelector, selectedClass }) => {
|
||||
if (!window.editorInstance) {
|
||||
throw new Error('Editor instance not found');
|
||||
}
|
||||
|
||||
window.getSelection()?.removeAllRanges();
|
||||
|
||||
const returnedValue = window.editorInstance.caret.setToBlock('missing-block-id');
|
||||
const selection = window.getSelection();
|
||||
const selectedBlocks = document.querySelectorAll(`${blockSelector}.${selectedClass}`).length;
|
||||
|
||||
return {
|
||||
returnedValue,
|
||||
rangeCount: selection?.rangeCount ?? 0,
|
||||
selectedBlocks,
|
||||
};
|
||||
}, { blockSelector: BLOCK_SELECTOR,
|
||||
selectedClass: BLOCK_SELECTED_CLASS });
|
||||
|
||||
expect(result.returnedValue).toBe(false);
|
||||
expect(result.rangeCount).toBe(0);
|
||||
expect(result.selectedBlocks).toBe(0);
|
||||
});
|
||||
|
||||
test('does not change selection when Block API instance is stale', async ({ page }) => {
|
||||
const paragraphBlock = createParagraphBlock('stale-block', 'Block api stale.');
|
||||
|
||||
await createEditor(page, {
|
||||
data: {
|
||||
blocks: [paragraphBlock, createParagraphBlock('second-block', 'Second block')],
|
||||
},
|
||||
});
|
||||
|
||||
await clearSelection(page);
|
||||
|
||||
const result = await page.evaluate(({ blockSelector, selectedClass }) => {
|
||||
if (!window.editorInstance) {
|
||||
throw new Error('Editor instance not found');
|
||||
}
|
||||
|
||||
const block = window.editorInstance.blocks.getBlockByIndex(0);
|
||||
|
||||
if (!block) {
|
||||
throw new Error('Block not found');
|
||||
}
|
||||
|
||||
window.editorInstance.blocks.delete(0);
|
||||
window.getSelection()?.removeAllRanges();
|
||||
|
||||
const returnedValue = window.editorInstance.caret.setToBlock(block);
|
||||
const selection = window.getSelection();
|
||||
const selectedBlocks = document.querySelectorAll(`${blockSelector}.${selectedClass}`).length;
|
||||
|
||||
return {
|
||||
returnedValue,
|
||||
rangeCount: selection?.rangeCount ?? 0,
|
||||
selectedBlocks,
|
||||
};
|
||||
}, { blockSelector: BLOCK_SELECTOR,
|
||||
selectedClass: BLOCK_SELECTED_CLASS });
|
||||
|
||||
expect(result.returnedValue).toBe(false);
|
||||
expect(result.rangeCount).toBe(0);
|
||||
expect(result.selectedBlocks).toBe(0);
|
||||
});
|
||||
|
||||
test('highlights non-focusable blocks instead of placing a caret', async ({ page }) => {
|
||||
const paragraphBlock = createParagraphBlock('focusable-block', 'Focusable content');
|
||||
const staticBlockId = 'static-block';
|
||||
const staticBlock = {
|
||||
id: staticBlockId,
|
||||
type: 'nonFocusable',
|
||||
data: {},
|
||||
};
|
||||
|
||||
await createEditor(page, {
|
||||
data: {
|
||||
blocks: [paragraphBlock, staticBlock],
|
||||
},
|
||||
tools: [
|
||||
{
|
||||
name: 'nonFocusable',
|
||||
classSource: NON_FOCUSABLE_TOOL_SOURCE,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
await clearSelection(page);
|
||||
|
||||
const result = await page.evaluate(({ blockSelector, selectedClass }) => {
|
||||
if (!window.editorInstance) {
|
||||
throw new Error('Editor instance not found');
|
||||
}
|
||||
|
||||
const returnedValue = window.editorInstance.caret.setToBlock(1);
|
||||
const selection = window.getSelection();
|
||||
const blocks = document.querySelectorAll(blockSelector);
|
||||
const secondBlock = blocks.item(1) as HTMLElement | null;
|
||||
const firstBlock = blocks.item(0) as HTMLElement | null;
|
||||
|
||||
return {
|
||||
returnedValue,
|
||||
rangeCount: selection?.rangeCount ?? 0,
|
||||
secondBlockSelected: !!secondBlock && secondBlock.classList.contains(selectedClass),
|
||||
firstBlockSelected: !!firstBlock && firstBlock.classList.contains(selectedClass),
|
||||
};
|
||||
}, { blockSelector: BLOCK_SELECTOR,
|
||||
selectedClass: BLOCK_SELECTED_CLASS });
|
||||
|
||||
expect(result.returnedValue).toBe(true);
|
||||
expect(result.rangeCount).toBe(0);
|
||||
expect(result.secondBlockSelected).toBe(true);
|
||||
expect(result.firstBlockSelected).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
654
test/unit/utils/caret.test.ts
Normal file
654
test/unit/utils/caret.test.ts
Normal file
|
|
@ -0,0 +1,654 @@
|
|||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import {
|
||||
getCaretNodeAndOffset,
|
||||
checkContenteditableSliceForEmptiness,
|
||||
isCaretAtStartOfInput,
|
||||
isCaretAtEndOfInput
|
||||
} from '../../../src/components/utils/caret';
|
||||
|
||||
/**
|
||||
* Unit tests for caret.ts utility functions
|
||||
*
|
||||
* Tests edge cases and internal functionality not covered by E2E tests
|
||||
*/
|
||||
describe('caret utilities', () => {
|
||||
const containerState = { element: null as HTMLElement | null };
|
||||
|
||||
beforeEach(() => {
|
||||
containerState.element = document.createElement('div');
|
||||
document.body.appendChild(containerState.element);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
// Clear selection
|
||||
window.getSelection()?.removeAllRanges();
|
||||
if (containerState.element) {
|
||||
containerState.element.remove();
|
||||
containerState.element = null;
|
||||
}
|
||||
});
|
||||
|
||||
const getContainer = (): HTMLElement => {
|
||||
if (!containerState.element) {
|
||||
throw new Error('Container not initialized');
|
||||
}
|
||||
|
||||
return containerState.element;
|
||||
};
|
||||
|
||||
/**
|
||||
* Helper function to set up selection in jsdom
|
||||
* jsdom has limited Selection API support, so we need to manually set focusNode and focusOffset
|
||||
*
|
||||
* @param node - The node where the selection should be set
|
||||
* @param offset - The offset within the node where the selection should be set
|
||||
*/
|
||||
const setupSelection = (node: Node, offset: number): void => {
|
||||
const range = document.createRange();
|
||||
|
||||
range.setStart(node, offset);
|
||||
range.setEnd(node, offset);
|
||||
|
||||
const selection = window.getSelection();
|
||||
|
||||
if (!selection) {
|
||||
return;
|
||||
}
|
||||
|
||||
selection.removeAllRanges();
|
||||
selection.addRange(range);
|
||||
|
||||
// jsdom doesn't properly set focusNode/focusOffset, so we need to set them manually
|
||||
Object.defineProperty(selection, 'focusNode', {
|
||||
value: node,
|
||||
writable: true,
|
||||
configurable: true,
|
||||
});
|
||||
|
||||
Object.defineProperty(selection, 'focusOffset', {
|
||||
value: offset,
|
||||
writable: true,
|
||||
configurable: true,
|
||||
});
|
||||
|
||||
Object.defineProperty(selection, 'anchorNode', {
|
||||
value: node,
|
||||
writable: true,
|
||||
configurable: true,
|
||||
});
|
||||
|
||||
Object.defineProperty(selection, 'anchorOffset', {
|
||||
value: offset,
|
||||
writable: true,
|
||||
configurable: true,
|
||||
});
|
||||
|
||||
// Focus the element if it's an HTMLElement
|
||||
if (node.nodeType === Node.ELEMENT_NODE) {
|
||||
(node as HTMLElement).focus?.();
|
||||
} else if (node.parentElement) {
|
||||
node.parentElement.focus?.();
|
||||
}
|
||||
};
|
||||
|
||||
describe('getCaretNodeAndOffset', () => {
|
||||
it('should return [null, 0] when selection is null', () => {
|
||||
// Mock window.getSelection to return null
|
||||
const originalGetSelection = window.getSelection;
|
||||
|
||||
window.getSelection = () => null;
|
||||
|
||||
const result = getCaretNodeAndOffset();
|
||||
|
||||
expect(result).toEqual([null, 0]);
|
||||
|
||||
window.getSelection = originalGetSelection;
|
||||
});
|
||||
|
||||
it('should return [null, 0] when focusNode is null', () => {
|
||||
const selection = window.getSelection();
|
||||
|
||||
if (!selection) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Create a mock selection with null focusNode
|
||||
const range = document.createRange();
|
||||
const textNode = document.createTextNode('test');
|
||||
|
||||
getContainer().appendChild(textNode);
|
||||
range.setStart(textNode, 0);
|
||||
range.setEnd(textNode, 0);
|
||||
selection.removeAllRanges();
|
||||
selection.addRange(range);
|
||||
|
||||
// Manually set focusNode to null (simulating edge case)
|
||||
Object.defineProperty(selection, 'focusNode', {
|
||||
value: null,
|
||||
writable: true,
|
||||
configurable: true,
|
||||
});
|
||||
|
||||
const result = getCaretNodeAndOffset();
|
||||
|
||||
expect(result).toEqual([null, 0]);
|
||||
});
|
||||
|
||||
it('should return text node and offset when focusNode is a text node', () => {
|
||||
const textNode = document.createTextNode('Hello world');
|
||||
|
||||
const CARET_OFFSET = 5;
|
||||
|
||||
getContainer().appendChild(textNode);
|
||||
getContainer().focus();
|
||||
|
||||
setupSelection(textNode, CARET_OFFSET);
|
||||
|
||||
const [node, offset] = getCaretNodeAndOffset();
|
||||
|
||||
expect(node).toBe(textNode);
|
||||
expect(offset).toBe(CARET_OFFSET);
|
||||
});
|
||||
|
||||
it('should return child node when focusNode is an element with children', () => {
|
||||
const div = document.createElement('div');
|
||||
const textNode = document.createTextNode('Hello');
|
||||
|
||||
div.appendChild(textNode);
|
||||
getContainer().appendChild(div);
|
||||
div.focus();
|
||||
|
||||
setupSelection(div, 0);
|
||||
|
||||
const [node, offset] = getCaretNodeAndOffset();
|
||||
|
||||
expect(node).toBe(textNode);
|
||||
expect(offset).toBe(0);
|
||||
});
|
||||
|
||||
it('should handle Firefox edge case when focusOffset is 1 with single child', () => {
|
||||
const div = document.createElement('div');
|
||||
const textNode = document.createTextNode('Hello');
|
||||
|
||||
div.appendChild(textNode);
|
||||
getContainer().appendChild(div);
|
||||
div.focus();
|
||||
|
||||
setupSelection(div, 1);
|
||||
|
||||
const [node, offset] = getCaretNodeAndOffset();
|
||||
|
||||
expect(node).toBe(textNode);
|
||||
expect(offset).toBe(textNode.textContent?.length ?? 0);
|
||||
});
|
||||
|
||||
it('should return element and offset when element has no children', () => {
|
||||
const div = document.createElement('div');
|
||||
|
||||
getContainer().appendChild(div);
|
||||
div.focus();
|
||||
|
||||
setupSelection(div, 0);
|
||||
|
||||
const [node, offset] = getCaretNodeAndOffset();
|
||||
|
||||
expect(node).toBe(div);
|
||||
expect(offset).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('checkContenteditableSliceForEmptiness', () => {
|
||||
it('should return true for left direction when content is empty', () => {
|
||||
const contenteditable = document.createElement('div');
|
||||
|
||||
contenteditable.contentEditable = 'true';
|
||||
const textNode = document.createTextNode('Hello');
|
||||
|
||||
contenteditable.appendChild(textNode);
|
||||
getContainer().appendChild(contenteditable);
|
||||
|
||||
const result = checkContenteditableSliceForEmptiness(contenteditable, textNode, 0, 'left');
|
||||
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false for left direction when content has visible text', () => {
|
||||
const contenteditable = document.createElement('div');
|
||||
|
||||
contenteditable.contentEditable = 'true';
|
||||
const textNode1 = document.createTextNode('Hello');
|
||||
const textNode2 = document.createTextNode(' World');
|
||||
|
||||
contenteditable.appendChild(textNode1);
|
||||
contenteditable.appendChild(textNode2);
|
||||
getContainer().appendChild(contenteditable);
|
||||
|
||||
const result = checkContenteditableSliceForEmptiness(contenteditable, textNode2, 0, 'left');
|
||||
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it('should return true for left direction when content has only whitespace', () => {
|
||||
const contenteditable = document.createElement('div');
|
||||
|
||||
contenteditable.contentEditable = 'true';
|
||||
const textNode1 = document.createTextNode(' \t\n');
|
||||
const textNode2 = document.createTextNode('Hello');
|
||||
|
||||
contenteditable.appendChild(textNode1);
|
||||
contenteditable.appendChild(textNode2);
|
||||
getContainer().appendChild(contenteditable);
|
||||
|
||||
const result = checkContenteditableSliceForEmptiness(contenteditable, textNode2, 0, 'left');
|
||||
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
it('should return true for left direction when content has only non-breaking spaces', () => {
|
||||
const contenteditable = document.createElement('div');
|
||||
|
||||
contenteditable.contentEditable = 'true';
|
||||
const textNode1 = document.createTextNode('\u00A0\u00A0\u00A0'); // Non-breaking spaces
|
||||
const textNode2 = document.createTextNode('Hello');
|
||||
|
||||
contenteditable.appendChild(textNode1);
|
||||
contenteditable.appendChild(textNode2);
|
||||
getContainer().appendChild(contenteditable);
|
||||
|
||||
// Non-breaking spaces are visible, so should return false
|
||||
const result = checkContenteditableSliceForEmptiness(contenteditable, textNode2, 0, 'left');
|
||||
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it('should return true for right direction when content is empty', () => {
|
||||
const contenteditable = document.createElement('div');
|
||||
|
||||
contenteditable.contentEditable = 'true';
|
||||
const textNode = document.createTextNode('Hello');
|
||||
|
||||
contenteditable.appendChild(textNode);
|
||||
getContainer().appendChild(contenteditable);
|
||||
|
||||
const result = checkContenteditableSliceForEmptiness(contenteditable, textNode, textNode.length, 'right');
|
||||
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false for right direction when content has visible text', () => {
|
||||
const contenteditable = document.createElement('div');
|
||||
|
||||
contenteditable.contentEditable = 'true';
|
||||
const textNode1 = document.createTextNode('Hello');
|
||||
const textNode2 = document.createTextNode(' World');
|
||||
|
||||
contenteditable.appendChild(textNode1);
|
||||
contenteditable.appendChild(textNode2);
|
||||
getContainer().appendChild(contenteditable);
|
||||
|
||||
const result = checkContenteditableSliceForEmptiness(contenteditable, textNode1, textNode1.length, 'right');
|
||||
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it('should return true for right direction when content has only whitespace', () => {
|
||||
const contenteditable = document.createElement('div');
|
||||
|
||||
contenteditable.contentEditable = 'true';
|
||||
const textNode1 = document.createTextNode('Hello');
|
||||
const textNode2 = document.createTextNode(' \t\n');
|
||||
|
||||
contenteditable.appendChild(textNode1);
|
||||
contenteditable.appendChild(textNode2);
|
||||
getContainer().appendChild(contenteditable);
|
||||
|
||||
const result = checkContenteditableSliceForEmptiness(contenteditable, textNode1, textNode1.length, 'right');
|
||||
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
it('should handle nested elements correctly for left direction', () => {
|
||||
const contenteditable = document.createElement('div');
|
||||
|
||||
contenteditable.contentEditable = 'true';
|
||||
const span = document.createElement('span');
|
||||
const textNode = document.createTextNode('Hello');
|
||||
|
||||
span.appendChild(textNode);
|
||||
contenteditable.appendChild(span);
|
||||
getContainer().appendChild(contenteditable);
|
||||
|
||||
const result = checkContenteditableSliceForEmptiness(contenteditable, textNode, 0, 'left');
|
||||
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
it('should handle nested elements correctly for right direction', () => {
|
||||
const contenteditable = document.createElement('div');
|
||||
|
||||
contenteditable.contentEditable = 'true';
|
||||
const span = document.createElement('span');
|
||||
const textNode = document.createTextNode('Hello');
|
||||
|
||||
span.appendChild(textNode);
|
||||
contenteditable.appendChild(span);
|
||||
getContainer().appendChild(contenteditable);
|
||||
|
||||
const result = checkContenteditableSliceForEmptiness(contenteditable, textNode, textNode.length, 'right');
|
||||
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isCaretAtStartOfInput', () => {
|
||||
it('should return true for empty input', () => {
|
||||
const input = document.createElement('div');
|
||||
|
||||
input.contentEditable = 'true';
|
||||
getContainer().appendChild(input);
|
||||
|
||||
const result = isCaretAtStartOfInput(input);
|
||||
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
it('should return true for native input at start', () => {
|
||||
const input = document.createElement('input');
|
||||
|
||||
input.type = 'text';
|
||||
input.value = 'Hello';
|
||||
getContainer().appendChild(input);
|
||||
input.focus();
|
||||
input.setSelectionRange(0, 0);
|
||||
|
||||
const result = isCaretAtStartOfInput(input);
|
||||
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false for native input not at start', () => {
|
||||
const input = document.createElement('input');
|
||||
const CARET_POSITION = 3;
|
||||
|
||||
input.type = 'text';
|
||||
input.value = 'Hello';
|
||||
getContainer().appendChild(input);
|
||||
input.focus();
|
||||
input.setSelectionRange(CARET_POSITION, CARET_POSITION);
|
||||
|
||||
const result = isCaretAtStartOfInput(input);
|
||||
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it('should return true for contenteditable at start with offset 0', () => {
|
||||
const input = document.createElement('div');
|
||||
|
||||
input.contentEditable = 'true';
|
||||
const textNode = document.createTextNode('Hello');
|
||||
|
||||
input.appendChild(textNode);
|
||||
getContainer().appendChild(input);
|
||||
input.focus();
|
||||
|
||||
setupSelection(textNode, 0);
|
||||
|
||||
const result = isCaretAtStartOfInput(input);
|
||||
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false for contenteditable not at start', () => {
|
||||
const input = document.createElement('div');
|
||||
const CARET_POSITION = 3;
|
||||
|
||||
input.contentEditable = 'true';
|
||||
const textNode = document.createTextNode('Hello');
|
||||
|
||||
input.appendChild(textNode);
|
||||
getContainer().appendChild(input);
|
||||
input.focus();
|
||||
|
||||
setupSelection(textNode, CARET_POSITION);
|
||||
|
||||
const result = isCaretAtStartOfInput(input);
|
||||
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false when there is no selection', () => {
|
||||
const input = document.createElement('div');
|
||||
|
||||
input.contentEditable = 'true';
|
||||
const textNode = document.createTextNode('Hello');
|
||||
|
||||
input.appendChild(textNode);
|
||||
getContainer().appendChild(input);
|
||||
|
||||
const selection = window.getSelection();
|
||||
|
||||
if (selection) {
|
||||
selection.removeAllRanges();
|
||||
// Clear selection properties to simulate no selection
|
||||
Object.defineProperty(selection, 'focusNode', {
|
||||
value: null,
|
||||
writable: true,
|
||||
configurable: true,
|
||||
});
|
||||
Object.defineProperty(selection, 'focusOffset', {
|
||||
value: 0,
|
||||
writable: true,
|
||||
configurable: true,
|
||||
});
|
||||
}
|
||||
|
||||
const result = isCaretAtStartOfInput(input);
|
||||
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it('should return true when caret is after whitespace-only content', () => {
|
||||
const input = document.createElement('div');
|
||||
|
||||
input.contentEditable = 'true';
|
||||
const textNode1 = document.createTextNode(' \t\n');
|
||||
const textNode2 = document.createTextNode('Hello');
|
||||
|
||||
input.appendChild(textNode1);
|
||||
input.appendChild(textNode2);
|
||||
getContainer().appendChild(input);
|
||||
input.focus();
|
||||
|
||||
setupSelection(textNode2, 0);
|
||||
|
||||
const result = isCaretAtStartOfInput(input);
|
||||
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false when caret is after visible content', () => {
|
||||
const input = document.createElement('div');
|
||||
|
||||
input.contentEditable = 'true';
|
||||
const textNode1 = document.createTextNode('Hello');
|
||||
const textNode2 = document.createTextNode(' World');
|
||||
|
||||
input.appendChild(textNode1);
|
||||
input.appendChild(textNode2);
|
||||
getContainer().appendChild(input);
|
||||
input.focus();
|
||||
|
||||
setupSelection(textNode2, 0);
|
||||
|
||||
const result = isCaretAtStartOfInput(input);
|
||||
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isCaretAtEndOfInput', () => {
|
||||
it('should return true for empty input', () => {
|
||||
const input = document.createElement('div');
|
||||
|
||||
input.contentEditable = 'true';
|
||||
getContainer().appendChild(input);
|
||||
|
||||
const result = isCaretAtEndOfInput(input);
|
||||
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
it('should return true for native input at end', () => {
|
||||
const input = document.createElement('input');
|
||||
|
||||
input.type = 'text';
|
||||
input.value = 'Hello';
|
||||
getContainer().appendChild(input);
|
||||
input.focus();
|
||||
input.setSelectionRange(input.value.length, input.value.length);
|
||||
|
||||
const result = isCaretAtEndOfInput(input);
|
||||
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false for native input not at end', () => {
|
||||
const input = document.createElement('input');
|
||||
const CARET_POSITION = 3;
|
||||
|
||||
input.type = 'text';
|
||||
input.value = 'Hello';
|
||||
getContainer().appendChild(input);
|
||||
input.focus();
|
||||
input.setSelectionRange(CARET_POSITION, CARET_POSITION);
|
||||
|
||||
const result = isCaretAtEndOfInput(input);
|
||||
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it('should return true for contenteditable at end', () => {
|
||||
const input = document.createElement('div');
|
||||
|
||||
input.contentEditable = 'true';
|
||||
const textNode = document.createTextNode('Hello');
|
||||
|
||||
input.appendChild(textNode);
|
||||
getContainer().appendChild(input);
|
||||
input.focus();
|
||||
|
||||
setupSelection(textNode, textNode.length);
|
||||
|
||||
const result = isCaretAtEndOfInput(input);
|
||||
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false for contenteditable not at end', () => {
|
||||
const input = document.createElement('div');
|
||||
const CARET_POSITION = 3;
|
||||
|
||||
input.contentEditable = 'true';
|
||||
const textNode = document.createTextNode('Hello');
|
||||
|
||||
input.appendChild(textNode);
|
||||
getContainer().appendChild(input);
|
||||
input.focus();
|
||||
|
||||
setupSelection(textNode, CARET_POSITION);
|
||||
|
||||
const result = isCaretAtEndOfInput(input);
|
||||
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false when there is no selection', () => {
|
||||
const input = document.createElement('div');
|
||||
|
||||
input.contentEditable = 'true';
|
||||
const textNode = document.createTextNode('Hello');
|
||||
|
||||
input.appendChild(textNode);
|
||||
getContainer().appendChild(input);
|
||||
|
||||
const selection = window.getSelection();
|
||||
|
||||
if (selection) {
|
||||
selection.removeAllRanges();
|
||||
// Clear selection properties to simulate no selection
|
||||
Object.defineProperty(selection, 'focusNode', {
|
||||
value: null,
|
||||
writable: true,
|
||||
configurable: true,
|
||||
});
|
||||
Object.defineProperty(selection, 'focusOffset', {
|
||||
value: 0,
|
||||
writable: true,
|
||||
configurable: true,
|
||||
});
|
||||
}
|
||||
|
||||
const result = isCaretAtEndOfInput(input);
|
||||
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it('should return true when caret is before whitespace-only content', () => {
|
||||
const input = document.createElement('div');
|
||||
|
||||
input.contentEditable = 'true';
|
||||
const textNode1 = document.createTextNode('Hello');
|
||||
const textNode2 = document.createTextNode(' \t\n');
|
||||
|
||||
input.appendChild(textNode1);
|
||||
input.appendChild(textNode2);
|
||||
getContainer().appendChild(input);
|
||||
input.focus();
|
||||
|
||||
setupSelection(textNode1, textNode1.length);
|
||||
|
||||
const result = isCaretAtEndOfInput(input);
|
||||
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false when caret is before visible content', () => {
|
||||
const input = document.createElement('div');
|
||||
|
||||
input.contentEditable = 'true';
|
||||
const textNode1 = document.createTextNode('Hello');
|
||||
const textNode2 = document.createTextNode(' World');
|
||||
|
||||
input.appendChild(textNode1);
|
||||
input.appendChild(textNode2);
|
||||
getContainer().appendChild(input);
|
||||
input.focus();
|
||||
|
||||
setupSelection(textNode1, textNode1.length);
|
||||
|
||||
const result = isCaretAtEndOfInput(input);
|
||||
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it('should handle nested elements correctly', () => {
|
||||
const input = document.createElement('div');
|
||||
|
||||
input.contentEditable = 'true';
|
||||
const span = document.createElement('span');
|
||||
const textNode = document.createTextNode('Hello');
|
||||
|
||||
span.appendChild(textNode);
|
||||
input.appendChild(span);
|
||||
getContainer().appendChild(input);
|
||||
input.focus();
|
||||
|
||||
setupSelection(textNode, textNode.length);
|
||||
|
||||
const result = isCaretAtEndOfInput(input);
|
||||
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Loading…
Add table
Add a link
Reference in a new issue