diff --git a/src/components/modules/caret.ts b/src/components/modules/caret.ts index db8a4f3b..77190a61 100644 --- a/src/components/modules/caret.ts +++ b/src/components/modules/caret.ts @@ -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)); diff --git a/src/components/utils/caret.ts b/src/components/utils/caret.ts index 58bf9604..62f0ea1d 100644 --- a/src/components/utils/caret.ts +++ b/src/components/utils/caret.ts @@ -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'); -} +}; diff --git a/test/cypress/tests/api/caret.cy.ts b/test/cypress/tests/api/caret.cy.ts deleted file mode 100644 index 00fc21af..00000000 --- a/test/cypress/tests/api/caret.cy.ts +++ /dev/null @@ -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('@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('@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('@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('@editorInstance') - .then(async (editor) => { - const block = editor.blocks.getById(paragraphDataMock.id); - - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - editor.caret.setToBlock(block!, 'default', 5); - - cy.window() - .then((window) => { - const selection = window.getSelection(); - - if (!selection) { - throw new Error('Selection not found'); - } - const range = selection.getRangeAt(0); - - expect(range.startOffset).to.equal(5); - }); - }); - }); - - it('should set caret at correct offset when text contains HTML elements', () => { - const paragraphDataMock = createParagraphMock('1234567!'); - - cy.createEditor({ - data: { - blocks: [ - paragraphDataMock, - ], - }, - }).as('editorInstance'); - - cy.get('@editorInstance') - .then(async (editor) => { - const block = editor.blocks.getById(paragraphDataMock.id); - - // Set caret after "12345" - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - editor.caret.setToBlock(block!, 'default', 6); - - cy.window() - .then((window) => { - const selection = window.getSelection(); - - if (!selection) { - throw new Error('Selection not found'); - } - const range = selection.getRangeAt(0); - - expect(range.startContainer.textContent).to.equal('567'); - expect(range.startOffset).to.equal(2); - }); - }); - }); - - it('should handle offset beyond content length', () => { - const paragraphDataMock = createParagraphMock('1234567890'); - - cy.createEditor({ - data: { - blocks: [ - paragraphDataMock, - ], - }, - }).as('editorInstance'); - - cy.get('@editorInstance') - .then(async (editor) => { - const block = editor.blocks.getById(paragraphDataMock.id); - - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - const contentLength = block!.holder.textContent?.length ?? 0; - - // Set caret beyond content length - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - editor.caret.setToBlock(block!, 'default', contentLength + 10); - - cy.window() - .then((window) => { - const selection = window.getSelection(); - - if (!selection) { - throw new Error('Selection not found'); - } - const range = selection.getRangeAt(0); - - // Should be at the end of content - expect(range.startOffset).to.equal(contentLength); - }); - }); - }); - - it('should handle offset in nested HTML structure', () => { - const paragraphDataMock = createParagraphMock('123456789!'); - - cy.createEditor({ - data: { - blocks: [ - paragraphDataMock, - ], - }, - }).as('editorInstance'); - - cy.get('@editorInstance') - .then(async (editor) => { - const block = editor.blocks.getById(paragraphDataMock.id); - - - // Set caret after "8" - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - editor.caret.setToBlock(block!, 'default', 8); - - cy.window() - .then((window) => { - const selection = window.getSelection(); - - if (!selection) { - throw new Error('Selection not found'); - } - const range = selection.getRangeAt(0); - - expect(range.startContainer.textContent).to.equal('789'); - expect(range.startOffset).to.equal(2); - }); - }); - }); - }); - }); -}); diff --git a/test/playwright/tests/api/caret.spec.ts b/test/playwright/tests/api/caret.spec.ts new file mode 100644 index 00000000..28cfd82e --- /dev/null +++ b/test/playwright/tests/api/caret.spec.ts @@ -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; +}; + +type EditorSetupOptions = { + data?: Record; + config?: Record; + tools?: ToolDefinition[]; +}; + +const resetEditor = async (page: Page): Promise => { + 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 => { + 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>((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 => { + await page.evaluate(() => { + window.getSelection()?.removeAllRanges(); + + const activeElement = document.activeElement as HTMLElement | null; + + activeElement?.blur?.(); + }); +}; + +const createParagraphBlock = (id: string, text: string): Record => { + 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 { + 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, '1234567!'); + + 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, '123456789!'); + + 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 world!'); + + 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); + }); + }); +}); + diff --git a/test/unit/utils/caret.test.ts b/test/unit/utils/caret.test.ts new file mode 100644 index 00000000..f31fd71e --- /dev/null +++ b/test/unit/utils/caret.test.ts @@ -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); + }); + }); +}); +