export enum SilkeCommandType { ACTION = '/', MENTION = '@', } export type SilkeCommand = { partialMatch?: boolean; type: SilkeCommandType; value: string; }; export type SilkeTextValue = { type: 'text'; value: string; }; export type SilkeImageValue = { type: 'image'; /** Base64-encoded image data */ data: string; /** MIME type (e.g., 'image/png', 'image/jpeg') */ mimeType: string; /** Optional filename */ name?: string; }; export type SilkeFileValue = { type: 'file'; /** Base64-encoded file data */ data: string; /** MIME type (e.g., 'text/csv', 'application/vnd.ms-excel') */ mimeType: string; /** Filename */ name: string; }; export type SilkeCommandTextFieldValue = | SilkeCommand | SilkeTextValue | SilkeImageValue | SilkeFileValue; /** Key direction mappings for navigation and editing */ export const KEY_DIRECTION: Record = { ArrowLeft: -1, ArrowRight: 1, Backspace: -1, Delete: 1, }; /** Get the caret offset within a contentEditable element */ export function getCaretOffset(element: HTMLDivElement | null): number { if (!element) return 0; const selection = window.getSelection(); if (!selection || selection.rangeCount === 0) return 0; const range = selection.getRangeAt(0); const preCaretRange = range.cloneRange(); preCaretRange.selectNodeContents(element); preCaretRange.setEnd(range.endContainer, range.endOffset); return preCaretRange.toString().length; } /** Check if there is a text selection with length > 0 */ export function hasSelectionLength(): boolean { const selection = window.getSelection(); if (!selection || selection.rangeCount === 0) return false; const range = selection.getRangeAt(0); return range.toString().length > 0; } /** Set the caret to a specific character offset within a contentEditable element */ export function setCaretOffset(element: HTMLDivElement | null, offset: number): void { if (!element) return; const selection = window.getSelection(); if (!selection) return; const range = document.createRange(); let currentPos = 0; const clampedOffset = Math.max(0, offset); const traverseNodes = (node: Node): boolean => { if (node.nodeType === Node.TEXT_NODE) { const textNode = node as Text; const nextPos = currentPos + textNode.length; if (clampedOffset <= nextPos) { range.setStart(node, clampedOffset - currentPos); range.collapse(true); return true; } currentPos = nextPos; } else { for (const child of node.childNodes) { if (traverseNodes(child)) return true; } } return false; }; if (traverseNodes(element)) { selection.removeAllRanges(); selection.addRange(range); } } /** Escape HTML special characters to prevent XSS */ export function escapeHtml(text: string): string { const htmlEscapes: Record = { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''', }; return text.replace(/[&<>"']/g, (char) => htmlEscapes[char]); } /** Get the index of the command at a given caret position */ export function getActiveCommandIndex( caretPos: number, value: SilkeCommandTextFieldValue[], ): number { if (!value || value.length === 0) return 0; let textLength = 0; for (let i = 0; i < value.length; i++) { const item = value[i]; if (item.type === 'image' || item.type === 'file') continue; // Attachments have no text length textLength += item.value?.length || 0; if (item.type !== 'text') textLength++; if (textLength > caretPos) return i; } return value.length - 1; } /** Calculate total text length up to a given index */ export function getTextLength(value: SilkeCommandTextFieldValue[], index?: number): number { if (!value || value.length === 0) return 0; const endIndex = index ?? value.length; if (endIndex <= 0) return 0; return value.slice(0, endIndex).reduce((acc, curr) => { if (curr.type === 'image' || curr.type === 'file') return acc; // Attachments have no text length return acc + (curr.type === 'text' ? curr.value.length : curr.value.length + 1); }, 0); } /** Get the rendered length of a single command/text value */ export function getItemLength(item: SilkeCommandTextFieldValue): number { if (!item || item.type === 'image' || item.type === 'file') return 0; return item.type === 'text' ? item.value.length : item.value.length + 1; } /** Convert command values to HTML for rendering (excludes images) */ export function valueToHtml( value: SilkeCommandTextFieldValue[], styles: Record, ): string { if (!value || value.length === 0) return ''; return value .filter( (item): item is SilkeCommand | SilkeTextValue => item.type !== 'image' && item.type !== 'file', ) .map((item) => { const escapedValue = escapeHtml(item.value); if (item.type === 'text') { // Preserve spaces by replacing them with non-breaking spaces in space-only segments // This prevents browser whitespace collapsing const preservedValue = item.value.trim() === '' ? item.value.replace(/ /g, '\u00A0') : escapedValue; return `${preservedValue}`; } const typeClass = item.type === SilkeCommandType.MENTION ? styles.mention : styles.action; const partialClass = item.partialMatch ? styles.partial : ''; return `${item.type}${escapedValue}`; }) .join(''); } /** Convert command values to plain string (excludes images) */ export function convertSilkeCommandsToString(value: SilkeCommandTextFieldValue[]): string { if (!value) return ''; return value .filter( (v): v is SilkeCommand | SilkeTextValue => v.type !== 'image' && v.type !== 'file', ) .map((v) => (v.type === 'text' ? v.value : v.type + v.value)) .join(''); } /** Check if a command value is a non-text, non-image command */ export function isSilkeCommand( value: SilkeCommandTextFieldValue | undefined, ): value is SilkeCommand { return ( value !== null && value !== undefined && value.type !== 'text' && value.type !== 'image' && value.type !== 'file' ); } /** Check if a command value is a text segment */ export function isText(value: SilkeCommandTextFieldValue | undefined): value is SilkeTextValue { return value !== null && value !== undefined && value.type === 'text'; } /** Check if a command value is an image */ export function isImage(value: SilkeCommandTextFieldValue | undefined): value is SilkeImageValue { return value !== null && value !== undefined && value.type === 'image'; } /** Check if a command value is a non-image file attachment */ export function isFile(value: SilkeCommandTextFieldValue | undefined): value is SilkeFileValue { return value !== null && value !== undefined && value.type === 'file'; } /** Check if a command value is any attachment (image or file) */ export function isAttachment( value: SilkeCommandTextFieldValue | undefined, ): value is SilkeImageValue | SilkeFileValue { return isImage(value) || isFile(value); } /** * Calculate a match score for a command against a search query. * Higher scores indicate better matches. * Returns -1 if there's no match. */ export function getCommandMatchScore(command: SilkeCommand, query: string): number { if (!query || !command.value) return -1; const queryLower = query.toLowerCase(); const valueLower = command.value.toLowerCase(); // Exact match (highest priority) if (valueLower === queryLower) return 100; // Starts with (high priority) if (valueLower.startsWith(queryLower)) { // Shorter matches score higher (more specific) return 80 + (queryLower.length / valueLower.length) * 10; } // Contains as word boundary (medium-high priority) // e.g., "test" matches "run-test" or "run_test" const wordBoundaryRegex = new RegExp(`(?:^|[-_\\s])${escapeRegExp(queryLower)}`); if (wordBoundaryRegex.test(valueLower)) { return 60 + (queryLower.length / valueLower.length) * 10; } // Contains anywhere (medium priority) const containsIndex = valueLower.indexOf(queryLower); if (containsIndex !== -1) { // Earlier matches score higher return 40 + (1 - containsIndex / valueLower.length) * 10; } // Fuzzy match - all query characters appear in order (low priority) if (fuzzyMatch(queryLower, valueLower)) { return 20; } return -1; } /** Escape special regex characters */ function escapeRegExp(string: string): string { return string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); } /** Check if all characters in query appear in value in order */ function fuzzyMatch(query: string, value: string): boolean { let queryIndex = 0; for (let i = 0; i < value.length && queryIndex < query.length; i++) { if (value[i] === query[queryIndex]) { queryIndex++; } } return queryIndex === query.length; } /** * Filter and sort commands based on partial matching against a query. * Returns commands sorted by match quality (best matches first). * If query is empty, returns all commands of the given type sorted alphabetically. */ export function filterAndSortCommands( commands: SilkeCommand[], query: string, type: SilkeCommandType, ): SilkeCommand[] { const typeCommands = commands.filter((cmd) => cmd.type === type); // If no query, return all commands of this type sorted alphabetically if (!query) { return [...typeCommands].sort((a, b) => a.value.localeCompare(b.value)); } const scored = typeCommands .map((cmd) => ({ cmd, score: getCommandMatchScore(cmd, query) })) .filter(({ score }) => score >= 0); // Sort by score descending, then alphabetically for ties scored.sort((a, b) => { if (b.score !== a.score) return b.score - a.score; return a.cmd.value.localeCompare(b.cmd.value); }); return scored.map(({ cmd }) => cmd); }