mirror of
https://github.com/andy-stack/vaultkeeper-ai.git
synced 2026-07-22 06:42:03 +00:00
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:
parent
eab7e77303
commit
a3d0b55100
4 changed files with 341 additions and 410 deletions
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -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('<div>')).toBe('<div>');
|
||||
expect(service.stripHtml('&')).toBe('&');
|
||||
expect(service.stripHtml('"')).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();
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue