From 34d28587b424e5c296d7e92603a9fb8fb89c41d7 Mon Sep 17 00:00:00 2001 From: Andrew Beal Date: Wed, 29 Oct 2025 19:46:20 +0000 Subject: [PATCH] Convert input helpers into service. --- Components/ChatInput.svelte | 30 +++---- Helpers/InputHelpers.ts | 142 ------------------------------- Services/InputService.ts | 146 ++++++++++++++++++++++++++++++++ Services/ServiceRegistration.ts | 2 + Services/Services.ts | 1 + 5 files changed, 160 insertions(+), 161 deletions(-) delete mode 100644 Helpers/InputHelpers.ts create mode 100644 Services/InputService.ts diff --git a/Components/ChatInput.svelte b/Components/ChatInput.svelte index f44e067..bc50602 100644 --- a/Components/ChatInput.svelte +++ b/Components/ChatInput.svelte @@ -7,17 +7,8 @@ import { Services } from "Services/Services"; import { SearchTrigger } from "Enums/SearchTrigger"; import ChatSearchResults from "./ChatSearchResults.svelte"; - import { - getCharacterAtPosition, - getCursorPosition, - setCursorPosition, - getPlainTextFromClipboard, - insertTextAtCursor, - isPrintableKey, - isInSearchZone, - stripHtml - } from "Helpers/InputHelpers"; import type { Writable } from "svelte/store"; + import type { InputService } from "Services/InputService"; export let hasNoApiKey: boolean; export let isSubmitting: boolean; @@ -26,6 +17,7 @@ export let ontoggleeditmode: () => void; export let onstop: () => void; + const inputService: InputService = Resolve(Services.InputService); const userInputService: UserInputService = Resolve(Services.UserInputService); const searchStateStore: SearchStateStore = Resolve(Services.SearchStateStore); @@ -87,13 +79,13 @@ if (SearchTrigger.isSearchTrigger(e.key)) { e.preventDefault(); - const position = getCursorPosition(textareaElement) + const position = inputService.getCursorPosition(textareaElement) const trigger = SearchTrigger.fromInput(e.key); searchStateStore.initializeSearch(trigger, position); textareaElement.textContent = textareaElement.textContent + e.key; - setCursorPosition(textareaElement, position + 1); + inputService.setCursorPosition(textareaElement, position + 1); } } @@ -126,7 +118,7 @@ } // Only append printable characters to the query - if (isPrintableKey(e.key, e.ctrlKey, e.metaKey)) { + if (inputService.isPrintableKey(e.key, e.ctrlKey, e.metaKey)) { searchStateStore.appendToQuery(e.key); userInputService.performSearch(); } @@ -139,12 +131,12 @@ if (textareaElement.innerHTML !== textareaElement.textContent) { // HTML detected - sanitize by replacing with plain text const plainText = textareaElement.textContent || ""; - const cursorPos = getCursorPosition(textareaElement); + const cursorPos = inputService.getCursorPosition(textareaElement); textareaElement.textContent = plainText; // Restore cursor position after sanitization - setCursorPosition(textareaElement, cursorPos); + inputService.setCursorPosition(textareaElement, cursorPos); } // If in search mode, synchronize the query with actual text content @@ -171,13 +163,13 @@ function handlePaste(e: ClipboardEvent) { e.preventDefault(); - const plainText = getPlainTextFromClipboard(e.clipboardData); + const plainText = inputService.getPlainTextFromClipboard(e.clipboardData); if (!plainText) { return; } - insertTextAtCursor(plainText); + inputService.insertTextAtCursor(plainText); handleInput(); } @@ -199,9 +191,9 @@ return; } - const currentPosition = getCursorPosition(textareaElement); + const currentPosition = inputService.getCursorPosition(textareaElement); - if (!isInSearchZone(currentPosition, $searchState.position)) { + if (!inputService.isInSearchZone(currentPosition, $searchState.position)) { searchStateStore.resetSearch(); } } diff --git a/Helpers/InputHelpers.ts b/Helpers/InputHelpers.ts deleted file mode 100644 index 17dfc9a..0000000 --- a/Helpers/InputHelpers.ts +++ /dev/null @@ -1,142 +0,0 @@ -export function getCursorPosition(element: HTMLElement): number { - const selection = window.getSelection(); - - if (!selection || selection.rangeCount === 0) { - return 0; - } - - const range = selection.getRangeAt(0); - - if (!element.contains(range.commonAncestorContainer)) { - return 0; - } - - const preCaretRange = range.cloneRange(); - preCaretRange.selectNodeContents(element); - preCaretRange.setEnd(range.endContainer, range.endOffset); - - return preCaretRange.toString().length; -} - -export function setCursorPosition(element: HTMLElement, position: number): void { - const textContent = element.textContent || ""; - const clampedPosition = Math.max(0, Math.min(position, textContent.length)); - - const nodeAndOffset = getTextNodeAtPosition(element, clampedPosition); - - if (nodeAndOffset) { - const range = document.createRange(); - const selection = window.getSelection(); - - if (selection) { - range.setStart(nodeAndOffset.node, nodeAndOffset.offset); - range.setEnd(nodeAndOffset.node, nodeAndOffset.offset); - selection.removeAllRanges(); - selection.addRange(range); - } - } -} - -export function getCharacterAtPosition(position: number, element: HTMLElement): string { - const text = element.textContent || ""; - if (position < 0 || position > text.length) { - return ""; - } - return text.charAt(position); -} - -/** - * Checks if a keyboard key represents a printable character - * @param key The key from KeyboardEvent.key - * @param ctrlKey Whether Ctrl/Cmd is pressed - * @param metaKey Whether Meta/Cmd is pressed - * @returns true if the key is a printable character - */ -export function 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"; -} - -/** - * Checks if cursor is in a valid search zone (after the trigger position) - * @param currentPosition Current cursor position - * @param triggerPosition Position where search trigger was typed - * @returns true if cursor is in valid search zone - */ -export function isInSearchZone(currentPosition: number, triggerPosition: number): boolean { - return currentPosition > triggerPosition; -} - -export function getPlainTextFromClipboard(clipboardData: DataTransfer | null): string { - if (!clipboardData) { - return ""; - } - return clipboardData.getData('text/plain') || ""; -} - -export function stripHtml(html: string): string { - const temp = document.createElement('div'); - temp.innerHTML = html; - return temp.textContent || temp.innerText || ""; -} - -export function insertTextAtCursor(text: string): void { - 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); - - // Move cursor to end of inserted text - range.setStartAfter(textNode); - range.setEndAfter(textNode); - selection.removeAllRanges(); - selection.addRange(range); -} - -function getTextNodeAtPosition(node: Node, targetPosition: number): { node: Node; offset: number } | null { - if (node.nodeType === Node.TEXT_NODE) { - return { node, offset: targetPosition }; - } - - let currentPosition = 0; - - for (let i = 0; i < node.childNodes.length; i++) { - const child = node.childNodes[i]; - - if (child.nodeType === Node.TEXT_NODE) { - const textLength = child.textContent?.length || 0; - - if (currentPosition + textLength >= targetPosition) { - return { node: child, offset: targetPosition - currentPosition }; - } - - currentPosition += textLength; - } else { - const childTextLength = child.textContent?.length || 0; - - if (currentPosition + childTextLength >= targetPosition) { - return getTextNodeAtPosition(child, targetPosition - currentPosition); - } - - currentPosition += childTextLength; - } - } - - return null; -} \ No newline at end of file diff --git a/Services/InputService.ts b/Services/InputService.ts new file mode 100644 index 0000000..3e640ca --- /dev/null +++ b/Services/InputService.ts @@ -0,0 +1,146 @@ +export class InputService { + + public getCursorPosition(element: HTMLElement): number { + const selection = window.getSelection(); + + if (!selection || selection.rangeCount === 0) { + return 0; + } + + const range = selection.getRangeAt(0); + + if (!element.contains(range.commonAncestorContainer)) { + return 0; + } + + const preCaretRange = range.cloneRange(); + preCaretRange.selectNodeContents(element); + preCaretRange.setEnd(range.endContainer, range.endOffset); + + return preCaretRange.toString().length; + } + + public setCursorPosition(element: HTMLElement, position: number): void { + const textContent = element.textContent || ""; + const clampedPosition = Math.max(0, Math.min(position, textContent.length)); + + const nodeAndOffset = this.getTextNodeAtPosition(element, clampedPosition); + + if (nodeAndOffset) { + const range = document.createRange(); + const selection = window.getSelection(); + + if (selection) { + range.setStart(nodeAndOffset.node, nodeAndOffset.offset); + range.setEnd(nodeAndOffset.node, nodeAndOffset.offset); + selection.removeAllRanges(); + selection.addRange(range); + } + } + } + + public getCharacterAtPosition(position: number, element: HTMLElement): string { + const text = element.textContent || ""; + if (position < 0 || position > text.length) { + return ""; + } + return text.charAt(position); + } + + /** + * Checks if a keyboard key represents a printable character + * @param key The key from KeyboardEvent.key + * @param ctrlKey Whether Ctrl/Cmd is pressed + * @param metaKey Whether Meta/Cmd is pressed + * @returns true if the key is a printable character + */ + 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"; + } + + /** + * Checks if cursor is in a valid search zone (after the trigger position) + * @param currentPosition Current cursor position + * @param triggerPosition Position where search trigger was typed + * @returns true if cursor is in valid search zone + */ + public isInSearchZone(currentPosition: number, triggerPosition: number): boolean { + return currentPosition > triggerPosition; + } + + 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): void { + 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); + + // Move cursor to end of inserted text + range.setStartAfter(textNode); + range.setEndAfter(textNode); + selection.removeAllRanges(); + selection.addRange(range); + } + + public getTextNodeAtPosition(node: Node, targetPosition: number): { node: Node; offset: number } | null { + if (node.nodeType === Node.TEXT_NODE) { + return { node, offset: targetPosition }; + } + + let currentPosition = 0; + + for (let i = 0; i < node.childNodes.length; i++) { + const child = node.childNodes[i]; + + if (child.nodeType === Node.TEXT_NODE) { + const textLength = child.textContent?.length || 0; + + if (currentPosition + textLength >= targetPosition) { + return { node: child, offset: targetPosition - currentPosition }; + } + + currentPosition += textLength; + } else { + const childTextLength = child.textContent?.length || 0; + + if (currentPosition + childTextLength >= targetPosition) { + return this.getTextNodeAtPosition(child, targetPosition - currentPosition); + } + + currentPosition += childTextLength; + } + } + + return null; + } + +} \ No newline at end of file diff --git a/Services/ServiceRegistration.ts b/Services/ServiceRegistration.ts index a755954..ad8ac42 100644 --- a/Services/ServiceRegistration.ts +++ b/Services/ServiceRegistration.ts @@ -32,6 +32,7 @@ import { SanitiserService } from "./SanitiserService"; import { VaultCacheService } from "./VaultCacheService"; import { UserInputService } from "./UserInputService"; import { SearchStateStore } from "Stores/SearchStateStore"; +import { InputService } from "./InputService"; export function RegisterDependencies(plugin: AIAgentPlugin) { RegisterSingleton(Services.AIAgentPlugin, plugin); @@ -54,6 +55,7 @@ export function RegisterDependencies(plugin: AIAgentPlugin) { RegisterSingleton(Services.ChatService, new ChatService()); RegisterTransient(Services.StreamingMarkdownService, () => new StreamingMarkdownService()); + RegisterTransient(Services.InputService, () => new InputService()); RegisterModals(plugin.app); RegisterAiProvider(plugin); diff --git a/Services/Services.ts b/Services/Services.ts index b78cbd9..a3ca852 100644 --- a/Services/Services.ts +++ b/Services/Services.ts @@ -16,6 +16,7 @@ export class Services { static AIFunctionService = Symbol("AIFunctionService"); static ChatService = Symbol("ChatService"); static SanitiserService = Symbol("SanitiserService"); + static InputService = Symbol("InputService"); // stores static SearchStateStore = Symbol("SearchStateStore");