import { useRef, useState, useCallback, useEffect, useMemo } from 'react' import { trackEvent } from '../lib/telemetry' import type { EditorView } from '@codemirror/view' import { MIN_QUERY_LENGTH } from '../utils/wikilinkSuggestions' import { buildTypeEntryMap } from '../utils/typeColors' import { NoteSearchList } from './NoteSearchList' import { buildRawEditorAutocompleteState, buildRawEditorBaseItems, detectYamlError, extractWikilinkQuery, getRawEditorDropdownPosition, replaceActiveWikilinkQuery, type RawEditorAutocompleteState, } from '../utils/rawEditorUtils' import { useCodeMirror } from '../hooks/useCodeMirror' import type { VaultEntry } from '../types' import { translate, type AppLocale } from '../lib/i18n' import { RawEditorFindBar, type RawEditorFindRequest } from './RawEditorFindBar' import { activatePlainTextPasteTarget, registerPlainTextPasteTarget, type PlainTextPasteTarget, } from '../utils/plainTextPaste' export interface RawEditorViewProps { content: string path: string entries: VaultEntry[] sourceEntry?: VaultEntry onContentChange: (path: string, content: string) => void vaultPath?: string onSave: () => void /** Mutable ref updated on every keystroke with the latest doc string. * Allows the parent to flush debounced content before unmount. */ latestContentRef?: React.MutableRefObject locale?: AppLocale findRequest?: RawEditorFindRequest | null } const DEBOUNCE_MS = 500 const DROPDOWN_MAX_HEIGHT = 200 type PendingChangeRefs = { debounceRef: React.MutableRefObject | null> latestDocRef: React.MutableRefObject onContentChangeRef: React.MutableRefObject pathRef: React.MutableRefObject } function useLatestRef(value: T): React.MutableRefObject { const ref = useRef(value) useEffect(() => { ref.current = value }, [value]) return ref } function flushPendingRawEditorChange({ debounceRef, latestDocRef, onContentChangeRef, pathRef, }: PendingChangeRefs): void { if (!debounceRef.current) return clearTimeout(debounceRef.current) debounceRef.current = null onContentChangeRef.current(pathRef.current, latestDocRef.current) } function moveRawEditorAutocompleteSelection( autocomplete: RawEditorAutocompleteState, direction: 'next' | 'previous', ): RawEditorAutocompleteState { const selectedIndex = direction === 'next' ? Math.min(autocomplete.selectedIndex + 1, autocomplete.items.length - 1) : Math.max(autocomplete.selectedIndex - 1, 0) return { ...autocomplete, selectedIndex } } function RawEditorYamlErrorBanner({ error }: { error: string | null }) { if (!error) return null return (
YAML error: {error}
) } function RawEditorAutocompleteDropdown({ autocomplete, onItemHover, position, }: { autocomplete: RawEditorAutocompleteState | null onItemHover: (index: number) => void position: { top: number; left: number } }) { if (!autocomplete || autocomplete.items.length === 0) return null return (
`${item.title}-${item.path ?? i}`} onItemClick={(item) => item.onItemClick()} onItemHover={onItemHover} />
) } type RawEditorPendingChanges = PendingChangeRefs & { handleDocChange: (doc: string) => void handleSave: () => void yamlError: string | null } function useRawEditorPendingChanges({ content, latestContentRef, onContentChange, onSave, path, }: Pick): RawEditorPendingChanges { const debounceRef = useRef | null>(null) const pathRef = useLatestRef(path) const onContentChangeRef = useLatestRef(onContentChange) const onSaveRef = useLatestRef(onSave) const latestContentRefStable = useRef(latestContentRef) const latestDocRef = useRef(content) const [yamlError, setYamlError] = useState(() => detectYamlError(content)) useEffect(() => { if (latestContentRef) latestContentRef.current = content }, [latestContentRef, content]) useEffect(() => { latestContentRefStable.current = latestContentRef }, [latestContentRef]) const handleDocChange = useCallback((doc: string) => { latestDocRef.current = doc if (latestContentRefStable.current) latestContentRefStable.current.current = doc setYamlError(detectYamlError(doc)) if (debounceRef.current) clearTimeout(debounceRef.current) debounceRef.current = setTimeout(() => { onContentChangeRef.current(pathRef.current, doc) }, DEBOUNCE_MS) }, [latestContentRefStable, onContentChangeRef, pathRef]) const handleSave = useCallback(() => { flushPendingRawEditorChange({ debounceRef, latestDocRef, onContentChangeRef, pathRef }) onSaveRef.current() }, [onContentChangeRef, onSaveRef, pathRef]) useEffect(() => { return () => { flushPendingRawEditorChange({ debounceRef, latestDocRef, onContentChangeRef, pathRef }) } }, [onContentChangeRef, pathRef]) return { debounceRef, handleDocChange, handleSave, latestDocRef, onContentChangeRef, pathRef, yamlError, } } type RawEditorAutocompleteDirection = 'next' | 'previous' type RawEditorSetAutocomplete = React.Dispatch> type RawEditorTypeEntryMap = ReturnType function getRawEditorAutocompleteDirection(key: string): RawEditorAutocompleteDirection | null { if (key === 'ArrowDown') return 'next' if (key === 'ArrowUp') return 'previous' return null } function buildNextRawEditorAutocomplete({ baseItems, insertWikilinkRef, sourceEntry, typeEntryMap, vaultPath, view, }: { baseItems: ReturnType insertWikilinkRef: React.MutableRefObject<(target: string) => void> sourceEntry?: VaultEntry typeEntryMap: RawEditorTypeEntryMap vaultPath?: string view: EditorView }): RawEditorAutocompleteState | null { const doc = view.state.doc.toString() const cursor = view.state.selection.main.head const query = extractWikilinkQuery(doc, cursor) if (query === null || query.length < MIN_QUERY_LENGTH) return null return buildRawEditorAutocompleteState({ view, baseItems, query, typeEntryMap, onInsertTarget: (target: string) => insertWikilinkRef.current(target), sourceEntry, vaultPath: vaultPath ?? '', }) } function useRawEditorAutocompleteEscape( autocomplete: RawEditorAutocompleteState | null, setAutocomplete: RawEditorSetAutocomplete, ) { return useCallback(() => { if (autocomplete) { setAutocomplete(null); return true } return false }, [autocomplete, setAutocomplete]) } function useRawEditorAutocompleteKeyboard( autocomplete: RawEditorAutocompleteState | null, setAutocomplete: RawEditorSetAutocomplete, ) { return useCallback((e: React.KeyboardEvent) => { if (!autocomplete) return if (e.key === 'Enter') { e.preventDefault() autocomplete.items[autocomplete.selectedIndex]?.onItemClick() return } const direction = getRawEditorAutocompleteDirection(e.key) if (!direction) return e.preventDefault() setAutocomplete(prev => prev ? moveRawEditorAutocompleteSelection(prev, direction) : null) }, [autocomplete, setAutocomplete]) } function useRawEditorAutocompleteController({ entries, sourceEntry, vaultPath, }: Pick) { const [autocomplete, setAutocomplete] = useState(null) const typeEntryMap = useMemo(() => buildTypeEntryMap(entries), [entries]) const baseItems = useMemo(() => buildRawEditorBaseItems(entries), [entries]) const insertWikilinkRef = useRef<(target: string) => void>(() => {}) const handleCursorActivity = useCallback((view: EditorView) => { setAutocomplete(buildNextRawEditorAutocomplete({ baseItems, insertWikilinkRef, sourceEntry, typeEntryMap, vaultPath, view, })) }, [baseItems, sourceEntry, typeEntryMap, vaultPath]) const handleItemHover = useCallback((index: number) => { setAutocomplete(prev => prev ? { ...prev, selectedIndex: index } : null) }, []) const handleEscape = useRawEditorAutocompleteEscape(autocomplete, setAutocomplete) const handleAutocompleteKey = useRawEditorAutocompleteKeyboard(autocomplete, setAutocomplete) return { autocomplete, handleAutocompleteKey, handleCursorActivity, handleEscape, handleItemHover, insertWikilinkRef, setAutocomplete, } } function useRawEditorWikilinkInsertion({ debounceRef, insertWikilinkRef, latestDocRef, onContentChangeRef, pathRef, setAutocomplete, viewRef, }: PendingChangeRefs & { insertWikilinkRef: React.MutableRefObject<(target: string) => void> setAutocomplete: RawEditorSetAutocomplete viewRef: React.MutableRefObject }) { const applyWikilinkChange = useCallback((view: EditorView, next: { text: string; cursor: number }) => { const doc = view.state.doc.toString() view.dispatch({ changes: { from: 0, to: doc.length, insert: next.text }, selection: { anchor: next.cursor }, }) trackEvent('wikilink_inserted') setAutocomplete(null) if (debounceRef.current) clearTimeout(debounceRef.current) debounceRef.current = null latestDocRef.current = next.text onContentChangeRef.current(pathRef.current, next.text) view.focus() }, [debounceRef, latestDocRef, onContentChangeRef, pathRef, setAutocomplete]) const insertAutocompleteWikilink = useCallback((target: string) => { const view = viewRef.current if (!view) return const cursor = view.state.selection.main.head const doc = view.state.doc.toString() const replacement = replaceActiveWikilinkQuery(doc, cursor, target) if (!replacement) return applyWikilinkChange(view, replacement) }, [applyWikilinkChange, viewRef]) useEffect(() => { insertWikilinkRef.current = insertAutocompleteWikilink }, [insertAutocompleteWikilink, insertWikilinkRef]) } function useRawEditorPlainTextPasteTarget({ containerRef, setAutocomplete, viewRef, }: { containerRef: React.RefObject setAutocomplete: RawEditorSetAutocomplete viewRef: React.MutableRefObject }) { const targetRef = useRef(null) useEffect(() => { const target: PlainTextPasteTarget = { surface: 'raw_editor', contains: (element) => Boolean(element && containerRef.current?.contains(element)), isConnected: () => containerRef.current?.isConnected === true, insert: (text) => { const view = viewRef.current if (!view) return false view.dispatch({ ...view.state.replaceSelection(text), userEvent: 'input.paste', }) setAutocomplete(null) view.focus() return true }, } targetRef.current = target const unregister = registerPlainTextPasteTarget(target) return () => { unregister() if (targetRef.current === target) { targetRef.current = null } } }, [containerRef, setAutocomplete, viewRef]) return useCallback(() => { if (targetRef.current) { activatePlainTextPasteTarget(targetRef.current) } }, []) } export function RawEditorView({ content, path, entries, sourceEntry, onContentChange, onSave, latestContentRef, vaultPath, locale = 'en', findRequest }: RawEditorViewProps) { const containerRef = useRef(null) const [rawDoc, setRawDoc] = useState(content) const [findOpen, setFindOpen] = useState(false) const [replaceOpen, setReplaceOpen] = useState(false) const pendingChanges = useRawEditorPendingChanges({ content, latestContentRef, onContentChange, onSave, path }) const { autocomplete, handleAutocompleteKey, handleCursorActivity, handleEscape: handleAutocompleteEscape, handleItemHover, insertWikilinkRef, setAutocomplete, } = useRawEditorAutocompleteController({ entries, sourceEntry, vaultPath }) const handleDocChange = useCallback((doc: string) => { setRawDoc(doc) pendingChanges.handleDocChange(doc) }, [pendingChanges]) const handleEscape = useCallback(() => { if (handleAutocompleteEscape()) return true if (!findOpen) return false setFindOpen(false) return true }, [findOpen, handleAutocompleteEscape]) const viewRef = useCodeMirror(containerRef, content, { onDocChange: handleDocChange, onCursorActivity: handleCursorActivity, onSave: pendingChanges.handleSave, onEscape: handleEscape, }) const activatePlainTextPaste = useRawEditorPlainTextPasteTarget({ containerRef, setAutocomplete, viewRef, }) useRawEditorWikilinkInsertion({ debounceRef: pendingChanges.debounceRef, insertWikilinkRef, latestDocRef: pendingChanges.latestDocRef, onContentChangeRef: pendingChanges.onContentChangeRef, pathRef: pendingChanges.pathRef, setAutocomplete, viewRef, }) useEffect(() => { setRawDoc(content) }, [content]) useEffect(() => { if (!findRequest || findRequest.path !== path) return setAutocomplete(null) setFindOpen(true) setReplaceOpen(findRequest.replace) }, [findRequest, path, setAutocomplete]) const dropdownPosition = getRawEditorDropdownPosition(autocomplete, DROPDOWN_MAX_HEIGHT, window) return (
setFindOpen(false)} onReplaceOpenChange={setReplaceOpen} open={findOpen} path={path} replaceOpen={replaceOpen} request={findRequest} viewRef={viewRef} />
) }