import { lazy, Suspense, useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from 'react'; import { buildDiagramAnchorValue, type DiagramAnchor, type DiagramKind, type DiagramTarget } from '@plannotator/core/diagram-anchor'; import { cn } from '../../lib/utils'; import { diagramFamilyOf } from '../../utils/diagram-anchor'; import { diagramFinder, type DiagramTheme } from '../../utils/diagram-render'; import { DiagramCanvas, type DiagramCanvasHandle, type DiagramEscapeOutcome } from './DiagramCanvas'; import { DiagramComposer } from './DiagramComposer'; import { DiagramOverlay } from './DiagramOverlay'; import { useDiagramComments, type DiagramComment, type DiagramCreateComment } from './useDiagramComments'; import { useDiagramRender, type DiagramRenderState } from './useDiagramRender'; import { useDiagramSourceDraft, type SaveResult } from './useDiagramSourceDraft'; /** * The one diagram viewer: the canvas with its overlay, the Source pane on * its LEFT (stacked under it on the phone) when the host can save, the * parse-error strip over a dimmed last-good render. It takes the diagram * source and the host's comments on it, and reports a new comment through * `onCreateComment`; the comments rail, storage and identity are the * host's. Plannotator's fence blocks render through it in the document * (`components/DiagramBlock`) and again at full size in the popout; a host * with its own document store renders it wherever a diagram lives. */ /** * The Source pane is CodeMirror, and a viewer that can never open one (every * fence in a Plannotator document today: no host passes `onSave`) must not * carry it. It loads on the first open of the pane and never before, so the * editor stays out of the document-read closure of a chunked host. */ const DiagramSourcePane = lazy(async () => ({ default: (await import('./DiagramSourcePane')).DiagramSourcePane })); /** The pane's box while its chunk loads: same class list, so the split it * opens into is already the right size and nothing jumps when it lands. * Never `null` here — that would collapse the row back onto the canvas. */ const PANE_CLASS = 'order-last min-h-0 shrink-0 basis-2/5 border-t border-border md:order-first md:w-80 md:basis-auto md:border-r md:border-t-0'; /** A question typed in the composer, asked about the part instead of saved * as a comment. The anchor is the one a comment there would carry (its * `sourceLine` already in document lines). Resolve false (or throw) to keep * the draft open; anything else closes it. */ export type DiagramAskAI = ( question: string, anchor: DiagramAnchor, additionalTargets: readonly DiagramTarget[], ) => boolean | void | Promise; /** A part chosen for a comment the host composes (`onHostDraft`). */ export interface DiagramHostDraft { /** The anchor a comment there carries (its `sourceLine` in document lines). */ readonly anchor: DiagramAnchor; /** Drop the draft: the part's ring goes. A draft already replaced ignores it. */ readonly cancel: () => void; } export interface DiagramViewerProps { readonly kind: DiagramKind; /** The diagram text. With `onSave` this is the saved baseline the pane's * draft starts from; without it, what the canvas renders. */ readonly source: string; readonly theme: DiagramTheme; readonly comments: readonly DiagramComment[]; /** A comment composed on a part. Absent: clicks open nothing. */ readonly onCreateComment?: DiagramCreateComment; /** Opt-in host capability: the composer handed to the host. A click on a * part reports it here (null when the draft closes) and no composer is * drawn; the host saves the comment and passes it back in `comments`. * Absent: unchanged. */ readonly onHostDraft?: (draft: DiagramHostDraft | null) => void; /** Ask AI from the composer. Absent: the composer offers no Ask AI. */ readonly onAskAI?: DiagramAskAI; /** Save the pane's text. Absent: there is no Source pane. */ readonly onSave?: (source: string) => Promise; /** With `onSave`: show the pane read-only (no edit access). */ readonly readOnlySource?: boolean; /** Whether the Source pane is open (the host owns the toggle). */ readonly sourceOpen?: boolean; readonly selectedCommentId?: string | null; readonly onSelectComment?: (id: string | null) => void; /** The comments whose part is gone from the current render, once per * membership change, after every render. */ readonly onUnanchoredChange?: (ids: ReadonlySet) => void; /** Every comment's verdict (true: its part is in this render), whenever * a verdict or the comment list changes. */ readonly onResolutionChange?: (resolution: ReadonlyMap) => void; /** Escape with no composer open and nothing selected: a popout closes. */ readonly onDismiss?: () => void; /** A stable prefix for the rendered element ids; two viewers over one * document need two. */ readonly renderId?: string; /** Lines to add so a comment's `sourceLine` names DOCUMENT lines: 0 when * the document is the diagram, the fence's opening line for a fence. */ readonly sourceLineOffset?: number; /** Shift-click targets one comment may also cover. Default 0: a comment * covers one part. */ readonly maxAdditionalTargets?: number; /** Commenting is off, and this is why (shown in place of the composer). */ readonly commentingDisabledReason?: string; /** Bumped by the host's Retry after a failed engine load. */ readonly retryToken?: number; /** Every render state change (the host sizes itself, shows its own * pending or error chrome). */ readonly onRenderState?: (state: DiagramRenderState) => void; /** What to show while there is no svg yet (the first render pending, or * the first render failed). Default: a quiet status line. */ readonly renderFallback?: (state: DiagramRenderState) => ReactNode; /** Take the keyboard on mount (a popout). */ readonly autoFocus?: boolean; readonly className?: string; /** Classes for the canvas host. The canvas lets a finger scroll the page * past it (`touch-action: pan-y`); a viewer that owns the whole screen * passes `touch-none`. */ readonly canvasClassName?: string; } export function DiagramViewer({ kind, source, theme, comments, onCreateComment, onHostDraft, onAskAI, onSave, readOnlySource = false, sourceOpen = false, selectedCommentId = null, onSelectComment, onUnanchoredChange, onResolutionChange, onDismiss, renderId = 'diagram', sourceLineOffset = 0, maxAdditionalTargets = 0, commentingDisabledReason, retryToken, onRenderState, renderFallback, autoFocus, className, canvasClassName, }: DiagramViewerProps) { const finder = diagramFinder(kind); const familyOf = useCallback((svg: Element) => (kind === 'graphviz' ? ('graphviz' as const) : diagramFamilyOf(svg)), [kind]); const hasPane = onSave !== undefined; const editable = hasPane && !readOnlySource; const draft = useDiagramSourceDraft({ source, editable, onSave }); const rendered = hasPane ? draft.preview : source; const savedSource = hasPane ? draft.baseline : source; const sourceDirty = hasPane && draft.dirty; const render = useDiagramRender(kind, renderId, rendered, theme, { retryToken }); useEffect(() => { onRenderState?.(render); }, [onRenderState, render]); const [svgRoot, setSvgRoot] = useState(null); const onSvgRoot = useCallback((root: SVGSVGElement | null) => setSvgRoot(root), []); const canCreate = onCreateComment !== undefined || commentingDisabledReason !== undefined || onHostDraft !== undefined; const commentsState = useDiagramComments({ finder, svgRoot, renderId: render.renderId, renderVersion: render.renderVersion, comments, savedSource, sourceLineOffset, sourceDirty, maxAdditionalTargets, canCreate, onCreateComment, onSelectComment, onUnanchoredChange, onResolutionChange, familyOf, }); // The selected comment's source line for the pane's gutter mark. The // anchor names DOCUMENT lines; the pane shows the diagram text. const markedLines = useMemo(() => { if (selectedCommentId === null) return null; const line = comments.find((entry) => entry.id === selectedCommentId)?.anchor.sourceLine; if (line === null || line === undefined) return null; return [line[0] - sourceLineOffset, line[1] - sourceLineOffset] as const; }, [comments, selectedCommentId, sourceLineOffset]); const onEscape = useCallback((): DiagramEscapeOutcome => { if (commentsState.composer !== null) { commentsState.cancel(); return 'consumed'; } if (selectedCommentId !== null && onSelectComment !== undefined) { onSelectComment(null); return 'consumed'; } onDismiss?.(); return 'pass'; }, [commentsState, onDismiss, onSelectComment, selectedCommentId]); // Closing the pane only closes an open composer so the layout shift does // not strand it; the draft survives a reopen. const cancelComposer = commentsState.cancel; useEffect(() => { if (!sourceOpen) cancelComposer(); }, [cancelComposer, sourceOpen]); // The composer handed to the host: the draft's part goes out, and the // host's cancel closes it only while it is still the open one. const onHostDraftRef = useRef(onHostDraft); onHostDraftRef.current = onHostDraft; const openComposer = commentsState.composer; const openComposerRef = useRef(openComposer); openComposerRef.current = openComposer; const hostComposes = onHostDraft !== undefined; useEffect(() => { const report = onHostDraftRef.current; if (!report) return; if (openComposer === null) { report(null); return; } report({ anchor: buildDiagramAnchorValue(openComposer.primary.target, openComposer.sourceLine), cancel: () => { if (openComposerRef.current === openComposer) cancelComposer(); }, }); }, [cancelComposer, hostComposes, openComposer]); // Ask AI asks about the part the draft is on; the host's handler decides // whether the question was taken, and a taken question closes the draft // exactly as the markdown composer closes. const composerDraft = commentsState.composer; const askFromComposer = useMemo(() => { if (onAskAI === undefined || composerDraft === null || onCreateComment === undefined) return undefined; return async (question: string): Promise => { const anchor = buildDiagramAnchorValue(composerDraft.primary.target, composerDraft.sourceLine); const accepted = await onAskAI(question, anchor, composerDraft.additional.map((extra) => extra.target)); if (accepted === false) return false; cancelComposer(); return true; }; }, [cancelComposer, composerDraft, onAskAI, onCreateComment]); const overlay = useCallback( (handle: DiagramCanvasHandle) => ( commentsState.composer === null || hostComposes ? null : ( void commentsState.submit(text)} onCancel={commentsState.cancel} onAskAI={askFromComposer} /> ) } /> ), [askFromComposer, commentingDisabledReason, commentsState, hostComposes, onSelectComment, selectedCommentId, sourceDirty], ); const showFallback = render.svgNode === null; return ( // `annotation-exclude`: the document's text highlighter never enters a // diagram, so a text restore can never wrap a inside the svg.
{/* The pane comes FIRST in the row (owner ruling: the source sits on the left of the diagram while editing). On the phone the row is a column and the pane stays stacked UNDER the canvas, which `order-last` keeps while `md:order-first` puts it left from `md`. */} {hasPane && sourceOpen && ( )}
{showFallback ? (
{renderFallback !== undefined ? ( renderFallback(render) ) : (
{render.error !== null ? `The diagram does not parse${render.error.line === null ? '' : ` (line ${render.error.line})`}: ${render.error.message}` : render.pending ? 'Rendering diagram…' : 'Nothing to render yet.'}
)}
) : ( {render.error !== null && (
{render.error.line === null ? 'The diagram does not parse' : `The diagram does not parse (line ${render.error.line})`}
{render.error.message} {' The last good render stays below.'}
)} {/* The chip earns its place only while the pane is closed (the draft survives a close); with the pane open its header carries the one "Draft · unsaved". */} {editable && draft.dirty && !sourceOpen && ( Draft preview · unsaved )}
)}
); }