Files
tolaria/src/components/InlineWikilinkInput.tsx
2026-04-28 19:32:08 +02:00

509 lines
16 KiB
TypeScript

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<HTMLDivElement | null>
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<HTMLDivElement>) {
return event.key.toLowerCase() === 'a' && (event.metaKey || event.ctrlKey)
}
function isLineBreakShortcut(
event: React.KeyboardEvent<HTMLDivElement>,
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<typeof useInlineWikilinkSuggestionsState>['suggestions']
selectedSuggestionIndex: number
setSuggestionIndex: (index: number) => void
selectSuggestion: (index: number) => void
typeEntryMap: Record<string, VaultEntry>
suggestionListVariant: 'floating' | 'palette'
suggestionEmptyLabel: string
}) {
if (suggestions.length === 0) return null
return (
<InlineWikilinkSuggestionList
suggestions={suggestions}
selectedIndex={selectedSuggestionIndex}
onHover={setSuggestionIndex}
onSelect={selectSuggestion}
typeEntryMap={typeEntryMap}
variant={suggestionListVariant}
emptyLabel={suggestionEmptyLabel}
/>
)
}
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<PendingPasteState | null>(null)
const pendingCompositionInputRef = useRef(false)
const handledFileDropRef = useRef(false)
const pendingFocusAfterRemountRef = useRef<InlineSelectionRange | null>(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<HTMLDivElement>) => {
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<HTMLDivElement>) => {
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<HTMLDivElement>) => {
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<HTMLDivElement>) => {
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 = (
<InlineWikilinkEditorField
key={renderVersion}
value={value}
placeholder={placeholder}
disabled={disabled}
inputRef={setCombinedRef}
dataTestId={dataTestId}
editorClassName={editorClassName}
onCompositionEnd={handleCompositionEnd}
onCompositionStart={handleCompositionStart}
onInput={handleInput}
onKeyDown={handleKeyDown}
onCut={cutSelectedContent}
onDrop={handleDrop}
onPaste={handlePaste}
onSelectionChange={syncSelectionRange}
segments={segments}
typeEntryMap={typeEntryMap}
/>
)
const suggestionList = renderInlineSuggestionList({
suggestions,
selectedSuggestionIndex,
setSuggestionIndex,
selectSuggestion,
typeEntryMap,
suggestionListVariant,
suggestionEmptyLabel,
})
if (suggestionListVariant === 'palette') {
return (
<InlineWikilinkPaletteLayout
header={paletteHeader}
editor={editor}
suggestionList={suggestionList}
emptyState={paletteEmptyState}
footer={paletteFooter}
/>
)
}
return <div className="relative">{editor}{suggestionList}</div>
}