andy-stack_vaultkeeper-ai/Services/InputService.ts
Andrew Beal a86d784888 refactor: remove unused backspace handling and element detection logic
Remove custom backspace key handler in ChatInput and unused getElementBeforeCursor method in InputService. Clean up unused imports and associated test cases.
2025-12-11 12:41:14 +00:00

319 lines
No EOL
11 KiB
TypeScript

import { Exception } from "Helpers/Exception";
import { isSearchTriggerElement } from "../Enums/SearchTrigger";
export class InputService {
public getPlainTextFromClipboard(clipboardData: DataTransfer | null): string {
if (!clipboardData) {
return "";
}
return clipboardData.getData("text/plain") || "";
}
public sanitizeToPlainText(element: HTMLElement) {
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 (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) {
return false;
}
// Single character keys are printable
if (key.length === 1) {
return true;
}
// Special printable keys
return key === "Enter" || key === "Tab";
}
public getCursorPosition(element: HTMLElement): number {
const selection = window.getSelection() || new Selection();
if (selection.rangeCount === 0) {
return -1;
}
const range = selection.getRangeAt(0);
const preSelectionRange = range.cloneRange();
preSelectionRange.selectNodeContents(element);
preSelectionRange.setEnd(range.startContainer, range.startOffset);
return preSelectionRange.toString().length;
}
public setCursorPosition(element: HTMLElement, position: number, fromEnd: boolean = false) {
// Ensure element is focusable
if (!element.isContentEditable) {
console.warn("Element must be contenteditable");
return false;
}
// Get text content for position calculation
const textContent = element.textContent || element.innerText || "";
const maxPosition = textContent.length;
// Calculate actual position
let targetPosition;
if (fromEnd) {
targetPosition = Math.max(0, maxPosition - position);
} else {
targetPosition = Math.min(position, maxPosition);
}
try {
// Create range and selection
const range = document.createRange();
const selection = window.getSelection() ?? new Selection();
// Find the text node and position
const result = this.findTextNodeAndOffset(element, targetPosition);
if (result.node) {
range.setStart(result.node, result.offset);
range.setEnd(result.node, result.offset);
// Clear existing selection and apply new range
selection.removeAllRanges();
selection.addRange(range);
// Focus the element
element.focus();
return true;
}
return false;
} catch (error) {
Exception.log(error);
return false;
}
}
public insertTextAtCursor(text: string, element?: HTMLElement) {
if (element && !element.isContentEditable) {
console.warn("Element must be contenteditable");
return;
}
const selection = window.getSelection();
if (!selection || selection.rangeCount === 0) {
return;
}
const range = selection.getRangeAt(0);
range.deleteContents();
const textNode = document.createTextNode(text);
range.insertNode(textNode);
range.setStartAfter(textNode);
range.setEndAfter(textNode);
selection.removeAllRanges();
selection.addRange(range);
}
public insertElementAtCursor(node: Node, element?: HTMLElement) {
if (element && !element.isContentEditable) {
console.warn("Element must be contenteditable");
return;
}
const selection = window.getSelection();
if (!selection || selection.rangeCount === 0) {
return;
}
const range = selection.getRangeAt(0);
range.deleteContents();
// Insert the node
range.insertNode(node);
// Move cursor to end of inserted element
range.setStartAfter(node);
range.setEndAfter(node);
selection.removeAllRanges();
selection.addRange(range);
}
public deleteTextRange(startPos: number, endPos: number, element: HTMLElement) {
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) {
Exception.log(error);
}
}
/**
* 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) {
const selection = window.getSelection();
if (!selection || selection.rangeCount === 0) {
return;
}
const range = selection.getRangeAt(0);
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;
}
}
/**
* Positions the cursor immediately after the given element.
*/
private positionCursorAfterElement(targetElement: HTMLElement, container: HTMLElement) {
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) {
Exception.log(error);
}
}
private findTextNodeAndOffset(element: HTMLElement, targetPosition: number): { node: Node | null; offset: number } {
let currentPosition = 0;
function traverse(node: Node): { node: Node | null; offset: number } {
// If it"s a text node, check if target position falls within it
if (node.nodeType === Node.TEXT_NODE) {
const textLength = node.textContent?.length || 0;
if (currentPosition + textLength >= targetPosition) {
// Found the target text node
const offset = targetPosition - currentPosition;
return { node: node, offset: offset };
}
currentPosition += textLength;
} else {
// Recursively check child nodes
for (let index = 0; index < node.childNodes.length; index++) {
const result = traverse(node.childNodes[index]);
if (result.node) {
return result;
}
}
}
return { node: null, offset: 0 };
}
return traverse(element);
}
}