import React, { useCallback, useEffect, useImperativeHandle, useLayoutEffect, useMemo, useRef, useState, } from 'react'; import { useFormField } from '../../hooks/form'; import { useDropZone } from '../../hooks/use-drop-zone'; import { SilkeTextFieldOutline, SilkeTextFieldOutlineProps, } from '../silke-text-field/silke-text-field-outline'; import { AttachmentList, AttachmentListHandle } from './attachment-list'; import { SilkeFileCategory, fileToAttachmentValue, getFileFromClipboard, getFileFromDragEvent, resolveFileCategories, } from './file-utils'; import { parseTextToSilkeCommands } from './silke-command-parser'; import styles from './silke-command-text-field.module.scss'; import { KEY_DIRECTION, SilkeCommand, SilkeCommandTextFieldValue, SilkeFileValue, SilkeImageValue, convertSilkeCommandsToString, filterAndSortCommands, getActiveCommandIndex, getCaretOffset, getItemLength, getTextLength, hasSelectionLength, isAttachment, isSilkeCommand, isText, setCaretOffset, valueToHtml, } from './utils'; export type SilkeCommandTextFieldProps = SilkeTextFieldOutlineProps & { /** * File type categories to accept as attachments. * - `'image'` — all image types (png, jpeg, gif, etc.) * - `'text'` — documents and text files (PDF, Word, Excel, CSV, TXT, JSON, etc.) * Defaults to `[]` (no files accepted). */ acceptedFileTypes?: SilkeFileCategory[]; autoFocus?: boolean; availableCommands: SilkeCommand[]; disabled?: boolean; /** Maximum height in pixels before the field starts scrolling */ maxHeight?: number; name?: string; onChange: (value: SilkeCommandTextFieldValue[]) => void; onCommandFocus?: (index: number, command: SilkeCommandTextFieldValue | undefined) => void; onKeyDown?: (e: React.KeyboardEvent) => void; onSuggestions?: (suggestions: SilkeCommand[]) => void; placeholder?: string; value: SilkeCommandTextFieldValue[]; }; type ActiveCommandState = { index: number; localOffset: number; }; export type SilkeCommandTextFieldHandle = { /** Remove focus from the input */ blur: () => void; /** Focus the input */ focus: () => void; /** Open the file picker dialog (requires acceptedFileTypes to be set) */ openFilePicker: () => void; /** Get the current value as a string */ toString: (value?: SilkeCommandTextFieldValue[]) => string; /** * Update command in the commands array. * @param command - the command to update * @param index - defaults to active index, use -1 to append */ update: (command: SilkeCommand, index?: number) => void; }; const SilkeCommandTextField = React.forwardRef< SilkeCommandTextFieldHandle, SilkeCommandTextFieldProps >((props, ref) => { const { acceptedFileTypes, autoFocus = false, availableCommands, disabled = false, maxHeight, onChange, onCommandFocus, onKeyDown, onSuggestions, placeholder, value, children, ...rest } = useFormField(props); const inputRef = useRef(null); const attachmentListRef = useRef(null); const valueRef = useRef(value); valueRef.current = value; // Resolve file categories to MIME patterns const effectiveAcceptedTypes = useMemo( () => resolveFileCategories(acceptedFileTypes ?? []), [acceptedFileTypes], ); const filesDisabled = effectiveAcceptedTypes.length === 0; // Extract attachments (images + files) and text values const attachments = useMemo(() => value.filter(isAttachment), [value]); // Get non-attachment values for text field rendering const textValues = useMemo(() => value.filter((v) => !isAttachment(v)), [value]); const [activeCommand, setActiveCommand] = useState(() => { const lastIndex = Math.max(0, value.length - 1); const lastItem = value[lastIndex]; return { index: lastIndex, localOffset: lastItem ? getItemLength(lastItem) : 0, }; }); const html = useMemo(() => valueToHtml(textValues || [], styles), [textValues]); const isEmpty = !textValues || textValues.length === 0 || convertSilkeCommandsToString(textValues).trim().length === 0; // Restore caret position after value changes useLayoutEffect(() => { const element = inputRef.current; if (!element || document.activeElement !== element) return; // Don't collapse an active text selection (e.g. from double-click word selection) if (hasSelectionLength()) return; const calculatedCaretPos = getTextLength(value, activeCommand.index) + activeCommand.localOffset; setCaretOffset(element, calculatedCaretPos); }, [activeCommand.index, activeCommand.localOffset, value]); const updateValue = useCallback( (command: SilkeCommand, index: number, insert = false) => { const newValue = [...valueRef.current]; if (index === -1) { newValue.push(command); index = newValue.length - 1; } else if (insert) { // Insert at index without replacing newValue.splice(index, 0, command); } else { newValue[index] = command; } // Insert space after the command newValue.splice(index + 1, 0, { type: 'text', value: ' ' }); // Move caret after the space setActiveCommand({ index: index + 1, localOffset: 1 }); onChange(newValue); inputRef.current?.focus(); }, [onChange], ); const focusAtEnd = useCallback(() => { const element = inputRef.current; if (!element) return; element.focus(); const currentValue = valueRef.current; const lastIndex = Math.max(0, currentValue.length - 1); const lastItem = currentValue[lastIndex]; const offset = lastItem ? getItemLength(lastItem) : 0; const caretPos = getTextLength(currentValue, lastIndex) + offset; setCaretOffset(element, caretPos); setActiveCommand({ index: lastIndex, localOffset: offset }); }, []); useImperativeHandle( ref, () => ({ blur: () => inputRef.current?.blur(), focus: focusAtEnd, openFilePicker: () => attachmentListRef.current?.openFilePicker(), toString: (val = valueRef.current || []) => convertSilkeCommandsToString(val), update: (command, index = activeCommand.index) => { // If the current item is a completed command, insert after it instead of replacing const currentItem = valueRef.current[index]; if (isSilkeCommand(currentItem) && !currentItem.partialMatch) { updateValue(command, index + 1, true); } else { updateValue(command, index); } }, }), [activeCommand.index, focusAtEnd, updateValue], ); // Handle auto focus useEffect(() => { if (autoFocus && !disabled) { focusAtEnd(); } }, [autoFocus, disabled, focusAtEnd]); // Handle focus event to position caret at end const handleFocus = useCallback(() => { const element = inputRef.current; if (!element) return; // Use requestAnimationFrame to ensure DOM is ready requestAnimationFrame(() => { const currentValue = valueRef.current; const lastIndex = Math.max(0, currentValue.length - 1); const lastItem = currentValue[lastIndex]; const offset = lastItem ? getItemLength(lastItem) : 0; const caretPos = getTextLength(currentValue, lastIndex) + offset; setCaretOffset(element, caretPos); setActiveCommand({ index: lastIndex, localOffset: offset }); }); }, []); // Notify parent of command focus changes useEffect(() => { onCommandFocus?.(activeCommand.index, valueRef.current[activeCommand.index]); }, [activeCommand.index, onCommandFocus]); // Track selection changes to update active command useEffect(() => { const handleSelectionChange = () => { const element = inputRef.current; if (!element) return; // Only update if selection is within this element const { activeElement } = document; if (activeElement !== element && !element.contains(activeElement)) return; const caretPos = getCaretOffset(element); const currentValue = valueRef.current; const newActiveIndex = getActiveCommandIndex(caretPos, currentValue); let newLocalOffset = caretPos - getTextLength(currentValue, newActiveIndex); const item = currentValue[newActiveIndex]; // Snap caret to boundary if inside a non-partial command if (isSilkeCommand(item) && !item.partialMatch) { const itemLength = getItemLength(item); if (newLocalOffset > 0 && newLocalOffset < itemLength) { // Snap to nearest boundary newLocalOffset = newLocalOffset < itemLength / 2 ? 0 : itemLength; const newCaretPos = getTextLength(currentValue, newActiveIndex) + newLocalOffset; setCaretOffset(element, newCaretPos); } } setActiveCommand({ index: newActiveIndex, localOffset: newLocalOffset }); }; document.addEventListener('selectionchange', handleSelectionChange); return () => document.removeEventListener('selectionchange', handleSelectionChange); }, []); const updateActiveCommandFromCaret = useCallback( (newValue: SilkeCommandTextFieldValue[], caretPos?: number) => { const pos = caretPos ?? getCaretOffset(inputRef.current); const newActiveIndex = getActiveCommandIndex(pos, newValue); const newLocalOffset = pos - getTextLength(newValue, newActiveIndex); setActiveCommand({ index: newActiveIndex, localOffset: newLocalOffset }); }, [], ); const handleInput = useCallback( (e: React.FormEvent) => { // Preserve existing attachments when parsing new text const existingAttachments = valueRef.current.filter(isAttachment); const parsedValue = parseTextToSilkeCommands(e.currentTarget.innerText, availableCommands); const newValue = [...existingAttachments, ...parsedValue]; valueRef.current = newValue; updateActiveCommandFromCaret(newValue); onChange(newValue); }, [availableCommands, onChange, updateActiveCommandFromCaret], ); // Update suggestions when active command changes useEffect(() => { if (!onSuggestions) return; const currentCommand = valueRef.current[activeCommand.index]; if (isSilkeCommand(currentCommand) && currentCommand.partialMatch) { // Get commands already used in the value (completed, non-partial commands) const usedCommands = new Set( valueRef.current .filter((item): item is SilkeCommand => isSilkeCommand(item) && !item.partialMatch) .map((cmd) => `${cmd.type}:${cmd.value}`), ); const suggestions = filterAndSortCommands( availableCommands, currentCommand.value, currentCommand.type, ).filter((cmd) => !usedCommands.has(`${cmd.type}:${cmd.value}`)); onSuggestions(suggestions); } else { onSuggestions([]); } }, [activeCommand.index, activeCommand.localOffset, availableCommands, onSuggestions]); const handleArrowNavigation = useCallback( ( e: React.KeyboardEvent, direction: number, commandIndex: number, currentValue: SilkeCommandTextFieldValue[], ) => { e.preventDefault(); if (direction > 0) { // Moving right: skip to end of command or start of next item if (commandIndex >= currentValue.length - 1) { const item = currentValue[commandIndex]; setActiveCommand({ index: commandIndex, localOffset: getItemLength(item) }); } else { setActiveCommand({ index: commandIndex + 1, localOffset: 0 }); } } else { // Moving left: skip to start of command or end of previous item if (commandIndex <= 0) { setActiveCommand({ index: 0, localOffset: 0 }); } else { const prevItem = currentValue[commandIndex - 1]; setActiveCommand({ index: commandIndex - 1, localOffset: getItemLength(prevItem), }); } } }, [], ); const handleDelete = useCallback( ( e: React.KeyboardEvent, commandIndex: number, currentValue: SilkeCommandTextFieldValue[], isForward: boolean, ) => { e.preventDefault(); const newValue = [...currentValue]; newValue.splice(commandIndex, 1); if (isForward) { // Delete key: stay at current position const nextIndex = Math.min(commandIndex, newValue.length - 1); setActiveCommand({ index: Math.max(0, nextIndex), localOffset: 0 }); } else { // Backspace: move to end of previous item const prevIndex = Math.max(commandIndex - 1, 0); const prevItem = newValue[prevIndex]; setActiveCommand({ index: prevIndex, localOffset: prevItem ? getItemLength(prevItem) : 0 }); } onChange(newValue); }, [onChange], ); const handleCharacterInput = useCallback( ( e: React.KeyboardEvent, commandIndex: number, commandItem: SilkeCommandTextFieldValue, currentValue: SilkeCommandTextFieldValue[], ) => { const element = inputRef.current; if (!element) return; // Only handle single character input without modifiers if (e.key.length !== 1 || e.ctrlKey || e.metaKey || e.altKey) return; const currentCaretPos = getCaretOffset(element); const startOfCommand = getTextLength(currentValue, commandIndex); const itemLength = getItemLength(commandItem); const localOffset = currentCaretPos - startOfCommand; e.preventDefault(); const newValue = [...currentValue]; if (localOffset <= 0) { // At start of command - insert text before const prevItem = newValue[commandIndex - 1]; if (commandIndex > 0 && isText(prevItem)) { // Append to previous text segment newValue[commandIndex - 1] = { type: 'text', value: prevItem.value + e.key, }; setActiveCommand({ index: commandIndex - 1, localOffset: prevItem.value.length + 1, }); } else { // Insert new text segment before command newValue.splice(commandIndex, 0, { type: 'text', value: e.key }); setActiveCommand({ index: commandIndex, localOffset: 1 }); } onChange(newValue); } else if (localOffset >= itemLength) { // At end of command - insert text after const nextItem = newValue[commandIndex + 1]; if (commandIndex < newValue.length - 1 && isText(nextItem)) { // Prepend to next text segment newValue[commandIndex + 1] = { type: 'text', value: e.key + nextItem.value, }; setActiveCommand({ index: commandIndex + 1, localOffset: 1 }); } else { // Insert new text segment after command newValue.splice(commandIndex + 1, 0, { type: 'text', value: e.key }); setActiveCommand({ index: commandIndex + 1, localOffset: 1 }); } onChange(newValue); } // If inside command (not at boundary), just prevent - no insertion }, [onChange], ); const handleKeyDown = useCallback( (e: React.KeyboardEvent) => { onKeyDown?.(e); if (disabled) { e.preventDefault(); return; } // Cmd+Enter or Shift+Enter inserts a line break if (e.key === 'Enter' && (e.metaKey || e.shiftKey)) { e.preventDefault(); document.execCommand('insertLineBreak'); return; } const element = inputRef.current; if (!element) return; const direction = KEY_DIRECTION[e.key] || 0; const currentCaretPos = getCaretOffset(element); const caretPos = currentCaretPos + direction; const currentValue = valueRef.current; const commandIndex = getActiveCommandIndex(caretPos, currentValue); const commandItem = currentValue[commandIndex]; // Don't interfere if there's a text selection if (hasSelectionLength()) return; // Prevent deleting a space that separates two completed commands if (e.key === 'Backspace' || e.key === 'Delete') { const isBackspace = e.key === 'Backspace'; // For backspace, check char before caret; for delete, check char at caret const deleteTargetPos = isBackspace ? currentCaretPos - 1 : currentCaretPos; const targetIndex = getActiveCommandIndex(deleteTargetPos, currentValue); const targetItem = currentValue[targetIndex]; // Check if we're about to delete a space-only segment between two commands if (isText(targetItem) && targetItem.value.trim() === '' && targetItem.value.length === 1) { const prevItem = currentValue[targetIndex - 1]; const nextItem = currentValue[targetIndex + 1]; const hasPrevCommand = isSilkeCommand(prevItem) && !prevItem.partialMatch; const hasNextCommand = isSilkeCommand(nextItem) && !nextItem.partialMatch; if (hasPrevCommand && hasNextCommand) { // Delete the space and the following command instead e.preventDefault(); const newValue = [...currentValue]; newValue.splice(targetIndex, 2); // Remove space and next command const prevItemLen = getItemLength(prevItem); setActiveCommand({ index: targetIndex - 1, localOffset: prevItemLen }); onChange(newValue); return; } } } // Only handle special keys for complete (non-partial) commands if (!isSilkeCommand(commandItem) || commandItem.partialMatch) return; // Calculate if target position is strictly inside the command (not at boundaries) const startOfCommand = getTextLength(currentValue, commandIndex); const itemLength = getItemLength(commandItem); const localOffset = caretPos - startOfCommand; const isStrictlyInside = localOffset > 0 && localOffset < itemLength; switch (e.key) { case 'ArrowLeft': case 'ArrowRight': // Only skip command if we would land strictly inside it if (isStrictlyInside) { handleArrowNavigation(e, direction, commandIndex, currentValue); } // If at boundary, allow normal navigation break; case 'Backspace': case 'Delete': // Delete entire command if target is inside or we're at appropriate boundary if (isStrictlyInside) { handleDelete(e, commandIndex, currentValue, e.key === 'Delete'); } // If at boundary (localOffset = 0 or itemLength), let browser handle break; default: handleCharacterInput(e, commandIndex, commandItem, currentValue); } }, [disabled, handleArrowNavigation, handleCharacterInput, handleDelete, onKeyDown], ); const addAttachment = useCallback( (attachmentValue: SilkeImageValue | SilkeFileValue) => { const newValue = [...valueRef.current, attachmentValue]; onChange(newValue); inputRef.current?.focus(); }, [onChange], ); const removeAttachment = useCallback( (index: number) => { // Find the nth attachment in the value array and remove it let attachmentCount = 0; const newValue = valueRef.current.filter((item) => { if (isAttachment(item)) { if (attachmentCount === index) { attachmentCount++; return false; } attachmentCount++; } return true; }); onChange(newValue); }, [onChange], ); const addMultipleAttachments = useCallback( (newAttachments: (SilkeImageValue | SilkeFileValue)[]) => { onChange([...valueRef.current, ...newAttachments]); inputRef.current?.focus(); }, [onChange], ); const handlePaste = useCallback( (e: React.ClipboardEvent) => { if (filesDisabled) return; const file = getFileFromClipboard(e, effectiveAcceptedTypes); if (!file) return; e.preventDefault(); fileToAttachmentValue(file, effectiveAcceptedTypes).then((attachmentValue) => { if (attachmentValue) addAttachment(attachmentValue); }); }, [addAttachment, effectiveAcceptedTypes, filesDisabled], ); const containerRef = useRef(null); const handleDrop = useCallback( (e: DragEvent) => { if (filesDisabled) return; const file = getFileFromDragEvent(e, effectiveAcceptedTypes); if (!file) return; fileToAttachmentValue(file, effectiveAcceptedTypes).then((attachmentValue) => { if (attachmentValue) addAttachment(attachmentValue); }); }, [addAttachment, effectiveAcceptedTypes, filesDisabled], ); useDropZone(containerRef, handleDrop); return (
{!filesDisabled && ( )}
{placeholder && isEmpty &&
{placeholder}
} {children}
); }); SilkeCommandTextField.displayName = 'SilkeCommandTextField'; export { SilkeCommandTextField };