Add trackEvent() calls for all user actions in the tracking plan: - Vault: vault_opened (with has_git, note_count), vault_switched - Notes: note_created (with has_type, creation_path), note_deleted, note_trashed, note_archived, note_favorited, note_unfavorited - Git: commit_made, sync_triggered - Search: search_used - Editor: raw_mode_toggled, wikilink_inserted - Types & Views: type_created, view_created - Telemetry: telemetry_opted_in, telemetry_opted_out All events gated by PostHog initialization (only fires when opted in). No PII in any event properties — counts, booleans, and enums only. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
235 lines
8.7 KiB
TypeScript
235 lines
8.7 KiB
TypeScript
import { useRef, useState, useCallback, useEffect, useMemo } from 'react'
|
|
import { trackEvent } from '../lib/telemetry'
|
|
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
|
|
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<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, vaultPath }: 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), vaultPath ?? '')
|
|
const items = enrichSuggestionItems(withHandlers, query, typeEntryMap)
|
|
setAutocomplete({ caretTop: coords.top, caretLeft: coords.left, selectedIndex: 0, items })
|
|
}, [baseItems, typeEntryMap, vaultPath])
|
|
|
|
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 },
|
|
})
|
|
trackEvent('wikilink_inserted')
|
|
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>
|
|
)
|
|
}
|