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.
This commit is contained in:
Andrew Beal 2025-10-31 18:03:00 +00:00
parent eab7e77303
commit a3d0b55100
4 changed files with 341 additions and 410 deletions

View file

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

View file

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

View file

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

View file

@ -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('<p>Hello</p>')).toBe('Hello');
expect(service.stripHtml('<div>World</div>')).toBe('World');
expect(service.stripHtml('<span>Test</span>')).toBe('Test');
});
it('should remove nested HTML tags', () => {
expect(service.stripHtml('<div><p>Hello</p></div>')).toBe('Hello');
expect(service.stripHtml('<div><span><b>Bold</b></span></div>')).toBe('Bold');
});
it('should preserve text content', () => {
expect(service.stripHtml('<p>Hello <strong>World</strong></p>')).toBe('Hello World');
expect(service.stripHtml('<div>Line 1<br>Line 2</div>')).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('<p class="test" id="para">Content</p>')).toBe('Content');
expect(service.stripHtml('<a href="http://example.com">Link</a>')).toBe('Link');
});
it('should handle self-closing tags', () => {
expect(service.stripHtml('Before<br/>After')).toBe('BeforeAfter');
expect(service.stripHtml('Text<img src="image.png"/>More')).toBe('TextMore');
});
it('should handle multiple paragraphs', () => {
const html = '<p>Paragraph 1</p><p>Paragraph 2</p><p>Paragraph 3</p>';
expect(service.stripHtml(html)).toBe('Paragraph 1Paragraph 2Paragraph 3');
});
it('should handle HTML entities', () => {
// Note: innerHTML parsing automatically converts entities
expect(service.stripHtml('&lt;div&gt;')).toBe('<div>');
expect(service.stripHtml('&amp;')).toBe('&');
expect(service.stripHtml('&quot;')).toBe('"');
});
it('should handle special characters', () => {
expect(service.stripHtml('<p>Special: @#$%</p>')).toBe('Special: @#$%');
expect(service.stripHtml('<div>Math: 1 + 1 = 2</div>')).toBe('Math: 1 + 1 = 2');
});
it('should handle unicode characters', () => {
expect(service.stripHtml('<p>café</p>')).toBe('café');
expect(service.stripHtml('<div>🎉 Party!</div>')).toBe('🎉 Party!');
});
it('should handle malformed HTML gracefully', () => {
expect(service.stripHtml('<p>Unclosed tag')).toBe('Unclosed tag');
expect(service.stripHtml('<div><p>Nested unclosed')).toBe('Nested unclosed');
});
it('should handle script and style tags', () => {
// Browser behavior: script/style content is typically included in textContent
const withScript = '<div>Text<script>alert("test")</script>More</div>';
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('<p> Spaces </p>')).toBe(' Spaces ');
expect(service.stripHtml('<div>\n\tTabs\n</div>')).toContain('Tabs');
});
it('should handle complex nested structures', () => {
const html = `
<div class="container">
<header>
<h1>Title</h1>
</header>
<main>
<p>Paragraph with <strong>bold</strong> and <em>italic</em> text.</p>
</main>
</div>
`;
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('<p></p>')).toBe('');
expect(service.stripHtml('<div><span></span></div>')).toBe('');
});
it('should handle mixed content', () => {
const html = 'Text before<p>Inside tag</p>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();
});
});