import { useMemo, useRef, useState, type ReactNode, } from 'react' import type { VaultEntry } from '../types' import type { NoteReference } from '../utils/ai-context' import { buildTypeEntryMap } from '../utils/typeColors' import { deleteInlineSelection, replaceInlineSelection, } from './inlineWikilinkEdits' import { buildInlineWikilinkSegments, extractInlineWikilinkReferences, findActiveWikilinkQuery, } from './inlineWikilinkText' import { serializeInlineNode } from './inlineWikilinkDom' import { buildPendingPasteState, type PendingPasteState, shouldRecoverPendingPaste, } from './inlineWikilinkPasteRecovery' import { InlineWikilinkEditorField, InlineWikilinkPaletteLayout, InlineWikilinkSuggestionList, } from './InlineWikilinkParts' import { handleInlineWikilinkKeyDown } from './inlineWikilinkKeydown' import { useInlineWikilinkSelection } from './useInlineWikilinkSelection' import { useInlineWikilinkSuggestionsState } from './useInlineWikilinkSuggestionsState' import { normalizeInlineWikilinkValue } from './inlineWikilinkTokens' interface InlineWikilinkInputProps { entries: VaultEntry[] value: string onChange: (value: string) => void onSubmit?: (text: string, references: NoteReference[]) => void onUnsupportedPaste?: (message: string) => void submitOnEmpty?: boolean disabled?: boolean placeholder?: string inputRef?: React.RefObject dataTestId?: string editorClassName?: string suggestionListVariant?: 'floating' | 'palette' suggestionEmptyLabel?: string paletteHeader?: ReactNode paletteEmptyState?: ReactNode paletteFooter?: ReactNode } function collapseSelectionRange(nextSelectionIndex: number) { return { start: nextSelectionIndex, end: nextSelectionIndex, } } export const UNSUPPORTED_INLINE_PASTE_MESSAGE = 'Only text paste is supported in the AI composer right now.' function hasUnsupportedClipboardPayload(clipboardData: DataTransfer) { if (clipboardData.files.length > 0) return true return Array.from(clipboardData.items).some((item) => item.kind === 'file' || item.type.startsWith('image/'), ) } function containsUnsupportedInlineContent(editor: HTMLDivElement) { return editor.querySelector('img, picture, video, audio, canvas, figure, iframe, object') !== null } function submitInlineValue({ onSubmit, submitOnEmpty, value, references, }: { onSubmit?: (text: string, references: NoteReference[]) => void submitOnEmpty: boolean value: string references: NoteReference[] }) { if (!onSubmit) return const normalizedValue = normalizeInlineWikilinkValue(value) if (!submitOnEmpty && !normalizedValue.trim()) return onSubmit(normalizedValue, references) } function renderInlineSuggestionList({ suggestions, selectedSuggestionIndex, setSuggestionIndex, selectSuggestion, typeEntryMap, suggestionListVariant, suggestionEmptyLabel, }: { suggestions: ReturnType['suggestions'] selectedSuggestionIndex: number setSuggestionIndex: (index: number) => void selectSuggestion: (index: number) => void typeEntryMap: Record suggestionListVariant: 'floating' | 'palette' suggestionEmptyLabel: string }) { if (suggestions.length === 0) return null return ( ) } export function InlineWikilinkInput({ entries, value, onChange, onSubmit, onUnsupportedPaste, submitOnEmpty = false, disabled = false, placeholder, inputRef, dataTestId = 'agent-input', editorClassName, suggestionListVariant = 'floating', suggestionEmptyLabel = 'No matching notes', paletteHeader, paletteEmptyState, paletteFooter, }: InlineWikilinkInputProps) { const [renderVersion, forceRender] = useState(0) const segments = useMemo( () => buildInlineWikilinkSegments(value, entries), [entries, value], ) const typeEntryMap = useMemo(() => buildTypeEntryMap(entries), [entries]) const { editorRef, selectionRange, selectionIndex, setSelectionRange, setCombinedRef, syncSelectionRange, commitValueFromEditor, focusSelectionRange, } = useInlineWikilinkSelection({ value, onChange, inputRef, }) const pendingPasteRef = useRef(null) const activeQuery = useMemo( () => selectionRange.start === selectionRange.end ? findActiveWikilinkQuery(value, selectionIndex) : null, [selectionIndex, selectionRange.end, selectionRange.start, value], ) const references = useMemo(() => extractInlineWikilinkReferences(value, entries), [entries, value]) const { suggestions, selectedSuggestionIndex, setSuggestionIndex, selectSuggestion, cycleSuggestions, } = useInlineWikilinkSuggestionsState({ activeQueryKey: activeQuery ? `${activeQuery.start}:${activeQuery.query}` : '', entries, query: activeQuery?.query ?? null, value, selectionIndex, onChange, onSelectionIndexChange: (nextSelectionIndex) => setSelectionRange(collapseSelectionRange(nextSelectionIndex)), focusSelectionAt: (nextSelectionIndex) => focusSelectionRange(collapseSelectionRange(nextSelectionIndex)), }) const insertText = (text: string) => { const nextState = replaceInlineSelection(value, selectionRange, text) onChange(nextState.value) setSelectionRange(nextState.selection) } const notifyUnsupportedPaste = () => onUnsupportedPaste?.(UNSUPPORTED_INLINE_PASTE_MESSAGE) const recoverUnsupportedMutation = () => { pendingPasteRef.current = null notifyUnsupportedPaste() forceRender((current) => current + 1) setSelectionRange({ ...selectionRange }) } const deleteContent = (direction: 'backward' | 'forward') => { const nextState = deleteInlineSelection(value, selectionRange, segments, direction) if (!nextState) return onChange(nextState.value) setSelectionRange(nextState.selection) } const handleBeforeInput = (event: React.FormEvent) => { if (disabled) return const nativeEvent = event.nativeEvent as InputEvent if (!nativeEvent.inputType.startsWith('insert')) return const dataTransfer = nativeEvent.dataTransfer if (!dataTransfer || !hasUnsupportedClipboardPayload(dataTransfer)) return event.preventDefault() notifyUnsupportedPaste() } const handlePaste = (event: React.ClipboardEvent) => { if (disabled) return if (hasUnsupportedClipboardPayload(event.clipboardData)) { event.preventDefault() notifyUnsupportedPaste() return } const pastedText = normalizeInlineWikilinkValue(event.clipboardData.getData('text/plain')) if (!pastedText) return const nextState = replaceInlineSelection(value, selectionRange, pastedText) pendingPasteRef.current = buildPendingPasteState(value, selectionRange, pastedText) event.preventDefault() onChange(nextState.value) setSelectionRange(nextState.selection) } const handleInput = () => { const editor = editorRef.current if (editor && containsUnsupportedInlineContent(editor)) { recoverUnsupportedMutation() return } const pendingPaste = pendingPasteRef.current if (editor && pendingPaste) { const nextValue = normalizeInlineWikilinkValue(serializeInlineNode(editor)) pendingPasteRef.current = null if (shouldRecoverPendingPaste(nextValue, pendingPaste)) { onChange(pendingPaste.expectedValue) forceRender((current) => current + 1) setSelectionRange({ ...pendingPaste.expectedSelection }) return } } commitValueFromEditor() } const submitValue = () => submitInlineValue({ onSubmit, submitOnEmpty, value, references }) const handleKeyDown = (event: React.KeyboardEvent) => handleInlineWikilinkKeyDown({ event, disabled, suggestionsOpen: suggestions.length > 0, onCycleSuggestions: cycleSuggestions, onSelectSuggestion: () => selectSuggestion(selectedSuggestionIndex), onDeleteContent: deleteContent, onInsertText: insertText, canSubmit: onSubmit !== undefined, onSubmit: submitValue, }) const editor = ( ) const suggestionList = renderInlineSuggestionList({ suggestions, selectedSuggestionIndex, setSuggestionIndex, selectSuggestion, typeEntryMap, suggestionListVariant, suggestionEmptyLabel, }) if (suggestionListVariant === 'palette') { return ( ) } return
{editor}{suggestionList}
}