/** * DocumentEditor - Split-view markdown editor with auto-save. * * Features: * - CodeMirror 6 for editing * - Live markdown preview * - Debounced auto-save (2s) * - Keyboard shortcuts (Cmd+S to save) * - Unsaved changes warning */ import { AlertCircleIcon, ArrowLeftIcon, BookOpenIcon, CheckIcon, CloudIcon, HistoryIcon, EyeIcon, EyeOffIcon, HomeIcon, LinkIcon, Loader2Icon, PenIcon, SquareArrowOutUpRightIcon, UnlinkIcon, } from "lucide-react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { NOTE_PRESETS, resolveNotePreset } from "../../../core/note-presets"; import { Loader } from "../components/ai-elements/loader"; import { CodeMirrorEditor, type CodeMirrorEditorRef, MarkdownPreview, } from "../components/editor"; import { parseFrontmatter } from "../components/FrontmatterDisplay"; import { Badge } from "../components/ui/badge"; import { Button } from "../components/ui/button"; import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, } from "../components/ui/dialog"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from "../components/ui/select"; import { Separator } from "../components/ui/separator"; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger, } from "../components/ui/tooltip"; import { type WikiLinkDoc, WikiLinkAutocomplete, } from "../components/WikiLinkAutocomplete"; import { apiFetch } from "../hooks/use-api"; import { useDocEvents } from "../hooks/use-doc-events"; import { buildEditDeepLink, parseDocumentDeepLink } from "../lib/deep-links"; import { waitForDocumentAvailability } from "../lib/document-availability"; import { appendLocalHistory, loadLocalHistory, loadLatestLocalHistory, type LocalHistoryEntry, } from "../lib/local-history"; import { getActiveWikiLinkQuery } from "../lib/wiki-link"; interface PageProps { navigate: (to: string | number) => void; } interface DocData { docid: string; uri: string; title: string | null; content: string | null; contentAvailable: boolean; collection: string; relPath: string; source: { absPath?: string; mime: string; ext: string; modifiedAt?: string; sizeBytes?: number; sourceHash?: string; }; capabilities: { editable: boolean; tagsEditable: boolean; tagsWriteback: boolean; canCreateEditableCopy: boolean; mode: "editable" | "read_only"; reason?: string; }; } interface CreateEditableCopyResponse { uri: string; path: string; jobId: string | null; note?: string; } interface UpdateDocResponse { success: boolean; docId: string; uri: string; path: string; jobId: string | null; writeBack?: "applied" | "skipped_unsupported"; version: { sourceHash: string; modifiedAt?: string; }; } interface DocsAutocompleteResponse { docs: WikiLinkDoc[]; } type SaveStatus = "saved" | "saving" | "unsaved" | "error"; function useDebouncedCallback( callback: (...args: T) => void | Promise, delay: number ) { const timeoutRef = useRef | null>(null); const debouncedFn = useCallback( (...args: T) => { if (timeoutRef.current) { clearTimeout(timeoutRef.current); } timeoutRef.current = setTimeout(() => { void callback(...args); }, delay); }, [callback, delay] ); const cancel = useCallback(() => { if (timeoutRef.current) { clearTimeout(timeoutRef.current); timeoutRef.current = null; } }, []); const flush = useCallback( (...args: T) => { cancel(); void callback(...args); }, [callback, cancel] ); useEffect(() => { return () => { if (timeoutRef.current) { clearTimeout(timeoutRef.current); } }; }, []); return { debouncedFn, cancel, flush }; } function formatTime(date: Date): string { return date.toLocaleTimeString("en-US", { hour: "2-digit", minute: "2-digit", }); } export default function DocumentEditor({ navigate }: PageProps) { const currentTarget = useMemo( () => parseDocumentDeepLink(window.location.search), [] ); const [doc, setDoc] = useState(null); const [error, setError] = useState(null); const [loading, setLoading] = useState(true); const [content, setContent] = useState(""); const [originalContent, setOriginalContent] = useState(""); const [saveStatus, setSaveStatus] = useState("saved"); const [lastSaved, setLastSaved] = useState(null); const [saveError, setSaveError] = useState(null); const [creatingCopy, setCreatingCopy] = useState(false); const [copyError, setCopyError] = useState(null); const [externalChangeNotice, setExternalChangeNotice] = useState< string | null >(null); const [historyEntries, setHistoryEntries] = useState([]); const [historyDialogOpen, setHistoryDialogOpen] = useState(false); const [selectedHistoryIndex, setSelectedHistoryIndex] = useState(0); const [insertPresetId, setInsertPresetId] = useState("project-note"); const [showPreview, setShowPreview] = useState(true); const [syncScroll, setSyncScroll] = useState(true); const [showUnsavedDialog, setShowUnsavedDialog] = useState(false); const [wikiLinkDocs, setWikiLinkDocs] = useState([]); const [wikiLinkOpen, setWikiLinkOpen] = useState(false); const [wikiLinkQuery, setWikiLinkQuery] = useState(""); const [wikiLinkRange, setWikiLinkRange] = useState<{ start: number; end: number; } | null>(null); const [wikiLinkPosition, setWikiLinkPosition] = useState({ x: 48, y: 120 }); const [wikiLinkActiveIndex, setWikiLinkActiveIndex] = useState(-1); /** Where to navigate after dialog action (-1 for back, or URL string) */ const [pendingNavigation, setPendingNavigation] = useState< string | number | null >(null); const editorRef = useRef(null); const previewRef = useRef(null); // Event-based suppression: ignore the echo event caused by programmatic scroll const ignoreNextEditorScroll = useRef(false); const ignoreNextPreviewScroll = useRef(false); const ignoreDocEventsUntilRef = useRef(0); const latestDocEvent = useDocEvents(); const hasUnsavedChanges = content !== originalContent; const hasLocalSnapshot = historyEntries.length > 0; const parsedContent = useMemo(() => parseFrontmatter(content), [content]); const refreshHistoryEntries = useCallback((docId: string) => { const next = loadLocalHistory(docId); setHistoryEntries(next); if (next.length === 0) { setSelectedHistoryIndex(0); return; } setSelectedHistoryIndex((current) => Math.min(current, next.length - 1)); }, []); // Reset ignore flags when sync is toggled to prevent stale state useEffect(() => { ignoreNextEditorScroll.current = false; ignoreNextPreviewScroll.current = false; }, [syncScroll, showPreview]); // Scroll sync handlers with event-based loop prevention // Note: Uses percentage-based mapping which provides approximate correspondence. // For very different layouts (headings, code blocks, images), perfect alignment // would require anchor-based mapping between editor lines and rendered elements. const handleEditorScroll = useCallback( (scrollPercent: number) => { // Clear ignore flag first, even if we early-return (prevents lingering) if (ignoreNextEditorScroll.current) { ignoreNextEditorScroll.current = false; return; } if (!syncScroll || !showPreview) return; if (!Number.isFinite(scrollPercent)) return; const clamped = Math.max(0, Math.min(1, scrollPercent)); const preview = previewRef.current; if (!preview) return; const maxScroll = preview.scrollHeight - preview.clientHeight; if (maxScroll <= 0) return; const targetScroll = clamped * maxScroll; // Only set ignore flag if scroll position actually changes (avoids lingering flag) if (Math.abs(preview.scrollTop - targetScroll) > 0.5) { ignoreNextPreviewScroll.current = true; preview.scrollTop = targetScroll; } }, [syncScroll, showPreview] ); const handlePreviewScroll = useCallback(() => { // Clear ignore flag first, even if we early-return (prevents lingering) if (ignoreNextPreviewScroll.current) { ignoreNextPreviewScroll.current = false; return; } if (!syncScroll) return; const preview = previewRef.current; if (!preview) return; const maxScroll = preview.scrollHeight - preview.clientHeight; if (maxScroll <= 0) return; const scrollPercentRaw = preview.scrollTop / maxScroll; if (!Number.isFinite(scrollPercentRaw)) return; const scrollPercent = Math.max(0, Math.min(1, scrollPercentRaw)); // Set ignore flag BEFORE programmatic scroll to prevent race condition ignoreNextEditorScroll.current = true; const didScroll = editorRef.current?.scrollToPercent(scrollPercent) ?? false; // Clear flag if no scroll actually occurred (avoids lingering) if (!didScroll) { ignoreNextEditorScroll.current = false; } }, [syncScroll]); // Save function const saveDocument = useCallback( async (contentToSave: string) => { if (!doc) return; setSaveStatus("saving"); setSaveError(null); const { data, error: err } = await apiFetch( `/api/docs/${encodeURIComponent(doc.docid)}`, { method: "PUT", body: JSON.stringify({ content: contentToSave, expectedSourceHash: doc.source.sourceHash, expectedModifiedAt: doc.source.modifiedAt, uri: doc.uri, }), } ); if (err) { setSaveStatus("error"); setSaveError(err); } else { ignoreDocEventsUntilRef.current = Date.now() + 5_000; if (originalContent !== contentToSave) { appendLocalHistory(doc.docid, originalContent); refreshHistoryEntries(doc.docid); } setSaveStatus("saved"); setOriginalContent(contentToSave); setLastSaved(new Date()); if (data) { setDoc((currentDoc) => currentDoc ? { ...currentDoc, source: { ...currentDoc.source, sourceHash: data.version.sourceHash, modifiedAt: data.version.modifiedAt, }, } : currentDoc ); } } }, [doc] ); const handleCreateEditableCopy = useCallback(async () => { if (!doc?.capabilities.canCreateEditableCopy) return; setCreatingCopy(true); setCopyError(null); const { data, error: err } = await apiFetch( `/api/docs/${encodeURIComponent(doc.docid)}/editable-copy`, { method: "POST", body: JSON.stringify({ uri: doc.uri }), } ); setCreatingCopy(false); if (err) { setCopyError(err); return; } if (data) { ignoreDocEventsUntilRef.current = Date.now() + 5_000; const ready = await waitForDocumentAvailability(data.uri); if (!ready) { setCopyError( "Created the markdown copy, but it is still indexing. Try again in a moment." ); return; } navigate(`/edit?uri=${encodeURIComponent(data.uri)}`); } }, [doc, navigate]); const handleInsertPreset = useCallback(() => { const resolved = resolveNotePreset({ presetId: insertPresetId, title: doc?.title ?? "Untitled", }); if (!resolved) { return; } const scaffold = hasUnsavedChanges || content.trim().length > 0 ? resolved.body.replace(/^# .*?\n+/u, "").trim() : resolved.content.trim(); if (!scaffold) { return; } const insertion = hasUnsavedChanges || content.trim().length > 0 ? `\n\n${scaffold}\n` : `${scaffold}\n`; editorRef.current?.insertAtCursor(insertion); }, [content, doc?.title, hasUnsavedChanges, insertPresetId]); const insertWikiLink = useCallback( (title: string) => { if (!wikiLinkRange) return; const didReplace = editorRef.current?.replaceRange( wikiLinkRange.start, wikiLinkRange.end, `[[${title}]]` ); if (didReplace) { setWikiLinkOpen(false); setWikiLinkActiveIndex(-1); } }, [wikiLinkRange] ); const handleCreateLinkedNote = useCallback( async (title: string) => { if (!doc) return; const filename = title .toLowerCase() .trim() .replaceAll(/[^\w\s-]/g, "") .replaceAll(/\s+/g, "-") .replaceAll(/-+/g, "-") .replace(/^-|-$/g, ""); const relPath = `${filename || "untitled"}.md`; const { data, error: err } = await apiFetch( "/api/docs", { method: "POST", body: JSON.stringify({ collection: doc.collection, relPath, content: `# ${title}\n`, }), } ); if (err) { setCopyError(err); return; } insertWikiLink(title); if (data) { setWikiLinkDocs((current) => [ ...current, { title, uri: data.uri, docid: data.uri, collection: doc.collection, }, ]); } }, [doc, insertWikiLink] ); // Debounced auto-save const { debouncedFn: debouncedSave } = useDebouncedCallback( saveDocument, 2000 ); // Handle content changes const handleContentChange = useCallback( (newContent: string) => { setContent(newContent); if (newContent !== originalContent) { setSaveStatus("unsaved"); debouncedSave(newContent); } const cursor = editorRef.current?.getCursorInfo(); if (!cursor) { setWikiLinkOpen(false); return; } const activeQuery = getActiveWikiLinkQuery(newContent, cursor.pos); if (!activeQuery) { setWikiLinkOpen(false); setWikiLinkRange(null); return; } setWikiLinkRange({ start: activeQuery.start, end: activeQuery.end }); setWikiLinkQuery(activeQuery.query); setWikiLinkPosition({ x: cursor.x, y: cursor.y + 8 }); setWikiLinkOpen(true); setWikiLinkActiveIndex(0); }, [originalContent, debouncedSave] ); useEffect(() => { if (!wikiLinkOpen) return; const params = new URLSearchParams({ limit: "8", query: wikiLinkQuery, }); if (doc?.collection) { params.set("collection", doc.collection); } void apiFetch( `/api/docs/autocomplete?${params.toString()}` ).then(({ data }) => { setWikiLinkDocs(data?.docs ?? []); }); }, [doc?.collection, wikiLinkOpen, wikiLinkQuery]); // Force save (Cmd+S) - saves and triggers embedding const handleForceSave = useCallback(async () => { if (!hasUnsavedChanges || !doc) return; setSaveStatus("saving"); setSaveError(null); // Save document const { data, error: err } = await apiFetch( `/api/docs/${encodeURIComponent(doc.docid)}`, { method: "PUT", body: JSON.stringify({ content, expectedSourceHash: doc.source.sourceHash, expectedModifiedAt: doc.source.modifiedAt, uri: doc.uri, }), } ); if (err) { setSaveStatus("error"); setSaveError(err); return; } ignoreDocEventsUntilRef.current = Date.now() + 5_000; if (originalContent !== content) { appendLocalHistory(doc.docid, originalContent); refreshHistoryEntries(doc.docid); } setSaveStatus("saved"); setOriginalContent(content); setLastSaved(new Date()); if (data) { setDoc({ ...doc, source: { ...doc.source, sourceHash: data.version.sourceHash, modifiedAt: data.version.modifiedAt, }, }); } // Trigger embedding (fire and forget - don't block on result) void apiFetch("/api/embed", { method: "POST" }); }, [hasUnsavedChanges, doc, content]); const loadDocument = useCallback(() => { const uri = currentTarget.uri; if (!uri) { setError("No document URI provided"); setLoading(false); return; } void apiFetch(`/api/doc?uri=${encodeURIComponent(uri)}`).then( ({ data, error: err }) => { setLoading(false); if (err) { setError(err); } else if (data) { setDoc(data); const docContent = data.content ?? ""; setContent(docContent); setOriginalContent(docContent); refreshHistoryEntries(data.docid); // Ensure CodeMirror reflects content after async load requestAnimationFrame(() => { editorRef.current?.setValue(docContent); if (currentTarget.lineStart) { editorRef.current?.revealLine(currentTarget.lineStart); } }); } } ); }, [currentTarget.lineStart, currentTarget.uri]); // Load document useEffect(() => { loadDocument(); }, [loadDocument]); useEffect(() => { if (!doc || latestDocEvent?.uri !== doc.uri) { return; } if (Date.now() < ignoreDocEventsUntilRef.current) { return; } setExternalChangeNotice( "This document changed on disk. Reload before continuing." ); }, [doc, latestDocEvent?.changedAt, latestDocEvent?.uri]); const reloadDocument = useCallback(() => { setExternalChangeNotice(null); loadDocument(); }, [loadDocument]); const restoreLatestSnapshot = useCallback(() => { if (!doc) return; const latest = loadLatestLocalHistory(doc.docid); if (!latest) return; setContent(latest.content); editorRef.current?.setValue(latest.content); setSaveStatus("unsaved"); }, [doc]); const selectedHistoryEntry = historyEntries[selectedHistoryIndex] ?? null; const restoreSelectedHistory = useCallback(() => { if (!selectedHistoryEntry) { return; } setContent(selectedHistoryEntry.content); editorRef.current?.setValue(selectedHistoryEntry.content); setSaveStatus("unsaved"); setHistoryDialogOpen(false); }, [selectedHistoryEntry]); // Keyboard shortcuts useEffect(() => { const handleKeyDown = (e: KeyboardEvent) => { const isMeta = e.metaKey || e.ctrlKey; // Cmd+S / Ctrl+S - Save if (isMeta && e.key === "s") { e.preventDefault(); void handleForceSave(); return; } // Editor-specific shortcuts - only when focus is in CodeMirror const target = e.target as HTMLElement; const inEditor = !!target.closest(".cm-editor"); // Cmd+B - Bold (editor only) if (isMeta && e.key === "b" && inEditor) { e.preventDefault(); editorRef.current?.wrapSelection("**", "**"); return; } // Cmd+I - Italic (editor only) if (isMeta && e.key === "i" && inEditor) { e.preventDefault(); editorRef.current?.wrapSelection("*", "*"); return; } // Cmd+K - Link (editor only) if (isMeta && e.key === "k" && inEditor) { e.preventDefault(); editorRef.current?.wrapSelection("[", "](url)"); return; } // Escape - Close (with warning if unsaved) if (e.key === "Escape") { e.preventDefault(); if (hasUnsavedChanges) { setShowUnsavedDialog(true); } else { navigate(-1); } } }; window.addEventListener("keydown", handleKeyDown); return () => window.removeEventListener("keydown", handleKeyDown); }, [handleForceSave, hasUnsavedChanges, navigate]); // Warn before leaving with unsaved changes useEffect(() => { const handleBeforeUnload = (e: BeforeUnloadEvent) => { if (hasUnsavedChanges) { e.preventDefault(); e.returnValue = ""; // Required for modern browsers } }; window.addEventListener("beforeunload", handleBeforeUnload); return () => window.removeEventListener("beforeunload", handleBeforeUnload); }, [hasUnsavedChanges]); // Handle close/back - goes to browser history const handleClose = () => { if (hasUnsavedChanges) { setPendingNavigation(-1); setShowUnsavedDialog(true); } else { navigate(-1); } }; // Handle view doc - goes to doc view page const handleViewDoc = () => { const viewUrl = `/doc?uri=${encodeURIComponent(doc?.uri ?? "")}`; if (hasUnsavedChanges) { setPendingNavigation(viewUrl); setShowUnsavedDialog(true); } else { navigate(viewUrl); } }; // Discard and navigate const handleDiscardAndNavigate = () => { setShowUnsavedDialog(false); if (pendingNavigation !== null) { navigate(pendingNavigation); setPendingNavigation(null); } }; // Save and navigate const handleSaveAndNavigate = async () => { await saveDocument(content); // Trigger embedding (fire and forget) void apiFetch("/api/embed", { method: "POST" }); setShowUnsavedDialog(false); if (pendingNavigation !== null) { navigate(pendingNavigation); setPendingNavigation(null); } }; // Save status indicator const SaveStatusIndicator = () => { const statusConfig = { saved: { icon: CheckIcon, text: lastSaved ? `Saved at ${formatTime(lastSaved)}` : "Saved", className: "text-green-500", }, saving: { icon: Loader2Icon, text: "Saving...", className: "text-muted-foreground animate-spin", }, unsaved: { icon: CloudIcon, text: "Unsaved changes", className: "text-yellow-500", }, error: { icon: AlertCircleIcon, text: saveError ?? "Save failed", className: "text-destructive", }, }; const { icon: Icon, text, className } = statusConfig[saveStatus]; return (
{saveStatus === "saved" && lastSaved ? formatTime(lastSaved) : text}

