/** * Linked Document Hook * * Manages same-view navigation to local .md files referenced in plans. * Handles state swapping (save plan state, load doc, restore on back), * annotation caching per filepath, and highlight re-application. */ import { useState, useCallback, useRef } from "react"; import { normalizeBrowserPath } from "@plannotator/core/browser-paths"; import { isDiagramRenderKind } from "@plannotator/core/annotatable"; import type { Annotation, DocumentRenderAs, ImageAttachment } from "../types"; import type { ViewerHandle } from "../components/Viewer"; import type { SidebarTab } from "./useSidebar"; import type { SourceSaveCapability } from "@plannotator/core/source-save"; import type { VersionInfo } from "./usePlanDiff"; import { annotationOwnsHighlight } from "../utils/annotationOwnsHighlight"; export interface LinkedDocLoadData { markdown?: string; filepath?: string; isConverted?: boolean; renderAs?: DocumentRenderAs; rawHtml?: string; shareHtml?: string; sourceSave?: SourceSaveCapability; /** * Per-file version-diff baseline (annotate folder sessions only) — the same * field names/shapes /api/plan already returns for single-file sessions. * /api/doc only populates these for eligible folder files (local annotatable * plain-text files — .md/.mdx/.txt plus the config set, per * `isAnnotatableTextPath` — markdown-branch, not converted/HTML); every other * document — including * plain linked docs outside folder mode — omits them, which is what keeps * the diff badge/version browser from appearing for those. */ previousPlan?: string | null; versionInfo?: VersionInfo | null; } /** * Resolve the version-diff baseline for a document being activated: prefer * an already-cached baseline (set the first time this document was opened) * over the freshly-fetched `data`, so re-opening a document during a session * never loses — or needs to re-request — its baseline. Pure so it's testable * without mounting the hook; used by activateDocument below. * * Gated on whether a baseline was EVER captured for this document (versionInfo * presence), not on truthiness of the cached previousPlan value — a document * at its first-ever version legitimately caches `previousPlan: null` ("no * earlier version to diff against yet"), which is a resolved fact, not a * cache miss. Falling through to `data` on a plain `??` would silently * discard that fact whenever `data` lacks the fields too (e.g. the * missing-on-disk openLoaded path, which never fetches /api/doc). */ export function resolveDiffBaseline( cached: Pick | undefined, data: Pick, ): { previousPlan: string | null | undefined; versionInfo: VersionInfo | null | undefined } { if (cached && cached.versionInfo !== undefined) { return { previousPlan: cached.previousPlan, versionInfo: cached.versionInfo }; } return { previousPlan: data.previousPlan, versionInfo: data.versionInfo }; } export interface UseLinkedDocOptions { markdown: string; annotations: Annotation[]; selectedAnnotationId: string | null; globalAttachments: ImageAttachment[]; setMarkdown: (md: string) => void; setAnnotations: (anns: Annotation[]) => void; setSelectedAnnotationId: (id: string | null) => void; setGlobalAttachments: (att: ImageAttachment[]) => void; /** Current render mode + raw HTML of the base document. An HTML linked/folder file * swaps these to render raw; back() restores the base values from this snapshot. */ renderAs: DocumentRenderAs; rawHtml: string; shareHtml: string; setRenderAs: (r: DocumentRenderAs) => void; setRawHtml: (html: string) => void; setShareHtml: (html: string) => void; viewerRef: React.RefObject; sidebar: { open: (tab?: SidebarTab) => void }; /** Absolute path of the primary document — enables getDocAnnotations() to include * stashed original-file annotations when viewing a linked doc. */ sourceFilePath?: string; /** Whether the primary document was converted from HTML/URL — propagated to the * stashed entry so feedback caveats survive cross-doc navigation. */ sourceConverted?: boolean; /** Snapshot live editor state before linked/folder navigation swaps documents. */ onBeforeNavigate?: () => void; /** Let the host initialize/restore editable document state and optionally * override the markdown displayed for this file. */ onDocumentLoaded?: (doc: LinkedDocLoadData) => string | undefined; /** Notify the host after any fetched or already-loaded destination has been * activated, including HTML documents and backlinks to the source. */ onDocumentActivated?: (doc: LinkedDocLoadData & { filepath: string }) => void; /** Read current host-owned text when caching a linked doc. */ getDocumentMarkdown?: (filepath: string, fallback?: string) => string | undefined; /** Let the host restore any state that was suspended while a linked doc was active. */ onAfterBack?: () => void; } interface SavedPlanState { markdown: string; annotations: Annotation[]; selectedAnnotationId: string | null; globalAttachments: ImageAttachment[]; renderAs: DocumentRenderAs; rawHtml: string; shareHtml: string; } export interface CachedDocState { annotations: Annotation[]; globalAttachments: ImageAttachment[]; markdown?: string; isConverted?: boolean; /** Version-diff baseline captured the first time this document was * activated — see LinkedDocLoadData. Persisted here so re-opening the * same document later in the session reuses it instead of losing it (or * needing to re-fetch it) once the fresh /api/doc data is gone. */ previousPlan?: string | null; versionInfo?: VersionInfo | null; } /** * The session's documents split the way FEEDBACK must be: the root document * (the plan, the annotated file, or the folder itself) once, and every other * document once under its own path. `getDocAnnotations()` is NOT this split — * it also carries the active document's live state, which the host exports * from its own state too, so exporting both printed the open document twice. */ export interface FeedbackDocuments { /** * The root document's stashed state while a linked document is open. Null * while the root itself is the active document: its annotations are then * the host's live state, which the host exports itself. */ root: (CachedDocState & { renderAs: DocumentRenderAs }) | null; /** * Every document other than the root that carries state, keyed by path: * the cached documents plus the active linked document's live state. The * root's own annotations are never here; a copy of the root's path opened * from the root itself is a separate annotation set and is listed by path. */ documents: Map; } export interface LinkedDocSessionState { root: SavedPlanState; docs: Map; } export interface UseLinkedDocReturn { /** Whether a linked doc is currently active */ isActive: boolean; /** Resolved filepath of the active linked doc */ filepath: string | null; /** Error from the last open attempt */ error: string | null; /** Whether a fetch is in progress */ isLoading: boolean; /** Open a linked document by path (saves plan state, fetches doc, swaps). * `revealSidebar: false` leaves the sidebar exactly as it was. */ open: ( docPath: string, buildUrl?: (path: string) => string, targetTab?: SidebarTab, options?: { revealSidebar?: boolean }, ) => Promise; /** Open an already-loaded linked document without refetching from disk */ openLoaded: ( doc: LinkedDocLoadData & { filepath: string }, targetTab?: SidebarTab, options?: { notifyDocumentLoaded?: boolean; revealSidebar?: boolean }, ) => void; /** Return to the plan (caches doc annotations, restores plan state) */ back: () => void; /** Dismiss the current error */ dismissError: () => void; /** All linked doc annotations including the active doc's live state (keyed by filepath). * For counts and the cross-file panel; a feedback export reads getFeedbackDocuments(). */ getDocAnnotations: () => Map; /** The root document and every other document, each exactly once — what a * feedback export reads (see FeedbackDocuments). */ getFeedbackDocuments: () => FeedbackDocuments; /** * Replace the stored annotations of a document that is NOT the active one — * a cached linked doc, or the stashed source document. This is what lets the * panel's cross-file view edit and delete another file's comments without * navigating to it; the active document's annotations are host state * (`setAnnotations`) and are deliberately never touched here. * * Returns false when no stored document matches (including the active one), * so a host can fall back to its own live-state path. */ updateStoredAnnotations: ( filepath: string, update: (annotations: Annotation[]) => Annotation[], ) => boolean; /** Snapshot the root document plus linked-doc cache for cross-document session swaps */ snapshotSession: () => LinkedDocSessionState; /** Restore a root document plus linked-doc cache, closing any active linked document */ restoreSession: (state: LinkedDocSessionState) => void; /** Reactive count of annotations on non-active documents (updates on open() and back()) */ docAnnotationCount: number; /** Active document's version-diff baseline (folder annotate only). Null * when no document is active, or the active document has no eligible * history — including every non-folder linked doc, since /api/doc never * populates these fields for those. */ diffPreviousPlan: string | null; diffVersionInfo: VersionInfo | null; } const HIGHLIGHT_REAPPLY_DELAY = 100; export function useLinkedDoc(options: UseLinkedDocOptions): UseLinkedDocReturn { const { markdown, annotations, selectedAnnotationId, globalAttachments, setMarkdown, setAnnotations, setSelectedAnnotationId, setGlobalAttachments, renderAs, rawHtml, shareHtml, setRenderAs, setRawHtml, setShareHtml, viewerRef, sidebar, sourceFilePath, sourceConverted, onBeforeNavigate, onDocumentLoaded, onDocumentActivated, getDocumentMarkdown, onAfterBack, } = options; const [linkedDoc, setLinkedDoc] = useState<{ filepath: string; isConverted?: boolean; markdown?: string; previousPlan?: string | null; versionInfo?: VersionInfo | null; } | null>(null); const [error, setError] = useState(null); const [isLoading, setIsLoading] = useState(false); const [docAnnotationCount, setDocAnnotationCount] = useState(0); // Bumped whenever a stored (non-active) document's annotations change in // place. docCache is a ref, so without this the memos keyed on // getDocAnnotations' identity — panel groups, counts, the export — would // keep serving the pre-mutation cache. const [storeRevision, setStoreRevision] = useState(0); // Stash plan state when navigating to a linked doc const savedPlanState = useRef(null); // Cache linked doc annotations keyed by filepath (persists across back/forth within session) const docCache = useRef>(new Map()); const defaultBuildUrl = useCallback( (path: string) => `/api/doc?path=${encodeURIComponent(path)}`, [] ); const back = useCallback(() => { if (!savedPlanState.current) return; onBeforeNavigate?.(); // Clear web-highlighter marks before swapping content to prevent React DOM mismatch viewerRef.current?.clearAllHighlights(); // Cache current linked doc annotations if (linkedDoc) { docCache.current.set(linkedDoc.filepath, { annotations: [...annotations], globalAttachments: [...globalAttachments], markdown: getDocumentMarkdown?.(linkedDoc.filepath, linkedDoc.markdown) ?? linkedDoc.markdown, isConverted: linkedDoc.isConverted, previousPlan: linkedDoc.previousPlan, versionInfo: linkedDoc.versionInfo, }); // Update reactive count so button labels can respond let total = 0; for (const cached of docCache.current.values()) { total += cached.annotations.length + cached.globalAttachments.length; } setDocAnnotationCount(total); } // Restore plan state (including render mode — an HTML base restores to HTML) const saved = savedPlanState.current; setRenderAs(saved.renderAs); setRawHtml(saved.rawHtml); setShareHtml(saved.shareHtml); setMarkdown(saved.markdown); setAnnotations(saved.annotations); setGlobalAttachments(saved.globalAttachments); setSelectedAnnotationId(saved.selectedAnnotationId); setLinkedDoc(null); setError(null); savedPlanState.current = null; onAfterBack?.(); // Re-apply plan annotation highlights after DOM settles if (saved.annotations.length) { setTimeout(() => { viewerRef.current?.clearAllHighlights(); viewerRef.current?.applySharedAnnotations(saved.annotations.filter(annotationOwnsHighlight)); }, HIGHLIGHT_REAPPLY_DELAY); } }, [ linkedDoc, annotations, globalAttachments, setMarkdown, setAnnotations, setSelectedAnnotationId, setGlobalAttachments, setRenderAs, setRawHtml, setShareHtml, viewerRef, onBeforeNavigate, getDocumentMarkdown, onAfterBack, ]); const activateDocument = useCallback(( data: LinkedDocLoadData & { filepath: string }, targetTab?: SidebarTab, options: { snapshotCurrent?: boolean; notifyDocumentLoaded?: boolean; revealSidebar?: boolean; } = {}, ) => { const snapshotCurrent = options.snapshotCurrent ?? true; const notifyDocumentLoaded = options.notifyDocumentLoaded ?? true; // Opening a document reveals the sidebar so its "Viewing / Back to …" // header is in reach. A host whose surface has its own way back (the // raw-HTML header's Back control) passes false, and the sidebar is left // exactly as the user had it — closed stays closed, open stays put. const revealSidebar = options.revealSidebar ?? true; if (snapshotCurrent) onBeforeNavigate?.(); // Backlink detection: if a linked doc links back to the source file (e.g., // original.md → design.md → link back to original.md), opening it as a linked // doc would create two competing Map entries for the same filepath in // getDocAnnotations(), and the empty linked-doc entry would overwrite the // stashed annotations. Instead, treat the backlink as a back() navigation — // the current linked doc gets cached and the source file restores with its // annotations intact. if (sourceFilePath && data.filepath === sourceFilePath && savedPlanState.current) { back(); onDocumentActivated?.(data); return; } // Clear web-highlighter marks before swapping content to prevent React DOM mismatch viewerRef.current?.clearAllHighlights(); // Save current state (plan or another linked doc) if (!savedPlanState.current) { savedPlanState.current = { markdown, annotations: [...annotations], selectedAnnotationId, globalAttachments: [...globalAttachments], renderAs, rawHtml, shareHtml, }; let total = annotations.length + globalAttachments.length; for (const [fp, cached] of docCache.current.entries()) { if (fp === data.filepath) continue; // destination becomes active — don't double-count total += cached.annotations.length + cached.globalAttachments.length; } setDocAnnotationCount(total); } else if (linkedDoc) { // Already viewing a linked doc — cache its annotations before moving on docCache.current.set(linkedDoc.filepath, { annotations: [...annotations], globalAttachments: [...globalAttachments], markdown: getDocumentMarkdown?.(linkedDoc.filepath, linkedDoc.markdown) ?? linkedDoc.markdown, isConverted: linkedDoc.isConverted, previousPlan: linkedDoc.previousPlan, versionInfo: linkedDoc.versionInfo, }); let total = 0; for (const [fp, cached] of docCache.current.entries()) { if (fp === data.filepath) continue; // destination becomes active — don't double-count total += cached.annotations.length + cached.globalAttachments.length; } if (savedPlanState.current) { total += savedPlanState.current.annotations.length + savedPlanState.current.globalAttachments.length; } setDocAnnotationCount(total); } // Check cache for previous annotations on this file const cached = docCache.current.get(data.filepath); // Swap to linked doc — an .html file renders raw (HtmlViewer), a markdown // file parses to blocks (Viewer). Drive renderAs/rawHtml per file so the // App's renderAs === 'html' ? HtmlViewer : Viewer switch flips automatically. // A diagram source (.mmd/.dot) keeps the markdown state path — the raw // text is the document body — but names its engine so the App renders it // as one diagram instead of parsing it as markdown. const docRenderAs: DocumentRenderAs = data.renderAs === 'html' ? 'html' : isDiagramRenderKind(data.renderAs) ? data.renderAs : 'markdown'; const hostMarkdown = docRenderAs === 'html' || !notifyDocumentLoaded ? undefined : onDocumentLoaded?.(data); const nextMarkdown = notifyDocumentLoaded ? hostMarkdown ?? cached?.markdown ?? data.markdown ?? '' : data.markdown ?? cached?.markdown ?? ''; setRenderAs(docRenderAs); setRawHtml(docRenderAs === 'html' ? (data.rawHtml ?? '') : ''); setShareHtml(docRenderAs === 'html' ? (data.shareHtml ?? '') : ''); setMarkdown(docRenderAs === 'html' ? '' : nextMarkdown); setAnnotations(cached?.annotations ?? []); setGlobalAttachments(cached?.globalAttachments ?? []); setSelectedAnnotationId(null); const diffBaseline = resolveDiffBaseline(cached, data); setLinkedDoc({ filepath: data.filepath, isConverted: !!data.isConverted, markdown: nextMarkdown, previousPlan: diffBaseline.previousPlan, versionInfo: diffBaseline.versionInfo, }); setError(null); if (revealSidebar) sidebar.open(targetTab ?? "toc"); onDocumentActivated?.(data); // Re-apply cached annotations after DOM settles if (cached?.annotations.length) { setTimeout(() => { viewerRef.current?.clearAllHighlights(); viewerRef.current?.applySharedAnnotations(cached.annotations.filter(annotationOwnsHighlight)); }, HIGHLIGHT_REAPPLY_DELAY); } }, [ markdown, annotations, selectedAnnotationId, globalAttachments, renderAs, rawHtml, shareHtml, linkedDoc, setMarkdown, setAnnotations, setSelectedAnnotationId, setGlobalAttachments, setRenderAs, setRawHtml, setShareHtml, viewerRef, sidebar, sourceFilePath, onBeforeNavigate, onDocumentLoaded, onDocumentActivated, getDocumentMarkdown, back, ]); const openLoaded = useCallback(( doc: LinkedDocLoadData & { filepath: string }, targetTab?: SidebarTab, options?: { notifyDocumentLoaded?: boolean; revealSidebar?: boolean }, ) => { activateDocument(doc, targetTab, { snapshotCurrent: true, notifyDocumentLoaded: options?.notifyDocumentLoaded, revealSidebar: options?.revealSidebar, }); }, [activateDocument]); const open = useCallback( async ( docPath: string, buildUrl?: (path: string) => string, targetTab?: SidebarTab, options?: { revealSidebar?: boolean }, ) => { onBeforeNavigate?.(); setIsLoading(true); setError(null); try { const url = (buildUrl ?? defaultBuildUrl)(docPath); const res = await fetch(url); const data = (await res.json()) as LinkedDocLoadData & { error?: string; matches?: string[]; }; if (!res.ok || data.error) { setError(data.error || "Failed to load document"); return; } if (!data.filepath) { setError("Failed to load document"); return; } activateDocument({ ...data, filepath: data.filepath }, targetTab, { snapshotCurrent: false, revealSidebar: options?.revealSidebar, }); } catch { setError("Failed to connect to server"); } finally { setIsLoading(false); } }, [ onBeforeNavigate, activateDocument, ] ); const dismissError = useCallback(() => setError(null), []); const snapshotSession = useCallback((): LinkedDocSessionState => { const docs = new Map(docCache.current); if (linkedDoc) { docs.set(linkedDoc.filepath, { annotations: [...annotations], globalAttachments: [...globalAttachments], markdown: getDocumentMarkdown?.(linkedDoc.filepath, linkedDoc.markdown) ?? linkedDoc.markdown, isConverted: linkedDoc.isConverted, previousPlan: linkedDoc.previousPlan, versionInfo: linkedDoc.versionInfo, }); } const root = savedPlanState.current ? { markdown: savedPlanState.current.markdown, renderAs: savedPlanState.current.renderAs, rawHtml: savedPlanState.current.rawHtml, shareHtml: savedPlanState.current.shareHtml, annotations: [...savedPlanState.current.annotations], selectedAnnotationId: savedPlanState.current.selectedAnnotationId, globalAttachments: [...savedPlanState.current.globalAttachments], } : { markdown, renderAs, rawHtml, shareHtml, annotations: [...annotations], selectedAnnotationId, globalAttachments: [...globalAttachments], }; return { root, docs }; }, [linkedDoc, annotations, globalAttachments, markdown, renderAs, rawHtml, shareHtml, selectedAnnotationId, getDocumentMarkdown]); const restoreSession = useCallback((state: LinkedDocSessionState) => { viewerRef.current?.clearAllHighlights(); savedPlanState.current = null; docCache.current = new Map(state.docs); let total = 0; for (const cached of docCache.current.values()) { total += cached.annotations.length + cached.globalAttachments.length; } setDocAnnotationCount(total); setMarkdown(state.root.markdown); setRenderAs(state.root.renderAs); setRawHtml(state.root.rawHtml); setShareHtml(state.root.shareHtml); setAnnotations([...state.root.annotations]); setGlobalAttachments([...state.root.globalAttachments]); setSelectedAnnotationId(state.root.selectedAnnotationId); setLinkedDoc(null); setError(null); if (state.root.annotations.length) { setTimeout(() => { viewerRef.current?.clearAllHighlights(); viewerRef.current?.applySharedAnnotations(state.root.annotations.filter(annotationOwnsHighlight)); }, HIGHLIGHT_REAPPLY_DELAY); } }, [ setMarkdown, setAnnotations, setSelectedAnnotationId, setGlobalAttachments, setRenderAs, setRawHtml, setShareHtml, viewerRef, ]); const updateStoredAnnotations = useCallback(( filepath: string, update: (annotations: Annotation[]) => Annotation[], ): boolean => { // Callers address documents by whatever spelling their list carries — the // panel's groups are normalized (forward slashes, collapsed separators) // while the cache is keyed by the raw server path. On Windows those differ // (`C:/repo/a.md` vs `C:\repo\a.md`) and an unnormalized lookup missed // every time, so a cross-file edit or delete silently did nothing. const wanted = normalizeBrowserPath(filepath); // The active document's annotations live in host state, not the cache — a // write here would be silently overwritten the next time it is cached. if (linkedDoc && wanted === normalizeBrowserPath(linkedDoc.filepath)) return false; let cacheKey: string | undefined; for (const key of docCache.current.keys()) { if (normalizeBrowserPath(key) === wanted) { cacheKey = key; break; } } const cached = cacheKey === undefined ? undefined : docCache.current.get(cacheKey); if (cached && cacheKey !== undefined) { docCache.current.set(cacheKey, { ...cached, annotations: update([...cached.annotations]) }); } else if (savedPlanState.current && sourceFilePath && wanted === normalizeBrowserPath(sourceFilePath)) { const saved = savedPlanState.current; savedPlanState.current = { ...saved, annotations: update([...saved.annotations]) }; } else { return false; } // Same accounting as activateDocument/back: everything except the document // that is active right now. let total = 0; for (const [fp, entry] of docCache.current.entries()) { if (linkedDoc && fp === linkedDoc.filepath) continue; total += entry.annotations.length + entry.globalAttachments.length; } if (linkedDoc && savedPlanState.current) { total += savedPlanState.current.annotations.length + savedPlanState.current.globalAttachments.length; } setDocAnnotationCount(total); setStoreRevision((r) => r + 1); return true; }, [linkedDoc, sourceFilePath]); const getDocAnnotations = useCallback((): Map => { const result = new Map(docCache.current); // Include stashed original-file annotations when viewing a linked doc if (linkedDoc && savedPlanState.current && sourceFilePath) { result.set(sourceFilePath, { annotations: [...savedPlanState.current.annotations], globalAttachments: [...savedPlanState.current.globalAttachments], markdown: savedPlanState.current.markdown, isConverted: !!sourceConverted, }); } if (linkedDoc) { result.set(linkedDoc.filepath, { annotations: [...annotations], globalAttachments: [...globalAttachments], markdown: getDocumentMarkdown?.(linkedDoc.filepath, linkedDoc.markdown) ?? linkedDoc.markdown, isConverted: linkedDoc.isConverted, }); } return result; // storeRevision: cross-file edits mutate docCache in place, so the // identity of this callback is what tells memoized readers to recompute. }, [linkedDoc, annotations, globalAttachments, sourceFilePath, sourceConverted, getDocumentMarkdown, storeRevision]); const getFeedbackDocuments = useCallback((): FeedbackDocuments => { const documents = new Map(docCache.current); if (linkedDoc) { documents.set(linkedDoc.filepath, { annotations: [...annotations], globalAttachments: [...globalAttachments], markdown: getDocumentMarkdown?.(linkedDoc.filepath, linkedDoc.markdown) ?? linkedDoc.markdown, isConverted: linkedDoc.isConverted, }); } // A cache entry under the root's own path is NOT the root: it is a copy // the reviewer opened from the root itself (an HTML "Home" link, or // index.html#intro from index.html — a backlink only routes through // back() while a linked doc is open). Its comments are a separate set the // counts include, so it is exported under its path, never dropped. const saved = linkedDoc ? savedPlanState.current : null; return { root: saved ? { annotations: [...saved.annotations], globalAttachments: [...saved.globalAttachments], markdown: saved.markdown, isConverted: !!sourceConverted, renderAs: saved.renderAs, } : null, documents, }; // storeRevision: same reason as getDocAnnotations above. }, [linkedDoc, annotations, globalAttachments, sourceConverted, getDocumentMarkdown, storeRevision]); return { isActive: linkedDoc !== null, filepath: linkedDoc?.filepath ?? null, error, isLoading, open, openLoaded, back, dismissError, getDocAnnotations, getFeedbackDocuments, updateStoredAnnotations, snapshotSession, restoreSession, docAnnotationCount, diffPreviousPlan: linkedDoc?.previousPlan ?? null, diffVersionInfo: linkedDoc?.versionInfo ?? null, }; }