import { useCallback, useLayoutEffect, 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, selectedInlineText, } from './inlineWikilinkEdits' import { buildInlineWikilinkSegments, extractInlineWikilinkReferences, findActiveWikilinkQuery, } from './inlineWikilinkText' import { extractDroppedPathText, formatDroppedPathList } from './inlineWikilinkDropText' import { readSelectionRange, serializeInlineNode, type InlineSelectionRange, } 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' import { isInsertBeforeInput, isPlainTextBeforeInput, } from './inlineWikilinkBeforeInput' import { useNativePathDrop } from './useNativePathDrop' 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, } } function fullSelectionRange(value: string) { return { start: 0, end: value.length, } } function isSelectAllShortcut(event: React.KeyboardEvent) { return event.key.toLowerCase() === 'a' && (event.metaKey || event.ctrlKey) } function isLineBreakShortcut( event: React.KeyboardEvent, isComposing: boolean, ) { return event.key === 'Enter' && event.shiftKey && !isComposing && !event.nativeEvent.isComposing && event.keyCode !== 229 } 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 isComposingRef = useRef(false) 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, isComposingRef, }) const pendingPasteRef = useRef(null) const pendingCompositionInputRef = useRef(false) const handledFileDropRef = useRef(false) const pendingFocusAfterRemountRef = useRef(null) useLayoutEffect(() => { const target = pendingFocusAfterRemountRef.current if (!target) return pendingFocusAfterRemountRef.current = null focusSelectionRange(target) }, [focusSelectionRange, renderVersion]) 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 insertTransferText = useCallback((text: string, focusAfterInsert = false) => { const editor = editorRef.current const currentSelectionRange = editor && !focusAfterInsert ? readSelectionRange(editor) : selectionRange const nextState = replaceInlineSelection(value, currentSelectionRange, text) const shouldRestoreFocus = focusAfterInsert || document.activeElement === editor onChange(nextState.value) setSelectionRange(nextState.selection) pendingFocusAfterRemountRef.current = shouldRestoreFocus ? nextState.selection : null forceRender((current) => current + 1) }, [editorRef, onChange, selectionRange, setSelectionRange, value]) const insertNativePathDrop = (paths: string[]) => { const droppedPathText = formatDroppedPathList(paths) if (!droppedPathText) return insertTransferText(droppedPathText, true) } useNativePathDrop({ targetRef: editorRef, disabled, onPathDrop: insertNativePathDrop, }) const notifyUnsupportedPaste = useCallback( () => onUnsupportedPaste?.(UNSUPPORTED_INLINE_PASTE_MESSAGE), [onUnsupportedPaste], ) const recoverUnsupportedMutation = () => { pendingCompositionInputRef.current = false 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 selectAllContent = () => { const nextSelection = fullSelectionRange(value) setSelectionRange(nextSelection) focusSelectionRange(nextSelection) } const cutSelectedContent = (event: React.ClipboardEvent) => { if (disabled) return const editor = editorRef.current const currentSelectionRange = editor ? readSelectionRange(editor) : selectionRange const selectedText = selectedInlineText(value, currentSelectionRange) if (!selectedText) return event.preventDefault() event.clipboardData.setData('text/plain', normalizeInlineWikilinkValue(selectedText)) const nextState = deleteInlineSelection(value, currentSelectionRange, segments, 'backward') if (!nextState) return onChange(nextState.value) setSelectionRange(nextState.selection) pendingFocusAfterRemountRef.current = nextState.selection forceRender((current) => current + 1) } const handleBeforeInput = useCallback((nativeEvent: InputEvent) => { if (disabled) return if (!isInsertBeforeInput(nativeEvent)) return if (nativeEvent.inputType === 'insertLineBreak') { nativeEvent.preventDefault() insertTransferText('\n') return } if (isPlainTextBeforeInput(nativeEvent)) { nativeEvent.preventDefault() insertTransferText(nativeEvent.data) return } const dataTransfer = nativeEvent.dataTransfer if (!dataTransfer || !hasUnsupportedClipboardPayload(dataTransfer)) return if (nativeEvent.inputType === 'insertFromDrop' && handledFileDropRef.current) { handledFileDropRef.current = false nativeEvent.preventDefault() return } if (nativeEvent.inputType === 'insertFromDrop') { const droppedPathText = extractDroppedPathText(dataTransfer) if (droppedPathText) { nativeEvent.preventDefault() insertTransferText(droppedPathText) return } } nativeEvent.preventDefault() notifyUnsupportedPaste() }, [disabled, insertTransferText, notifyUnsupportedPaste]) useLayoutEffect(() => { const editor = editorRef.current if (!editor) return editor.addEventListener('beforeinput', handleBeforeInput as EventListener) return () => editor.removeEventListener('beforeinput', handleBeforeInput as EventListener) }, [editorRef, handleBeforeInput, renderVersion]) const handleDrop = (event: React.DragEvent) => { if (disabled) return if (!hasUnsupportedClipboardPayload(event.dataTransfer)) return handledFileDropRef.current = true const droppedPathText = extractDroppedPathText(event.dataTransfer) event.preventDefault() if (!droppedPathText) { notifyUnsupportedPaste() return } insertTransferText(droppedPathText) } 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 syncValueFromEditor = () => { 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 flushPendingCompositionInput = () => { if (isComposingRef.current || !pendingCompositionInputRef.current) return pendingCompositionInputRef.current = false const editor = editorRef.current if (!editor) return if (containsUnsupportedInlineContent(editor)) { recoverUnsupportedMutation() return } const nextValue = normalizeInlineWikilinkValue(serializeInlineNode(editor)) const nextSelection = readSelectionRange(editor) const clampedSelection: InlineSelectionRange = { start: Math.min(nextSelection.start, nextValue.length), end: Math.min(nextSelection.end, nextValue.length), } const shouldRestoreFocus = document.activeElement === editor pendingFocusAfterRemountRef.current = shouldRestoreFocus ? clampedSelection : null onChange(nextValue) setSelectionRange(clampedSelection) forceRender((current) => current + 1) } const handleCompositionStart = () => { isComposingRef.current = true } const handleCompositionEnd = () => { isComposingRef.current = false queueMicrotask(flushPendingCompositionInput) } const handleInput = () => { if (isComposingRef.current) { pendingCompositionInputRef.current = true return } pendingCompositionInputRef.current = false syncValueFromEditor() } const submitValue = () => submitInlineValue({ onSubmit, submitOnEmpty, value, references }) const handleKeyDown = (event: React.KeyboardEvent) => { if (!disabled && isLineBreakShortcut(event, isComposingRef.current)) { event.preventDefault() insertTransferText('\n') return } if (isSelectAllShortcut(event)) { event.preventDefault() selectAllContent() return } handleInlineWikilinkKeyDown({ event, disabled, isComposing: isComposingRef.current, suggestionsOpen: suggestions.length > 0, onCycleSuggestions: cycleSuggestions, onSelectSuggestion: () => selectSuggestion(selectedSuggestionIndex), onDeleteContent: deleteContent, canSubmit: onSubmit !== undefined, onSubmit: submitValue, }) } const editor = ( ) const suggestionList = renderInlineSuggestionList({ suggestions, selectedSuggestionIndex, setSuggestionIndex, selectSuggestion, typeEntryMap, suggestionListVariant, suggestionEmptyLabel, }) if (suggestionListVariant === 'palette') { return ( ) } return
{editor}{suggestionList}
}