{text}

); }; // Loading state if (loading) { return (

Loading document...

); } // Error state if (error || !doc) { return (

Failed to load document

{error ?? "Unknown error"}

); } if (!doc.capabilities.editable) { return (

Read-only document

{doc.capabilities.reason ?? "This document cannot be edited in place."}

{copyError && (
{copyError}
)}
{doc.capabilities.canCreateEditableCopy && ( )} {doc.source.absPath && ( )}
); } return (
{/* Toolbar */}
{/* Home button - Scholarly Dusk brass accent */} {/* Back button */} {/* View document (exit edit mode) */}

Exit edit mode

{/* Document info */}

{doc.title || doc.relPath}

{currentTarget.lineStart && ( L{currentTarget.lineStart} {currentTarget.lineEnd && currentTarget.lineEnd !== currentTarget.lineStart ? `-${currentTarget.lineEnd}` : ""} )} {hasUnsavedChanges && ( Unsaved )}
{/* Save status */} {/* Preview toggle */} {/* Preview toggle */}

{showPreview ? "Hide preview" : "Show preview"}

{/* Sync scroll toggle (only show when preview is visible) */} {showPreview && (

{syncScroll ? "Disable scroll sync" : "Enable scroll sync"}

)} {/* Save button */}
{externalChangeNotice && (

{externalChangeNotice}

{hasLocalSnapshot && ( )}
)} {/* Editor area */}
{/* Editor pane */}
{/* Preview pane */} {showPreview && (
)}
Local history Restore a recent local snapshot captured before an in-app save.
{historyEntries.length === 0 ? (

No local snapshots yet.

) : ( historyEntries.map((entry, index) => ( )) )}
{selectedHistoryEntry ? (

Snapshot from{" "} {new Date(selectedHistoryEntry.savedAt).toLocaleString( "en-US", { year: "numeric", month: "short", day: "numeric", hour: "2-digit", minute: "2-digit", } )}

                    {selectedHistoryEntry.content || "(empty)"}
                  
) : (

Select a snapshot to preview it.

)}
{ void handleCreateLinkedNote(title); }} onDismiss={() => setWikiLinkOpen(false)} onSelect={(title) => insertWikiLink(title)} position={wikiLinkPosition} searchQuery={wikiLinkQuery} /> {/* Unsaved changes dialog */} Unsaved changes You have unsaved changes. What would you like to do?
); }