Convert input helpers into service.

This commit is contained in:
Andrew Beal 2025-10-29 19:46:20 +00:00
parent e551155230
commit 34d28587b4
5 changed files with 160 additions and 161 deletions

View file

@ -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<InputService>(Services.InputService);
const userInputService: UserInputService = Resolve<UserInputService>(Services.UserInputService);
const searchStateStore: SearchStateStore = Resolve<SearchStateStore>(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();
}
}

View file

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

146
Services/InputService.ts Normal file
View file

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

View file

@ -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<AIAgentPlugin>(Services.AIAgentPlugin, plugin);
@ -54,6 +55,7 @@ export function RegisterDependencies(plugin: AIAgentPlugin) {
RegisterSingleton<ChatService>(Services.ChatService, new ChatService());
RegisterTransient<StreamingMarkdownService>(Services.StreamingMarkdownService, () => new StreamingMarkdownService());
RegisterTransient<InputService>(Services.InputService, () => new InputService());
RegisterModals(plugin.app);
RegisterAiProvider(plugin);

View file

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