Remove all theme switching, creation, and management functionality from the app — backend (Rust theme module, Tauri commands, menu items, startup tasks, vault seeding), frontend (useThemeManager, ThemePropertyEditor, themeSchema, command palette Appearance group, settings panel appearance section, isDarkTheme prop chain), mock data, and all related tests. Editor styling via theme.json/EditorTheme.css is preserved (not user theming). Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
232 lines
8.6 KiB
TypeScript
232 lines
8.6 KiB
TypeScript
import { useRef, useState, useCallback, useEffect, useMemo } from 'react'
|
|
import type { EditorView } from '@codemirror/view'
|
|
import { preFilterWikilinks, deduplicateByPath, MIN_QUERY_LENGTH } from '../utils/wikilinkSuggestions'
|
|
import { attachClickHandlers, enrichSuggestionItems } from '../utils/suggestionEnrichment'
|
|
import { buildTypeEntryMap } from '../utils/typeColors'
|
|
import { NoteSearchList } from './NoteSearchList'
|
|
import { extractWikilinkQuery, detectYamlError } from '../utils/rawEditorUtils'
|
|
import { useCodeMirror } from '../hooks/useCodeMirror'
|
|
import type { WikilinkSuggestionItem } from './WikilinkSuggestionMenu'
|
|
import type { VaultEntry } from '../types'
|
|
|
|
interface AutocompleteState {
|
|
caretTop: number
|
|
caretLeft: number
|
|
selectedIndex: number
|
|
items: WikilinkSuggestionItem[]
|
|
}
|
|
|
|
export interface RawEditorViewProps {
|
|
content: string
|
|
path: string
|
|
entries: VaultEntry[]
|
|
onContentChange: (path: string, content: string) => void
|
|
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<string | null>
|
|
}
|
|
|
|
const DEBOUNCE_MS = 500
|
|
const DROPDOWN_MAX_HEIGHT = 200
|
|
|
|
function getCursorCoords(view: EditorView): { top: number; left: number } | null {
|
|
const pos = view.state.selection.main.head
|
|
const coords = view.coordsAtPos(pos)
|
|
if (!coords) return null
|
|
return { top: coords.bottom, left: coords.left }
|
|
}
|
|
|
|
export function RawEditorView({ content, path, entries, onContentChange, onSave, latestContentRef }: RawEditorViewProps) {
|
|
const containerRef = useRef<HTMLDivElement>(null)
|
|
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
|
const pathRef = useRef(path)
|
|
const onContentChangeRef = useRef(onContentChange)
|
|
const onSaveRef = useRef(onSave)
|
|
const latestDocRef = useRef(content)
|
|
useEffect(() => { pathRef.current = path }, [path])
|
|
// Expose latest doc content to parent via ref
|
|
useEffect(() => { if (latestContentRef) latestContentRef.current = content }, [latestContentRef, content])
|
|
useEffect(() => { onContentChangeRef.current = onContentChange }, [onContentChange])
|
|
useEffect(() => { onSaveRef.current = onSave }, [onSave])
|
|
|
|
const [autocomplete, setAutocomplete] = useState<AutocompleteState | null>(null)
|
|
const [yamlError, setYamlError] = useState<string | null>(() => detectYamlError(content))
|
|
|
|
const typeEntryMap = useMemo(() => buildTypeEntryMap(entries), [entries])
|
|
|
|
const baseItems = useMemo(
|
|
() => deduplicateByPath(entries.filter(e => !e.trashed).map(entry => ({
|
|
title: entry.title,
|
|
aliases: [...new Set([entry.filename.replace(/\.md$/, ''), ...entry.aliases])],
|
|
group: entry.isA || 'Note',
|
|
entryTitle: entry.title,
|
|
path: entry.path,
|
|
}))),
|
|
[entries],
|
|
)
|
|
|
|
const insertWikilinkRef = useRef<(entryTitle: string) => void>(() => {})
|
|
|
|
const latestContentRefStable = useRef(latestContentRef)
|
|
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)
|
|
}, [])
|
|
|
|
const handleCursorActivity = useCallback((view: EditorView) => {
|
|
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) {
|
|
setAutocomplete(null)
|
|
return
|
|
}
|
|
const coords = getCursorCoords(view)
|
|
if (!coords) { setAutocomplete(null); return }
|
|
const candidates = preFilterWikilinks(baseItems, query)
|
|
const withHandlers = attachClickHandlers(candidates, (title: string) => insertWikilinkRef.current(title))
|
|
const items = enrichSuggestionItems(withHandlers, query, typeEntryMap)
|
|
setAutocomplete({ caretTop: coords.top, caretLeft: coords.left, selectedIndex: 0, items })
|
|
}, [baseItems, typeEntryMap])
|
|
|
|
const handleSave = useCallback(() => {
|
|
if (debounceRef.current) {
|
|
clearTimeout(debounceRef.current)
|
|
debounceRef.current = null
|
|
onContentChangeRef.current(pathRef.current, latestDocRef.current)
|
|
}
|
|
onSaveRef.current()
|
|
}, [])
|
|
|
|
const handleEscape = useCallback(() => {
|
|
if (autocomplete) { setAutocomplete(null); return true }
|
|
return false
|
|
}, [autocomplete])
|
|
|
|
const viewRef = useCodeMirror(containerRef, content, {
|
|
onDocChange: handleDocChange,
|
|
onCursorActivity: handleCursorActivity,
|
|
onSave: handleSave,
|
|
onEscape: handleEscape,
|
|
})
|
|
|
|
const insertWikilink = useCallback((entryTitle: string) => {
|
|
const view = viewRef.current
|
|
if (!view) return
|
|
const cursor = view.state.selection.main.head
|
|
const doc = view.state.doc.toString()
|
|
const before = doc.slice(0, cursor)
|
|
const triggerIdx = before.lastIndexOf('[[')
|
|
if (triggerIdx === -1) return
|
|
|
|
const after = doc.slice(cursor)
|
|
const newText = `${doc.slice(0, triggerIdx)}[[${entryTitle}]]${after}`
|
|
const newCursor = triggerIdx + entryTitle.length + 4
|
|
|
|
view.dispatch({
|
|
changes: { from: 0, to: doc.length, insert: newText },
|
|
selection: { anchor: newCursor },
|
|
})
|
|
setAutocomplete(null)
|
|
|
|
if (debounceRef.current) clearTimeout(debounceRef.current)
|
|
debounceRef.current = null
|
|
latestDocRef.current = newText
|
|
onContentChangeRef.current(pathRef.current, newText)
|
|
|
|
view.focus()
|
|
}, [viewRef])
|
|
|
|
useEffect(() => { insertWikilinkRef.current = insertWikilink }, [insertWikilink])
|
|
|
|
const handleAutocompleteKey = useCallback((e: React.KeyboardEvent) => {
|
|
if (!autocomplete) return
|
|
if (e.key === 'ArrowDown') {
|
|
e.preventDefault()
|
|
setAutocomplete(prev => prev
|
|
? { ...prev, selectedIndex: Math.min(prev.selectedIndex + 1, prev.items.length - 1) }
|
|
: null)
|
|
} else if (e.key === 'ArrowUp') {
|
|
e.preventDefault()
|
|
setAutocomplete(prev => prev
|
|
? { ...prev, selectedIndex: Math.max(prev.selectedIndex - 1, 0) }
|
|
: null)
|
|
} else if (e.key === 'Enter') {
|
|
e.preventDefault()
|
|
const item = autocomplete.items[autocomplete.selectedIndex]
|
|
if (item) insertWikilink(item.entryTitle ?? item.title)
|
|
}
|
|
}, [autocomplete, insertWikilink])
|
|
|
|
// Flush pending debounce on unmount
|
|
useEffect(() => {
|
|
return () => {
|
|
if (debounceRef.current) {
|
|
clearTimeout(debounceRef.current)
|
|
onContentChangeRef.current(pathRef.current, latestDocRef.current)
|
|
}
|
|
}
|
|
}, [])
|
|
|
|
const dropdownBelow = autocomplete
|
|
? autocomplete.caretTop + 20 + DROPDOWN_MAX_HEIGHT <= window.innerHeight
|
|
: true
|
|
const dropdownTop = autocomplete
|
|
? (dropdownBelow ? autocomplete.caretTop + 4 : autocomplete.caretTop - DROPDOWN_MAX_HEIGHT - 24)
|
|
: 0
|
|
const dropdownLeft = autocomplete
|
|
? Math.min(autocomplete.caretLeft, window.innerWidth - 260)
|
|
: 0
|
|
|
|
return (
|
|
<div className="flex flex-1 flex-col min-h-0 relative" style={{ background: 'var(--background)' }} onKeyDown={handleAutocompleteKey} role="presentation">
|
|
{yamlError && (
|
|
<div
|
|
className="flex items-center gap-2 px-4 py-2 text-xs border-b shrink-0"
|
|
style={{ background: '#fef3c7', borderColor: '#d97706', color: '#92400e' }}
|
|
role="alert"
|
|
data-testid="raw-editor-yaml-error"
|
|
>
|
|
<span style={{ fontWeight: 600 }}>YAML error:</span>
|
|
<span>{yamlError}</span>
|
|
</div>
|
|
)}
|
|
<div
|
|
ref={containerRef}
|
|
className="flex flex-1 min-h-0"
|
|
data-testid="raw-editor-codemirror"
|
|
aria-label="Raw editor"
|
|
/>
|
|
{autocomplete && autocomplete.items.length > 0 && (
|
|
<div
|
|
className="fixed z-50 min-w-64 max-w-xs rounded-md border shadow-lg overflow-auto"
|
|
style={{
|
|
top: dropdownTop,
|
|
left: dropdownLeft,
|
|
maxHeight: DROPDOWN_MAX_HEIGHT,
|
|
background: 'var(--popover)',
|
|
borderColor: 'var(--border)',
|
|
}}
|
|
data-testid="raw-editor-wikilink-dropdown"
|
|
>
|
|
<NoteSearchList
|
|
items={autocomplete.items}
|
|
selectedIndex={autocomplete.selectedIndex}
|
|
getItemKey={(item, i) => `${item.title}-${item.path ?? i}`}
|
|
onItemClick={(item) => insertWikilink(item.entryTitle ?? item.title)}
|
|
onItemHover={(i) => setAutocomplete(prev => prev ? { ...prev, selectedIndex: i } : null)}
|
|
/>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|