import { IconArrowsMaximize, IconScribble, IconShape2, IconX, } from "@tabler/icons-react"; import { useEffect, useId, useMemo, useRef, useState } from "react"; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger, } from "../../components/ui/tooltip.js"; import { cn } from "../../utils.js"; import { AiEditableFieldLabel } from "../AiEditableField.js"; import { ltrCodeBlockProps } from "../code-block-direction.js"; import { defineBlock } from "../types.js"; import type { BlockReadProps, BlockEditProps, BlockRenderContext, } from "../types.js"; import { useBlockCopy } from "./block-copy.js"; import { diagramMdx, diagramSchema, type DiagramData, type DiagramEdge, type DiagramNode, } from "./diagram.config.js"; import { sanitizeDiagramHtml, sanitizeWireframeCss, scopeDesignCss, } from "./sanitize-html.js"; import { RoughOverlay, toggleWireframeStyle, useIsDark, useWireframeStyle, } from "./wireframe-kit.js"; /** * Read + Edit renderers for the shared `diagram` block — a flexible inline * architecture/code diagram. The preferred authoring path is a scoped, inert * HTML/SVG fragment that leans on `.diagram-*` primitives and `--wf-*` tokens; * a legacy positional / sequence node-graph path is kept for older/simple plans. * Lives in core so any app can register it (it originated in the plan template). * * DECOUPLING from the plan original (mirrors the sibling `wireframe.tsx` port): * - Theme: `useIsDark()` reads `document.documentElement.classList` instead of * `next-themes`; `useWireframeStyle()` reads the viewer's sketchy/clean * preference from the shared `plan-wireframe-style` localStorage key — both * from `./wireframe-kit.js`, so core stays plan-free and shadcn-free. * - HTML sanitize: the HTML/SVG fragment + CSS run through the app-injected * `ctx.sanitizeHtml` at the render point (defense-in-depth against stored * XSS). Without a sanitizer wired, the HTML path emits nothing — core never * injects unsanitized author HTML. The React-free `diagramSchema` already * rejects active markup before storage. * - The rough.js sketch overlay reuses the kit's shared `RoughOverlay`, scoped * with the diagram selector and `drawFrame={false}` (the same call the plan * `HtmlDiagram` made). The `.plan-diagram-frame` / `.diagram-*` / `data-rough` * class contract is preserved exactly so the theme-token CSS in core's * `blocks.css` styles it in any app. * * The section carries the app-neutral `an-block` class plus the legacy * `plan-block` class so plan renders byte-identically while any other app gets * the theme-token treatment. */ /* -------------------------------------------------------------------------- */ /* HTML/SVG diagram path */ /* -------------------------------------------------------------------------- */ /** The rough-overlay selector for diagram bordered boxes (mirrors the plan). */ const DIAGRAM_ROUGH_SELECTOR = "[data-rough],.diagram-panel,.diagram-node,.diagram-box,.diagram-pill,.diagram-card,[class*='card'],[class*='box'],[class*='panel'],[class*='pill'],[class*='chip'],[class*='badge'],hr"; function HtmlDiagram({ data, ctx, compact, }: { data: DiagramData; ctx: BlockRenderContext; compact?: boolean; }) { const ref = useRef(null); const isDark = useIsDark(); const preferredStyle = useWireframeStyle(); const designMode = data.renderMode === "design"; const style = designMode ? "clean" : preferredStyle; const showFrame = resolveVisualFrame(data.frame, ctx); const scopeId = useId().replace(/[^a-zA-Z0-9_-]/g, ""); // Sanitize author HTML/CSS at the render point (defense-in-depth against // stored XSS). Self-contained in core via the shared block sanitizer (DOM-based // in the browser, regex fallback on the server) so diagrams render in any app // without the host wiring a sanitizer hook. const safeHtml = useMemo(() => sanitizeDiagramHtml(data.html), [data.html]); const scopedCss = useMemo(() => { const safeCss = sanitizeWireframeCss(data.css); // Scope every author selector under this diagram instance so global // selectors (body, *, .app-shell, :root) can't escape and restyle the page. return safeCss ? scopeDesignCss(safeCss, `[data-plan-diagram-scope="${scopeId}"]`) : ""; }, [data.css, scopeId]); return (
{scopedCss && }
{data.caption && !compact && (

{data.caption}

)}
); } function resolveVisualFrame( frame: DiagramData["frame"], ctx: BlockRenderContext, ): boolean { const resolved = frame && frame !== "auto" ? frame : (ctx.visualFrame ?? "show"); return resolved !== "hide"; } /* -------------------------------------------------------------------------- */ /* Legacy node-graph paths */ /* -------------------------------------------------------------------------- */ function clampDiagramPercent(value: number) { if (!Number.isFinite(value)) return 50; return Math.max(4, Math.min(96, value)); } function hasPositionedDiagramNodes(data: DiagramData): boolean { return (data.nodes ?? []).some( (node) => typeof node.x === "number" && typeof node.y === "number", ); } function orderDiagramNodes( nodes: DiagramNode[], edges: DiagramEdge[], ): DiagramNode[] { if (nodes.length === 0) return []; const byId = new Map(nodes.map((node) => [node.id, node])); const indegree = new Map(nodes.map((node) => [node.id, 0])); for (const edge of edges) { if (byId.has(edge.from) && byId.has(edge.to)) { indegree.set(edge.to, (indegree.get(edge.to) ?? 0) + 1); } } const start = nodes.find((node) => (indegree.get(node.id) ?? 0) === 0); if (!start) return nodes; const ordered: DiagramNode[] = []; const seen = new Set(); let current: DiagramNode | undefined = start; while (current && !seen.has(current.id)) { const node: DiagramNode = current; ordered.push(node); seen.add(node.id); const next = edges.find((edge) => edge.from === node.id); current = next ? byId.get(next.to) : undefined; } for (const node of nodes) if (!seen.has(node.id)) ordered.push(node); return ordered; } function PositionedDiagram({ data, compact, markerId, direction, showFrame, }: { data: DiagramData; compact?: boolean; markerId: string; direction?: BlockRenderContext["textDirection"]; showFrame: boolean; }) { const nodes = (data.nodes ?? []).map((node) => ({ ...node, x: clampDiagramPercent(node.x ?? 50), y: clampDiagramPercent(node.y ?? 50), })); const edges = data.edges ?? []; const arrowId = `${markerId}-diagram-arrow`; const nodeWidth = compact ? 150 : 190; const rows = diagramRows(nodes, compact); const canvasHeight = diagramCanvasHeight(rows.length, compact); const canvasMinWidth = compact ? 560 : 720; const nodeWidthPct = compact ? 20 : 18; const nodeHeightPct = compact ? 14 : 18; const paddedViewBox = diagramViewBox(nodes, nodeWidthPct, nodeHeightPct); const xMargin = compact ? 13 : 14; const yMargin = compact ? 15 : 18; const displayNodes = nodes.map((node) => ({ ...node, displayXBase: diagramPointPercent( node.x, paddedViewBox.x, paddedViewBox.width, xMargin, ), displayY: diagramRowPointPercent(node.y, rows, paddedViewBox, yMargin), })); const displayNodesForDirection = displayNodes.map((node) => ({ ...node, displayX: direction === "rtl" ? 100 - node.displayXBase : node.displayXBase, })); const nodeByIdForDirection = new Map( displayNodesForDirection.map((node) => [node.id, node]), ); return (
{!compact && edges.map((edge, index) => { const from = nodeByIdForDirection.get(edge.from); const to = nodeByIdForDirection.get(edge.to); if (!edge.label || !from || !to) return null; return ( {edge.label} ); })} {displayNodesForDirection.map((node, index) => (

{index + 1}

{node.label}

{node.detail && !compact && (

{node.detail}

)}
))}
{data.notes && data.notes.length > 0 && !compact && (
{data.notes.map((note) => (

{note.text}

))}
)}
); } type DiagramViewBox = { x: number; y: number; width: number; height: number; }; function diagramViewBox( nodes: Array>, nodeWidthPct: number, nodeHeightPct: number, ): DiagramViewBox { if (nodes.length === 0) return { x: 0, y: 0, width: 100, height: 100 }; const halfWidth = nodeWidthPct / 2; const halfHeight = nodeHeightPct / 2; const left = Math.min(...nodes.map((node) => (node.x ?? 50) - halfWidth)); const right = Math.max(...nodes.map((node) => (node.x ?? 50) + halfWidth)); const top = Math.min(...nodes.map((node) => (node.y ?? 50) - halfHeight)); const bottom = Math.max(...nodes.map((node) => (node.y ?? 50) + halfHeight)); const x = Math.min(0, left); const y = Math.min(0, top); const width = Math.max(100, right) - x; const height = Math.max(100, bottom) - y; return { x, y, width, height }; } function diagramPointPercent( value: number, viewBoxStart: number, viewBoxSize: number, margin = 0, ): number { if ( !Number.isFinite(value) || !Number.isFinite(viewBoxSize) || !viewBoxSize ) { return 50; } const percent = ((value - viewBoxStart) / viewBoxSize) * 100; return Math.min(100 - margin, Math.max(margin, percent)); } function diagramRows( nodes: Array>, compact?: boolean, ): number[] { const rowGap = compact ? 8 : 10; return [...nodes] .sort((a, b) => (a.y ?? 50) - (b.y ?? 50)) .reduce((acc, node) => { const y = node.y ?? 50; const last = acc[acc.length - 1]; if (last == null || Math.abs(y - last) >= rowGap) acc.push(y); return acc; }, []); } function diagramRowPointPercent( value: number, rows: number[], viewBox: DiagramViewBox, margin: number, ): number { if (rows.length <= 2) { return diagramPointPercent(value, viewBox.y, viewBox.height, margin); } const closestIndex = rows.reduce((bestIndex, row, index) => { const bestDistance = Math.abs((rows[bestIndex] ?? 50) - value); return Math.abs(row - value) < bestDistance ? index : bestIndex; }, 0); const span = 100 - margin * 2; return margin + (span * closestIndex) / Math.max(1, rows.length - 1); } function diagramCanvasHeight(rows: number, compact?: boolean): number { const base = compact ? 280 : 430; if (rows <= 2) return base; return Math.max(base, rows * (compact ? 140 : 190) + (compact ? 80 : 120)); } function SequenceDiagram({ data, compact, direction, showFrame, }: { data: DiagramData; compact?: boolean; direction?: BlockRenderContext["textDirection"]; showFrame: boolean; }) { const edges = data.edges ?? []; const nodes = orderDiagramNodes(data.nodes ?? [], edges); if (nodes.length === 0) { return (
Diagram content is empty.
); } const visualNodes = direction === "rtl" ? [...nodes].reverse() : nodes; return (
{visualNodes.map((node, index) => { const next = visualNodes[index + 1]; const edge = next ? edges.find( (candidate) => (candidate.from === node.id && candidate.to === next.id) || (candidate.from === next.id && candidate.to === node.id), ) : undefined; return (

{index + 1}

{node.label}

{node.detail && !compact && (

{node.detail}

)}
{next && (
{edge?.label && ( {edge.label} )}
)}
); })}
{data.notes && data.notes.length > 0 && !compact && (
{data.notes.map((note) => (

{note.text}

))}
)}
); } /** * The diagram body. Routes to the preferred HTML/SVG path (when `data.html` is * set) and otherwise to a legacy node-graph path (positioned canvas when nodes * carry x/y, else an ordered sequence). Used both inline and, scaled up, inside * the expand lightbox — so every variant (html/css, positioned, sequence) * enlarges through the same code path. */ function DiagramBody({ data, ctx, compact, }: { data: DiagramData; ctx: BlockRenderContext; compact?: boolean; }) { const markerId = useId().replace(/:/g, ""); const showFrame = resolveVisualFrame(data.frame, ctx); if (data.html?.trim()) { return ; } if (hasPositionedDiagramNodes(data)) { return ( ); } return ( ); } /* -------------------------------------------------------------------------- */ /* Expand / lightbox */ /* -------------------------------------------------------------------------- */ /** * Enlarge overlay for a rendered diagram. Mirrors the image lightbox contract * used by the composer's `ImagePreviewLightbox` (PromptComposer.tsx) so the * expand affordance feels identical to viewing an image full-size: a fixed * `bg-black/80` backdrop, Escape to close, click-the-backdrop to close, and a * top-right close button. Unlike the image variant this renders arbitrary * children (the diagram body re-rendered larger) rather than an ``, since a * diagram is live HTML/SVG/node-graph markup, not an image URL. * * Exported so the separate Mermaid block (`MermaidBlock.tsx`, which renders its * diagram to an SVG through a different runtime) can reuse the exact same * lightbox contract — one expand affordance shared across both diagram types. */ export function DiagramLightbox({ children, onClose, }: { children: React.ReactNode; onClose: () => void; }) { useEffect(() => { const handler = (e: KeyboardEvent) => { if (e.key === "Escape") onClose(); }; document.addEventListener("keydown", handler); document.body.style.overflow = "hidden"; return () => { document.removeEventListener("keydown", handler); document.body.style.overflow = ""; }; }, [onClose]); return (
e.stopPropagation()} className="max-h-full w-full max-w-5xl cursor-default overflow-auto rounded-md bg-background p-6 shadow-2xl" > {children}
); } /** * The diagram body plus a hover-revealed top-right "expand" button (like the * image attachment zoom). Opening the button re-renders the exact same * `DiagramBody` inside `DiagramLightbox` at a larger size, so html/css and * mermaid/legacy node-graph diagrams alike enlarge through one path. The inline * (non-expanded) render is otherwise unchanged. */ function ExpandableDiagramBody({ data, ctx, compact, }: { data: DiagramData; ctx: BlockRenderContext; compact?: boolean; }) { const [expanded, setExpanded] = useState(false); const designMode = data.renderMode === "design"; const supportsStyleToggle = Boolean(data.html) && !designMode; const style = useWireframeStyle(); const copy = useBlockCopy(); const sketchy = style === "sketchy"; const styleLabel = sketchy ? copy.switchToCleanDiagrams : copy.switchToHandDrawnDiagrams; const styleTooltip = sketchy ? copy.handDrawnDiagramsSwitch : copy.cleanDiagramsSwitch; return (
{supportsStyleToggle && ( {styleTooltip} )} {copy.expandDiagram}
{expanded ? ( setExpanded(false)}> ) : null}
); } /* -------------------------------------------------------------------------- */ /* Read + Edit */ /* -------------------------------------------------------------------------- */ /** Read-only renderer: the diagram body wrapped in the standard titled block. */ export function DiagramRead({ data, blockId, title, summary, ctx, compactVisuals, }: BlockReadProps) { return (
{title &&
{title}
} {summary &&

{summary}

}
); } /** * Edit form (panel surface). The block can be an HTML/SVG fragment or a legacy * node/edge/note graph, so this exposes html/css/caption plus a collapsible * legacy node-graph JSON editor, each with an AI-edit affordance via * `ctx.renderAiFieldAction` (through `AiEditableFieldLabel`). `editSurface: * "panel"` means the registry renders the `Read` view with a corner edit button * that opens this form in the app-provided popover. */ export function DiagramEdit({ data, onChange, editable, blockId, title, summary, ctx, }: BlockEditProps) { const htmlId = useId(); const cssId = useId(); const captionId = useId(); const legacyId = useId(); const [html, setHtml] = useState(data.html ?? ""); const [css, setCss] = useState(data.css ?? ""); const [caption, setCaption] = useState(data.caption ?? ""); const [legacyJson, setLegacyJson] = useState(() => JSON.stringify( { nodes: data.nodes ?? [], edges: data.edges ?? [], notes: data.notes ?? [], }, null, 2, ), ); useEffect(() => { setHtml(data.html ?? ""); setCss(data.css ?? ""); setCaption(data.caption ?? ""); setLegacyJson( JSON.stringify( { nodes: data.nodes ?? [], edges: data.edges ?? [], notes: data.notes ?? [], }, null, 2, ), ); }, [data]); const saveHtmlDiagram = () => { onChange({ html: html.trim() || undefined, css: css.trim() || undefined, renderMode: data.renderMode, caption: caption.trim() || undefined, frame: data.frame, nodes: data.nodes, edges: data.edges, notes: data.notes, }); }; const saveLegacyDiagram = () => { const parsed = JSON.parse(legacyJson) as Pick< DiagramData, "nodes" | "edges" | "notes" >; onChange({ ...data, nodes: parsed.nodes ?? [], edges: parsed.edges ?? [], notes: parsed.notes ?? [], }); }; const fieldAction = ( field: "HTML / SVG fragment" | "CSS" | "caption" | "legacy node graph JSON", value: string, ) => ({ blockId, blockType: "diagram", blockTitle: title, blockSummary: summary, fieldValue: value, draftScope: `block:diagram:${blockId}:${field.toLowerCase().replace(/[^a-z0-9]+/g, "-")}`, disabled: !editable, instructions: "Update the plan with update-visual-plan using a targeted update-block content patch for this diagram block id. Preserve unrelated diagram fields unless the requested edit requires changing them. Keep diagram HTML/CSS on renderer-owned .diagram-* primitives and --wf-* tokens; do not introduce custom font-family or hard-coded hex/rgb/hsl colors.", companionFields: [ { label: "HTML / SVG fragment", value: html.trim() || "(empty)", language: "html", }, { label: "CSS", value: css.trim() || "(empty)", language: "css" }, { label: "caption", value: caption.trim() || "(empty)", language: "text", }, ], }); return (