import React, { lazy, Suspense, useCallback, useContext, useEffect, useMemo, useRef, useState, useSyncExternalStore } from 'react'; import { diagramTargetText, type DiagramKind } from '@plannotator/core/diagram-anchor'; import type { AnnotationRestoreReport } from '../hooks/useAnnotationHighlighter'; import { AnnotationType, type Annotation, type Block } from '../types'; import { useConfigValue } from '../config'; import type { DiagramTheme } from '../utils/diagram-render'; import { diagramShadowAmount } from '../utils/diagramShadow'; import { getIdentity } from '../utils/identity'; import { createRuntimeRetryEpoch } from '../utils/runtimeRetry'; import { DiagramAnchorClaims, DiagramAnchorClaimsContext } from './diagram/anchorClaims'; import { DiagramPending, DiagramInlineSource } from './diagram/DiagramPending'; import { DiagramViewer, type DiagramAskAI } from './diagram/DiagramViewer'; import type { CommentAskAIHandler } from './CommentPopover'; import { diagramAskAIContext } from '../utils/diagramAskAI'; import { svgContentSize } from './diagram/svgContentSize'; import type { DiagramComment, DiagramCreateComment } from './diagram/useDiagramComments'; import type { DiagramRenderState } from './diagram/useDiagramRender'; import { useTheme } from './ThemeProvider'; /** * A diagram fence in the document: the fence's language picks the engine * (`MermaidBlock`, `GraphvizBlock`), and everything else is one code path * through the renderer slot and `DiagramViewer` — the canvas with zoom, pan * and fit, the comment overlay, and the same viewer at full size in the * popout. What this block owns is the document side: the source fence under * a status until the first render lands (never the error panel as a * placeholder), the error panel with the source and a Retry for a failed * engine load, the "Show source" toggle, and the bridge between a comment * composed on a part and an `Annotation` on the document (`diagramAnchor` * plus the fence's document lines), so it lists in the rail beside text * comments, exports, drafts and restores after a reload. */ /** One Retry re-attempts every block whose engine import failed (see utils/runtimeRetry). */ const RETRY_EPOCHS: Record> = { mermaid: createRuntimeRetryEpoch(), graphviz: createRuntimeRetryEpoch(), }; const LABELS: Record = { mermaid: 'Mermaid', graphviz: 'Graphviz' }; /** The full-size popout is only ever reached by pressing Expand, so it loads * then and not with the document. Its fallback is null: the overlay simply * has not opened yet, and nothing in the document flow moves. */ const DiagramPopout = lazy(async () => ({ default: (await import('./diagram/DiagramPopout')).DiagramPopout })); /** The inline box height from the diagram's aspect at a nominal width, so * a wide flowchart is not letterboxed in a tall box and a tall state * diagram is not squeezed into a short one; clamped so neither extreme * takes the page. The canvas fits the diagram inside whatever it gets. */ const NOMINAL_WIDTH_PX = 800; const MIN_HEIGHT_PX = 16 * 16; const MAX_HEIGHT_PX = 36 * 16; function inlineHeight(state: DiagramRenderState | null): string { const size = state?.svgNode ? svgContentSize(state.svgNode) : null; if (size === null) return 'min(65vh, 24rem)'; const px = Math.round(size.height * (NOMINAL_WIDTH_PX / size.width)) + 48; return `min(65vh, ${Math.min(MAX_HEIGHT_PX, Math.max(MIN_HEIGHT_PX, px))}px)`; } function newAnnotationId(): string { const c = globalThis.crypto; if (c && typeof c.randomUUID === 'function') return c.randomUUID(); return `ann-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`; } export interface DiagramBlockProps { block: Block; /** The document's annotations; the block keeps the ones anchored on this * fence (`diagramAnchor` + its `blockId`). */ annotations?: readonly Annotation[]; selectedAnnotationId?: string | null; onSelectAnnotation?: (id: string | null) => void; /** A comment composed on a part becomes an annotation on the document. */ onAddAnnotation?: (annotation: Annotation) => void; readOnly?: boolean; /** The block's restore verdict after every render: its own comments as * `attempted`, the ones whose part is gone as `unanchored`, so the host's * panel shows the same "Unanchored" chip a text comment gets. */ onRestoreReport?: (report: AnnotationRestoreReport) => void; /** The document's Ask AI handler (the one the markdown composer takes). * With it the diagram composer offers "Ask AI", and the question carries * the part's identity and the bounded diagram source as `detail`. Absent: * no Ask AI, exactly as before. */ onAskAI?: CommentAskAIHandler; /** The document's path, sent with an Ask AI question (`sourcePath`). */ askAISourcePath?: string; } const NO_ANNOTATIONS: readonly Annotation[] = []; export const DiagramBlock: React.FC = ({ kind, block, annotations = NO_ANNOTATIONS, selectedAnnotationId = null, onSelectAnnotation, onAddAnnotation, readOnly = false, onRestoreReport, onAskAI, askAISourcePath, }) => { const label = LABELS[kind]; const rootRef = useRef(null); const [showSource, setShowSource] = useState(false); const [isExpanded, setIsExpanded] = useState(false); const [retryToken, setRetryToken] = useState(0); const [renderState, setRenderState] = useState(null); // The (palette, mode) the diagram must follow: the same resolution the // code fences use (see useFenceTheme). Outside a ThemeProvider the default // context yields the Plannotator dark pair, and with no theme tokens on the // document the renderer keeps the static config, so a host without the // provider renders exactly as before. A key change re-runs the render, // which is what re-themes an already rendered diagram. const { colorTheme, resolvedMode } = useTheme(); // The node shadow reaches the renderer the same way the palette does: as // part of the theme key, so changing it re-initializes Mermaid and re-renders // every mounted diagram. const diagramShadow = useConfigValue('diagramShadow'); const theme = useMemo( () => ({ colorTheme, mode: resolvedMode === 'light' ? 'light' : 'dark', shadowAmount: diagramShadowAmount(diagramShadow), }), [colorTheme, resolvedMode, diagramShadow], ); // A sibling's Retry re-attempts this block too, but only while its own // failure was the shared engine import; a healthy block or a diagram // syntax error is left alone. const runtimeUnavailableRef = useRef(false); runtimeUnavailableRef.current = renderState?.error?.runtimeUnavailable ?? false; useEffect( () => RETRY_EPOCHS[kind].subscribe(() => { if (!runtimeUnavailableRef.current) return; setRetryToken((token) => token + 1); }), [kind], ); useEffect(() => { setIsExpanded(false); }, [block.content]); // Comments that name no diagram block of the document (an external POST // carries `blockId: "external"`; a deleted fence leaves a dead id) belong // to whichever diagram resolves their anchor first: see anchorClaims. const sharedClaims = useContext(DiagramAnchorClaimsContext); const ownClaims = useMemo(() => new DiagramAnchorClaims([block.id]), [block.id]); const claims = sharedClaims ?? ownClaims; const claimsVersion = useSyncExternalStore(claims.subscribe, claims.getVersion, claims.getVersion); // `diagramAnchor` is read defensively everywhere: a row can reach the // renderer from any local ingest (the external-annotations API, a draft, a // share link), so a nullish or malformed anchor must list as unanchored, // never take the page down with a property read (`null.family`). const ownAnnotations = useMemo( () => annotations.filter((ann) => ann.diagramAnchor != null && ann.blockId === block.id), [annotations, block.id], ); const unownedAnnotations = useMemo( () => annotations.filter( (ann) => ann.diagramAnchor != null && ann.blockId !== block.id && !claims.blockIds.includes(ann.blockId) && // A Graphviz anchor names a DOT part; it is never a Mermaid one. (ann.diagramAnchor?.family === 'graphviz') === (kind === 'graphviz'), ), [annotations, block.id, claims, kind], ); // The comments on this fence, in document order (the badge numbers): its // own, then the unowned ones it shows or is still trying. const comments = useMemo(() => { void claimsVersion; const tried = unownedAnnotations.filter((ann) => { const owner = claims.owner(ann.id); return owner === undefined || owner === block.id; }); return [...ownAnnotations, ...tried].map((ann) => ({ id: ann.id, anchor: ann.diagramAnchor!, text: ann.text ?? '', author: ann.author, })); }, [block.id, claims, claimsVersion, ownAnnotations, unownedAnnotations]); const selectedCommentId = useMemo( () => (selectedAnnotationId !== null && comments.some((c) => c.id === selectedAnnotationId) ? selectedAnnotationId : null), [comments, selectedAnnotationId], ); useEffect(() => { if (selectedCommentId === null) return; rootRef.current?.scrollIntoView?.({ block: 'nearest' }); }, [selectedCommentId]); const handleCreate = useMemo(() => { if (readOnly || onAddAnnotation === undefined) return undefined; return (anchor, text) => { onAddAnnotation({ id: newAnnotationId(), blockId: block.id, startOffset: 0, endOffset: 0, type: AnnotationType.COMMENT, text, originalText: diagramTargetText(anchor), createdA: Date.now(), author: getIdentity(), diagramAnchor: anchor, }); }; }, [block.id, onAddAnnotation, readOnly]); // A fence's offset is its own opening line; a whole-file diagram source // overrides it with 0 (see Block.diagramSourceLineOffset). const sourceLineOffset = block.diagramSourceLineOffset ?? block.startLine; const handleAskAI = useMemo(() => { if (readOnly || onAskAI === undefined) return undefined; return (question, anchor, additionalTargets) => onAskAI( question, diagramAskAIContext({ kind, anchor, additionalTargets, source: block.content, sourceLineOffset, sourcePath: askAISourcePath, }), ); }, [askAISourcePath, block.content, kind, onAskAI, readOnly, sourceLineOffset]); // The popout covers the panel the answer streams into, so a question // asked there closes it once the host takes the question. const handlePopoutAskAI = useMemo(() => { if (handleAskAI === undefined) return undefined; return async (question, anchor, additionalTargets) => { const accepted = await handleAskAI(question, anchor, additionalTargets); if (accepted !== false) setIsExpanded(false); return accepted; }; }, [handleAskAI]); const [resolution, setResolution] = useState | null>(null); const svgReady = renderState?.svgNode != null; const renderFailed = renderState !== null && renderState.error !== null && !svgReady; // This block's verdicts for the unowned comments. A diagram that failed // to render resolves nothing, and must say so or the verdict stays // pending forever. useEffect(() => { if (unownedAnnotations.length === 0) return; const results = new Map(); for (const ann of unownedAnnotations) { if (renderFailed) results.set(ann.id, false); else if (resolution?.has(ann.id)) results.set(ann.id, resolution.get(ann.id) === true); } if (results.size > 0) claims.report(block.id, results); }, [block.id, claims, renderFailed, resolution, unownedAnnotations]); // The restore verdict the panel's "Unanchored" chip runs on: this block's // own comments, the unowned ones it shows, and (from the first diagram // block only, so it is said once) the unowned ones nobody resolved. useEffect(() => { if (onRestoreReport === undefined || (resolution === null && !renderFailed)) return; const attempted: string[] = []; const unanchored: string[] = []; for (const ann of ownAnnotations) { attempted.push(ann.id); if (renderFailed || resolution?.get(ann.id) === false) unanchored.push(ann.id); } for (const ann of unownedAnnotations) { const owner = claims.owner(ann.id); if (owner === block.id) attempted.push(ann.id); else if (owner === null && claims.blockIds[0] === block.id) { attempted.push(ann.id); unanchored.push(ann.id); } } if (attempted.length > 0) onRestoreReport({ attempted, unanchored }); }, [block.id, claims, claimsVersion, onRestoreReport, ownAnnotations, renderFailed, resolution, unownedAnnotations]); const renderFallback = useCallback( (state: DiagramRenderState) => { if (state.error !== null) { return (
{label} Error {state.error.runtimeUnavailable && ( )}
{state.error.message}
              {block.content}
            
); } // First render still in flight (the engine import on the lazy path, // then the render itself): the source stays readable under a quiet // status line. A re-render for a theme change keeps the previous SVG, // so this shows only before the first diagram lands. return ; }, [block, kind, label], ); const viewerProps = { kind, source: block.content, theme, comments, onCreateComment: handleCreate, selectedCommentId, onSelectComment: onSelectAnnotation, sourceLineOffset, retryToken, }; return ( <> {/* `annotation-exclude`: the text highlighter never enters a diagram, so a text restore (a reply that lost its anchor, a quote that also appears in a node label) can never wrap a inside the svg. */}
{svgReady && !showSource && (
)} {showSource ? (
) : (
)}
{isExpanded && svgReady && typeof document !== 'undefined' && ( setIsExpanded(false)} title={`${label} diagram`} renderId={`${kind}-${block.id}-popout`} onAskAI={handlePopoutAskAI} dataAttributes={{ 'data-block-id': block.id }} /> )} ); };