mirror of
https://github.com/andy-stack/vaultkeeper-ai.git
synced 2026-07-22 06:42:03 +00:00
Convert input helpers into service.
This commit is contained in:
parent
e551155230
commit
34d28587b4
5 changed files with 160 additions and 161 deletions
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
146
Services/InputService.ts
Normal 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;
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
Loading…
Reference in a new issue