From a3d0b5510012e06aca1cbe2f50658042c6f70141 Mon Sep 17 00:00:00 2001 From: Andrew Beal Date: Fri, 31 Oct 2025 18:03:00 +0000 Subject: [PATCH] refactor: improve backspace handling for non-editable elements Replace getNodeAtCursorPosition with getElementBeforeCursor for more robust element detection before cursor. Add cursor position validation after deletions to prevent cursor from ending up inside contenteditable="false" elements. Remove unused helper methods and reorganize code structure. --- Components/ChatInput.svelte | 14 +- Enums/SearchTrigger.ts | 3 +- Services/InputService.ts | 321 +++++++++++------- __tests__/Services/InputService.test.ts | 413 ++++++++---------------- 4 files changed, 341 insertions(+), 410 deletions(-) diff --git a/Components/ChatInput.svelte b/Components/ChatInput.svelte index 8ed324c..f02a7bc 100644 --- a/Components/ChatInput.svelte +++ b/Components/ChatInput.svelte @@ -9,6 +9,7 @@ import ChatSearchResults from "./ChatSearchResults.svelte"; import type { Writable } from "svelte/store"; import type { InputService } from "Services/InputService"; + import { textAreaNode } from "happy-dom/lib/PropertySymbol"; export let hasNoApiKey: boolean; export let isSubmitting: boolean; @@ -80,13 +81,14 @@ e.preventDefault(); - const node = inputService.getNodeAtCursorPosition(); - if (node && SearchTrigger.isSearchTriggerElement(node)) { - (node as HTMLElement).remove(); - } else { - inputService.deleteTextRange(position -1, position, textareaElement); - inputService.setCursorPosition(textareaElement, position - 1); + const elementBeforeCursor = inputService.getElementBeforeCursor(textareaElement); + if (elementBeforeCursor && SearchTrigger.isSearchTriggerElement(elementBeforeCursor)) { + elementBeforeCursor.remove(); + return; } + + inputService.deleteTextRange(position - 1, position, textareaElement); + return; } diff --git a/Enums/SearchTrigger.ts b/Enums/SearchTrigger.ts index e12f50b..694fc33 100644 --- a/Enums/SearchTrigger.ts +++ b/Enums/SearchTrigger.ts @@ -1,3 +1,4 @@ +import { disabled } from "happy-dom/lib/PropertySymbol"; import { basename, extname } from "path"; export enum SearchTrigger { @@ -56,7 +57,7 @@ export namespace SearchTrigger { text: text, cls: "search-trigger", attr: { - contenteditable: "false" + contenteditable: false } }); diff --git a/Services/InputService.ts b/Services/InputService.ts index 33785a9..ebae53a 100644 --- a/Services/InputService.ts +++ b/Services/InputService.ts @@ -2,6 +2,57 @@ import { SearchTrigger } from "../Enums/SearchTrigger"; export class InputService { + public getPlainTextFromClipboard(clipboardData: DataTransfer | null): string { + if (!clipboardData) { + return ""; + } + return clipboardData.getData("text/plain") || ""; + } + + public sanitizeToPlainText(element: HTMLElement): void { + const plainText = element.textContent || ""; + const cursorPos = this.getCursorPosition(element); + + element.textContent = plainText; + + // Restore cursor position after sanitization + this.setCursorPosition(element, cursorPos); + } + + public hasUnauthorizedHTML(element: HTMLElement): boolean { + const checkNode = (node: Node): boolean => { + if (node.nodeType === Node.TEXT_NODE) { + return false; + } + + if (node.nodeType === Node.ELEMENT_NODE) { + const el = node as HTMLElement; + + if (SearchTrigger.isSearchTriggerElement(node)) { + return false; + } + + // Allow BR tags (browsers auto-insert these in contentEditable) + if (el.tagName === "BR") { + return false; + } + + // Allow DIV tags (browsers wrap content in divs) - but check their children recursively + if (el.tagName === "DIV") { + // Recursively check all children of the div + return Array.from(el.childNodes).some(checkNode); + } + } + return true; + }; + + return Array.from(element.childNodes).some(checkNode); + } + + public isInSearchZone(currentPosition: number, triggerPosition: number): boolean { + return currentPosition > triggerPosition; + } + public isPrintableKey(key: string, ctrlKey: boolean = false, metaKey: boolean = false): boolean { // Control or meta keys are not printable if (ctrlKey || metaKey) { @@ -17,16 +68,6 @@ export class InputService { return key === "Enter" || key === "Tab"; } - public isInSearchZone(currentPosition: number, triggerPosition: number): boolean { - return currentPosition > triggerPosition; - } - - public getNodeAtCursorPosition(): Node | null { - const selection = window.getSelection() ?? new Selection(); - const node = selection.anchorNode; - return node ? node.nodeType == 3 ? node.parentNode : node : null; - } - public getCursorPosition(element: HTMLElement): number { const selection = window.getSelection() || new Selection(); @@ -90,65 +131,56 @@ export class InputService { return false; } } - - public getCharacterAtPosition(position: number, element: HTMLElement): string { - const text = element.textContent || ""; - if (position < 0 || position > text.length) { - return ""; - } - return text.charAt(position); - } - - public deleteTextRange(startPos: number, endPos: number, element: HTMLElement): void { - if (!element.isContentEditable) { - console.warn("Element must be contenteditable"); - return; - } + public getElementBeforeCursor(element: HTMLElement): HTMLElement | null { const selection = window.getSelection(); - if (!selection) { - return; + if (!selection || selection.rangeCount === 0) { + return null; } - - try { - const range = document.createRange(); - - // Find the text nodes and offsets for start and end positions - const startResult = this.findTextNodeAndOffset(element, startPos); - const endResult = this.findTextNodeAndOffset(element, endPos); - - if (!startResult.node || !endResult.node) { - console.warn("Could not find text nodes for range deletion"); - return; + + const range = selection.getRangeAt(0); + if (!range.collapsed) { + return null; // Selection is not collapsed, not at a single cursor position + } + + let node = range.startContainer; + let offset = range.startOffset; + + // If we're in a text node and not at the start, we're not next to an element + if (node.nodeType === Node.TEXT_NODE && offset > 0) { + return null; + } + + // If we're at the start of a text node, check the previous sibling + if (node.nodeType === Node.TEXT_NODE && offset === 0) { + const previousSibling = node.previousSibling; + if (previousSibling && previousSibling.nodeType === Node.ELEMENT_NODE) { + return previousSibling as HTMLElement; } - - // Set the range to span from start to end position - range.setStart(startResult.node, startResult.offset); - range.setEnd(endResult.node, endResult.offset); - - // Delete the range contents - range.deleteContents(); - - // Set cursor to where deletion occurred - this.setCursorPosition(element, startPos); - } catch (error) { - console.error("Error deleting text range:", error); + // Check if we need to traverse up to find a previous element + let parent = node.parentNode; + while (parent && parent !== element) { + const prevSibling = parent.previousSibling; + if (prevSibling && prevSibling.nodeType === Node.ELEMENT_NODE) { + return prevSibling as HTMLElement; + } + parent = parent.parentNode; + } + return null; } + + // If we're in an element node, check the child before the offset + if (node.nodeType === Node.ELEMENT_NODE && offset > 0) { + const childBefore = node.childNodes[offset - 1]; + if (childBefore && childBefore.nodeType === Node.ELEMENT_NODE) { + // Return the deepest rightmost element + return this.getDeepestRightmostElement(childBefore as HTMLElement); + } + } + + return null; } - public getPlainTextFromClipboard(clipboardData: DataTransfer | null): string { - if (!clipboardData) { - return ""; - } - return clipboardData.getData("text/plain") || ""; - } - - public stripHtml(html: string): string { - const temp = document.createElement("div"); - temp.innerHTML = html; - return temp.textContent || temp.innerText || ""; - } - public insertTextAtCursor(text: string, element?: HTMLElement): void { if (element && !element.isContentEditable) { console.warn("Element must be contenteditable"); @@ -195,32 +227,121 @@ export class InputService { selection.removeAllRanges(); selection.addRange(range); } + + public deleteTextRange(startPos: number, endPos: number, element: HTMLElement): void { + if (!element.isContentEditable) { + console.warn("Element must be contenteditable"); + return; + } + + const selection = window.getSelection(); + if (!selection) { + return; + } + + try { + const range = document.createRange(); + + // Find the text nodes and offsets for start and end positions + const startResult = this.findTextNodeAndOffset(element, startPos); + const endResult = this.findTextNodeAndOffset(element, endPos); + + if (!startResult.node || !endResult.node) { + console.warn("Could not find text nodes for range deletion"); + return; + } + + // Set the range to span from start to end position + range.setStart(startResult.node, startResult.offset); + range.setEnd(endResult.node, endResult.offset); + + // Delete the range contents + range.deleteContents(); + + // Set cursor to where deletion occurred + this.setCursorPosition(element, startPos); + + // Validate and fix cursor position if it ended up in a non-editable element + this.ensureCursorNotInNonEditableElement(element); + + } catch (error) { + console.error("Error deleting text range:", error); + } + } + + /** + * Gets the deepest rightmost element in a tree, which is what the cursor + * would be "after" when positioned after this element. + */ + private getDeepestRightmostElement(element: HTMLElement): HTMLElement { + let current = element; + while (current.lastChild && current.lastChild.nodeType === Node.ELEMENT_NODE) { + current = current.lastChild as HTMLElement; + } + return current; + } - public getPreviousNode(): Node | null { + /** + * Ensures the cursor is not positioned inside a contentEditable="false" element. + * If it is, repositions the cursor to a valid location. + */ + private ensureCursorNotInNonEditableElement(element: HTMLElement): void { const selection = window.getSelection(); if (!selection || selection.rangeCount === 0) { - return null; + return; } - + const range = selection.getRangeAt(0); - - if (!range.collapsed) { - return null; - } - - let nodeBefore: Node | null = null; - - if (range.startContainer.nodeType !== Node.TEXT_NODE) { - if (range.startOffset > 0) { - nodeBefore = range.startContainer.childNodes[range.startOffset - 1]; + let node: Node | null = range.startContainer; + + // Walk up the tree to check if we're inside a non-editable element + while (node && node !== element) { + if (node.nodeType === Node.ELEMENT_NODE) { + const elem = node as HTMLElement; + if (elem.contentEditable === "false" || elem.getAttribute("contenteditable") === "false") { + // Found a non-editable ancestor - reposition cursor after it + this.positionCursorAfterElement(elem, element); + return; + } } + node = node.parentNode; } - - if (nodeBefore && SearchTrigger.isSearchTriggerElement(nodeBefore)) { - return nodeBefore; + } + + /** + * Positions the cursor immediately after the given element. + */ + private positionCursorAfterElement(targetElement: HTMLElement, container: HTMLElement): void { + const selection = window.getSelection(); + if (!selection) { + return; + } + + try { + const range = document.createRange(); + + // Try to position cursor in the next text node or after the element + const nextSibling = targetElement.nextSibling; + if (nextSibling) { + if (nextSibling.nodeType === Node.TEXT_NODE) { + range.setStart(nextSibling, 0); + range.setEnd(nextSibling, 0); + } else { + range.setStartBefore(nextSibling); + range.setEndBefore(nextSibling); + } + } else { + // No next sibling, position after the element + range.setStartAfter(targetElement); + range.setEndAfter(targetElement); + } + + selection.removeAllRanges(); + selection.addRange(range); + container.focus(); + } catch (error) { + console.error("Error positioning cursor:", error); } - - return null; } private findTextNodeAndOffset(element: HTMLElement, targetPosition: number): { node: Node | null; offset: number } { @@ -254,44 +375,4 @@ export class InputService { return traverse(element); } - public hasUnauthorizedHTML(element: HTMLElement): boolean { - const checkNode = (node: Node): boolean => { - if (node.nodeType === Node.TEXT_NODE) { - return false; - } - - if (node.nodeType === Node.ELEMENT_NODE) { - const el = node as HTMLElement; - - if (SearchTrigger.isSearchTriggerElement(node)) { - return false; - } - - // Allow BR tags (browsers auto-insert these in contentEditable) - if (el.tagName === "BR") { - return false; - } - - // Allow DIV tags (browsers wrap content in divs) - but check their children recursively - if (el.tagName === "DIV") { - // Recursively check all children of the div - return Array.from(el.childNodes).some(checkNode); - } - } - return true; - }; - - return Array.from(element.childNodes).some(checkNode); - } - - public sanitizeToPlainText(element: HTMLElement): void { - const plainText = element.textContent || ""; - const cursorPos = this.getCursorPosition(element); - - element.textContent = plainText; - - // Restore cursor position after sanitization - this.setCursorPosition(element, cursorPos); - } - } \ No newline at end of file diff --git a/__tests__/Services/InputService.test.ts b/__tests__/Services/InputService.test.ts index 5d8f4f2..50b5c17 100644 --- a/__tests__/Services/InputService.test.ts +++ b/__tests__/Services/InputService.test.ts @@ -132,204 +132,6 @@ describe('InputService', () => { }); }); - describe('getCharacterAtPosition', () => { - let element: HTMLDivElement; - - beforeEach(() => { - element = document.createElement('div'); - }); - - it('should return character at valid position', () => { - element.textContent = 'Hello World'; - - expect(service.getCharacterAtPosition(0, element)).toBe('H'); - expect(service.getCharacterAtPosition(1, element)).toBe('e'); - expect(service.getCharacterAtPosition(6, element)).toBe('W'); - expect(service.getCharacterAtPosition(10, element)).toBe('d'); - }); - - it('should return empty string for position beyond text length', () => { - element.textContent = 'Hello'; - - expect(service.getCharacterAtPosition(5, element)).toBe(''); - expect(service.getCharacterAtPosition(10, element)).toBe(''); - expect(service.getCharacterAtPosition(100, element)).toBe(''); - }); - - it('should return empty string for negative position', () => { - element.textContent = 'Hello'; - - expect(service.getCharacterAtPosition(-1, element)).toBe(''); - expect(service.getCharacterAtPosition(-10, element)).toBe(''); - }); - - it('should return empty string for empty element', () => { - element.textContent = ''; - - expect(service.getCharacterAtPosition(0, element)).toBe(''); - expect(service.getCharacterAtPosition(1, element)).toBe(''); - }); - - it('should handle element with null textContent', () => { - // Explicitly set to null (though DOM usually gives empty string) - Object.defineProperty(element, 'textContent', { value: null, writable: true }); - - expect(service.getCharacterAtPosition(0, element)).toBe(''); - }); - - it('should handle single character text', () => { - element.textContent = 'A'; - - expect(service.getCharacterAtPosition(0, element)).toBe('A'); - expect(service.getCharacterAtPosition(1, element)).toBe(''); - }); - - it('should handle spaces and special characters', () => { - element.textContent = ' @#$ '; - - expect(service.getCharacterAtPosition(0, element)).toBe(' '); - expect(service.getCharacterAtPosition(2, element)).toBe('@'); - expect(service.getCharacterAtPosition(3, element)).toBe('#'); - expect(service.getCharacterAtPosition(4, element)).toBe('$'); - }); - - it('should handle unicode characters', () => { - element.textContent = 'café'; - - expect(service.getCharacterAtPosition(0, element)).toBe('c'); - expect(service.getCharacterAtPosition(3, element)).toBe('é'); - // Note: Emoji like 🎉 are surrogate pairs (2 JS characters), so we test simpler unicode - }); - - it('should handle newlines', () => { - element.textContent = 'line1\nline2'; - - expect(service.getCharacterAtPosition(5, element)).toBe('\n'); - expect(service.getCharacterAtPosition(6, element)).toBe('l'); - }); - - it('should handle tabs', () => { - element.textContent = 'before\tafter'; - - expect(service.getCharacterAtPosition(6, element)).toBe('\t'); - }); - - it('should work with nested elements', () => { - // Even with nested elements, textContent flattens to a single string - const span = document.createElement('span'); - span.textContent = 'World'; - element.textContent = 'Hello '; - element.appendChild(span); - - // textContent should now be "Hello World" - expect(service.getCharacterAtPosition(0, element)).toBe('H'); - expect(service.getCharacterAtPosition(6, element)).toBe('W'); - }); - }); - - describe('stripHtml', () => { - it('should remove simple HTML tags', () => { - expect(service.stripHtml('

Hello

')).toBe('Hello'); - expect(service.stripHtml('
World
')).toBe('World'); - expect(service.stripHtml('Test')).toBe('Test'); - }); - - it('should remove nested HTML tags', () => { - expect(service.stripHtml('

Hello

')).toBe('Hello'); - expect(service.stripHtml('
Bold
')).toBe('Bold'); - }); - - it('should preserve text content', () => { - expect(service.stripHtml('

Hello World

')).toBe('Hello World'); - expect(service.stripHtml('
Line 1
Line 2
')).toBe('Line 1Line 2'); - }); - - it('should handle empty string', () => { - expect(service.stripHtml('')).toBe(''); - }); - - it('should handle plain text without HTML', () => { - expect(service.stripHtml('Plain text')).toBe('Plain text'); - expect(service.stripHtml('No tags here!')).toBe('No tags here!'); - }); - - it('should handle HTML with attributes', () => { - expect(service.stripHtml('

Content

')).toBe('Content'); - expect(service.stripHtml('Link')).toBe('Link'); - }); - - it('should handle self-closing tags', () => { - expect(service.stripHtml('Before
After')).toBe('BeforeAfter'); - expect(service.stripHtml('TextMore')).toBe('TextMore'); - }); - - it('should handle multiple paragraphs', () => { - const html = '

Paragraph 1

Paragraph 2

Paragraph 3

'; - expect(service.stripHtml(html)).toBe('Paragraph 1Paragraph 2Paragraph 3'); - }); - - it('should handle HTML entities', () => { - // Note: innerHTML parsing automatically converts entities - expect(service.stripHtml('<div>')).toBe('
'); - expect(service.stripHtml('&')).toBe('&'); - expect(service.stripHtml('"')).toBe('"'); - }); - - it('should handle special characters', () => { - expect(service.stripHtml('

Special: @#$%

')).toBe('Special: @#$%'); - expect(service.stripHtml('
Math: 1 + 1 = 2
')).toBe('Math: 1 + 1 = 2'); - }); - - it('should handle unicode characters', () => { - expect(service.stripHtml('

café

')).toBe('café'); - expect(service.stripHtml('
🎉 Party!
')).toBe('🎉 Party!'); - }); - - it('should handle malformed HTML gracefully', () => { - expect(service.stripHtml('

Unclosed tag')).toBe('Unclosed tag'); - expect(service.stripHtml('

Nested unclosed')).toBe('Nested unclosed'); - }); - - it('should handle script and style tags', () => { - // Browser behavior: script/style content is typically included in textContent - const withScript = '

TextMore
'; - const result = service.stripHtml(withScript); - // Script content might be included depending on browser implementation - expect(result).toContain('Text'); - expect(result).toContain('More'); - }); - - it('should handle whitespace preservation', () => { - expect(service.stripHtml('

Spaces

')).toBe(' Spaces '); - expect(service.stripHtml('
\n\tTabs\n
')).toContain('Tabs'); - }); - - it('should handle complex nested structures', () => { - const html = ` -
-
-

Title

-
-
-

Paragraph with bold and italic text.

-
-
- `; - const result = service.stripHtml(html); - expect(result).toContain('Title'); - expect(result).toContain('Paragraph with bold and italic text.'); - }); - - it('should handle empty tags', () => { - expect(service.stripHtml('

')).toBe(''); - expect(service.stripHtml('
')).toBe(''); - }); - - it('should handle mixed content', () => { - const html = 'Text before

Inside tag

Text after'; - expect(service.stripHtml(html)).toBe('Text beforeInside tagText after'); - }); - }); describe('getPlainTextFromClipboard', () => { it('should extract text/plain from DataTransfer', () => { @@ -838,62 +640,12 @@ describe('InputService', () => { }); }); - describe('getPreviousNode', () => { + describe('getElementBeforeCursor', () => { it('should return null when no selection exists', () => { const selection = window.getSelection(); selection?.removeAllRanges(); - const result = service.getPreviousNode(); - expect(result).toBeNull(); - }); - - it('should return null when cursor is at start', () => { - service.setCursorPosition(element, 0); - const result = service.getPreviousNode(); - expect(result).toBeNull(); - }); - - it('should return null when cursor is in text node', () => { - service.setCursorPosition(element, 5); - const result = service.getPreviousNode(); - expect(result).toBeNull(); - }); - - it('should return search trigger element when cursor is after it', () => { - element.innerHTML = 'Text '; - const trigger = document.createElement('span'); - trigger.className = 'search-trigger'; - trigger.textContent = '#tag'; - element.appendChild(trigger); - element.appendChild(document.createTextNode(' more')); - - // Position cursor right after the trigger element - const range = document.createRange(); - const selection = window.getSelection(); - range.setStart(element, 2); // After text node and trigger - range.setEnd(element, 2); - selection?.removeAllRanges(); - selection?.addRange(range); - - const result = service.getPreviousNode(); - expect(result).toBe(trigger); - }); - - it('should return null when previous element is not a search trigger', () => { - element.innerHTML = 'Text '; - const span = document.createElement('span'); - span.textContent = 'regular span'; - element.appendChild(span); - element.appendChild(document.createTextNode(' more')); - - const range = document.createRange(); - const selection = window.getSelection(); - range.setStart(element, 2); // After text node and span - range.setEnd(element, 2); - selection?.removeAllRanges(); - selection?.addRange(range); - - const result = service.getPreviousNode(); + const result = service.getElementBeforeCursor(element); expect(result).toBeNull(); }); @@ -907,49 +659,144 @@ describe('InputService', () => { selection?.removeAllRanges(); selection?.addRange(range); - const result = service.getPreviousNode(); + const result = service.getElementBeforeCursor(element); expect(result).toBeNull(); }); - }); - describe('getNodeAtCursorPosition', () => { - it('should return node at cursor position', () => { - service.setCursorPosition(element, 5); - const node = service.getNodeAtCursorPosition(); - - // Should return the parent element or text node - expect(node).toBeTruthy(); + it('should return null when cursor is in middle of text node', () => { + service.setCursorPosition(element, 5); // Middle of "Hello World" + const result = service.getElementBeforeCursor(element); + expect(result).toBeNull(); }); - it('should return null when no selection exists', () => { - const selection = window.getSelection(); - selection?.removeAllRanges(); - - const node = service.getNodeAtCursorPosition(); - // JSDOM may return an empty Selection, so node might be null or a node - // We just verify it doesn't crash - expect(node === null || node instanceof Node).toBe(true); - }); - - it('should return parent node for text nodes', () => { - service.setCursorPosition(element, 5); - const node = service.getNodeAtCursorPosition(); - - // Since cursor is in text, it should return the parent (element) - expect(node?.nodeType).toBe(Node.ELEMENT_NODE); - }); - - it('should handle nested elements', () => { + it('should return element when cursor is at start of text node after an element', () => { element.innerHTML = ''; - element.appendChild(document.createTextNode('Hello ')); const span = document.createElement('span'); - span.textContent = 'World'; + span.className = 'test-element'; + span.textContent = 'Element'; element.appendChild(span); + element.appendChild(document.createTextNode(' text')); - service.setCursorPosition(element, 8); // Inside span - const node = service.getNodeAtCursorPosition(); + // Position cursor at start of the text node after span + const range = document.createRange(); + const selection = window.getSelection(); + const textNode = element.childNodes[1] as Text; + range.setStart(textNode, 0); + range.setEnd(textNode, 0); + selection?.removeAllRanges(); + selection?.addRange(range); - expect(node).toBeTruthy(); + const result = service.getElementBeforeCursor(element); + expect(result).toBe(span); + }); + + it('should return element when cursor is positioned after element node', () => { + element.innerHTML = 'Text '; + const span = document.createElement('span'); + span.className = 'test-element'; + span.textContent = 'Element'; + element.appendChild(span); + element.appendChild(document.createTextNode(' more')); + + // Position cursor right after the span element + const range = document.createRange(); + const selection = window.getSelection(); + range.setStart(element, 2); // After text node and span + range.setEnd(element, 2); + selection?.removeAllRanges(); + selection?.addRange(range); + + const result = service.getElementBeforeCursor(element); + expect(result).toBe(span); + }); + + it('should work with search trigger elements', () => { + element.innerHTML = 'Text '; + const trigger = document.createElement('span'); + trigger.className = 'search-trigger'; + trigger.textContent = '#tag'; + element.appendChild(trigger); + element.appendChild(document.createTextNode(' more')); + + // Position cursor right after the trigger + const range = document.createRange(); + const selection = window.getSelection(); + const textNode = element.childNodes[2] as Text; + range.setStart(textNode, 0); + range.setEnd(textNode, 0); + selection?.removeAllRanges(); + selection?.addRange(range); + + const result = service.getElementBeforeCursor(element); + expect(result).toBe(trigger); + }); + + it('should return deepest rightmost element for nested structures', () => { + element.innerHTML = ''; + const outer = document.createElement('div'); + const inner = document.createElement('span'); + inner.className = 'inner'; + inner.textContent = 'Nested'; + outer.appendChild(inner); + element.appendChild(document.createTextNode('Before ')); + element.appendChild(outer); + element.appendChild(document.createTextNode(' After')); + + // Position cursor right after the outer div (which contains inner span) + const range = document.createRange(); + const selection = window.getSelection(); + range.setStart(element, 2); // After text node and outer div + range.setEnd(element, 2); + selection?.removeAllRanges(); + selection?.addRange(range); + + const result = service.getElementBeforeCursor(element); + // Should return the deepest rightmost element (inner span) + expect(result).toBe(inner); + }); + + it('should return null when cursor is at start of element', () => { + service.setCursorPosition(element, 0); + const result = service.getElementBeforeCursor(element); + expect(result).toBeNull(); + }); + + it('should handle cursor positioned in element node with child before', () => { + element.innerHTML = ''; + element.appendChild(document.createTextNode('Text')); + const span = document.createElement('span'); + span.textContent = 'Element'; + element.appendChild(span); + element.appendChild(document.createTextNode('After')); + + // Position cursor in the element node itself, after the span + const range = document.createRange(); + const selection = window.getSelection(); + range.setStart(element, 2); // After text and span + range.setEnd(element, 2); + selection?.removeAllRanges(); + selection?.addRange(range); + + const result = service.getElementBeforeCursor(element); + expect(result).toBe(span); + }); + + it('should return null when previous sibling is a text node', () => { + element.innerHTML = ''; + element.appendChild(document.createTextNode('First')); + element.appendChild(document.createTextNode('Second')); + + // Position cursor at start of second text node + const range = document.createRange(); + const selection = window.getSelection(); + const secondText = element.childNodes[1] as Text; + range.setStart(secondText, 0); + range.setEnd(secondText, 0); + selection?.removeAllRanges(); + selection?.addRange(range); + + const result = service.getElementBeforeCursor(element); + expect(result).toBeNull(); }); });