fix: add tests and fix lint issues in caret.ts

This commit is contained in:
JackUait 2025-11-11 11:15:58 +03:00
commit cae0b37779
5 changed files with 1562 additions and 345 deletions

View file

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

View file

@ -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');
}
};

View file

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

View 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);
});
});
});

View 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);
});
});
});