"use client"; import { createContext, useContext, useEffect, useMemo, useRef, useState } from "react"; import { MaximizeIcon, MinusIcon, PlusIcon, XIcon } from "lucide-react"; import { cn } from "@/lib/utils"; import { fitGraphView, fitZoomFor, zoomedGraphView, type Viewport } from "@/components/pan-zoom"; import { renderMermaid } from "@/components/mermaid-render"; import { MermaidError } from "@/components/mermaid-error"; import { Hint } from "@/components/ui/hint"; /** * SESSION-SCOPED OVERLAYS (owner wave, 2026-09-19): the "fullscreen" * expansions — the plan focus, this mermaid focus — do NOT cover the app * chrome. They mount ABSOLUTE INSIDE THE SESSION AREA (the `relative` div * below the top bar, right of the sidebar, where the transcript and dock * live), so the 60px drag bar stays sharp, visible, and draggable and the X * can never land in drag territory — the old `fixed inset-0` overlays put it * exactly there, which is what drove this redesign. Surface: 80% background + * blur (user-decided) — the transcript ghosts through while the focus owns * the session area. * * The chart source registers from wherever it lives in the transcript * (md-blocks' expand button) via the context below; the CHAT PAGE owns the * open state so this overlay mounts as a sibling of the scroller inside the * session area — never inside it (an absolute inset-0 inside the scroller * would cover the scrollable extent, not the visible area). */ /** The expand seam: md-blocks' mermaid hover button calls openMermaid with * the chart source; the chat page (which provides this context) opens the * overlay. Null when no chat page is mounted (sidebar previews etc.) — the * expand button simply doesn't render there. */ export const SessionMermaidContext = createContext<{ openMermaid: (chart: string) => void } | null>(null); export function useSessionMermaid() { return useContext(SessionMermaidContext); } /** A chart svg's natural size — mermaid always emits a viewBox; this is the * extent at zoom 1 (1 svg unit = 1 px), like the plan diagram's natural * size. The fallback covers a degenerate no-viewBox svg. Pure. */ export function chartSize(svg: string): { w: number; h: number } { const m = /]*\bviewBox="([\d.eE+-]+)\s+([\d.eE+-]+)\s+([\d.eE+-]+)\s+([\d.eE+-]+)"/.exec(svg); if (m) { const w = Number.parseFloat(m[3]!); const h = Number.parseFloat(m[4]!); if (Number.isFinite(w) && Number.isFinite(h) && w > 0 && h > 0) return { w, h }; } return { w: 600, h: 400 }; } /** The mermaid focus: the chart at natural size inside a pan/zoom viewport * (the plan popup's fit-floor semantics — fitGraphView/zoomedGraphView in * pan-zoom.ts), Esc/X close. Renders its own copy of the chart (mermaid * ids are per-render), not the injected inline svg. */ export function MermaidOverlay({ chart, onClose }: { chart: string; onClose: () => void }) { // ── chart render (own instance, own ids) ── const [svg, setSvg] = useState(null); const [error, setError] = useState(null); const [attempt, setAttempt] = useState(0); useEffect(() => { let cancelled = false; setSvg(null); setError(null); renderMermaid(chart) .then((s) => { if (!cancelled) setSvg(s); }) .catch((e) => { if (!cancelled) setError(e instanceof Error ? e.message : String(e)); }); return () => { cancelled = true; }; }, [chart, attempt]); // Esc closes. useEffect(() => { const onKey = (e: KeyboardEvent) => { if (e.key === "Escape") onClose(); }; window.addEventListener("keydown", onKey); return () => window.removeEventListener("keydown", onKey); }, [onClose]); // ── pan/zoom viewport (same wiring as the plan popup) ── const viewportRef = useRef(null); const [size, setSize] = useState({ w: 0, h: 0 }); useEffect(() => { const el = viewportRef.current; if (!el || typeof ResizeObserver === "undefined") return; const ro = new ResizeObserver(() => setSize({ w: el.clientWidth, h: el.clientHeight })); ro.observe(el); return () => ro.disconnect(); }, []); const box = useMemo(() => (svg ? chartSize(svg) : { w: 0, h: 0 }), [svg]); const fitZoom = useMemo(() => fitZoomFor(box, size), [box, size]); const [view, setView] = useState({ x: 0, y: 0, zoom: 1 }); const userAdjustedRef = useRef(false); const atFloor = view.zoom <= fitZoom + 1e-9; const [settled, setSettled] = useState(false); const [panning, setPanning] = useState(false); useEffect(() => { if (atFloor) userAdjustedRef.current = false; }, [atFloor]); useEffect(() => { if (userAdjustedRef.current || !size.w || !size.h || !svg) return; setView(fitGraphView(box, size, fitZoom)); const raf = requestAnimationFrame(() => setSettled(true)); return () => cancelAnimationFrame(raf); }, [fitZoom, size, box, svg]); useEffect(() => { const el = viewportRef.current; if (!el) return; const onWheel = (e: WheelEvent) => { e.preventDefault(); const dy = e.deltaMode === 1 ? e.deltaY * 16 : e.deltaY; const factor = Math.exp(-dy * 0.0015); const rect = el.getBoundingClientRect(); setView((v) => zoomedGraphView(v, factor, fitZoom, { x: e.clientX - rect.left, y: e.clientY - rect.top }, box, size), ); userAdjustedRef.current = true; }; el.addEventListener("wheel", onWheel, { passive: false }); return () => el.removeEventListener("wheel", onWheel); }, [fitZoom, box, size]); const dragRef = useRef<{ px: number; py: number; x: number; y: number; moved: boolean } | null>(null); const startDrag = (e: React.PointerEvent) => { if (e.button !== 0) return; if ((e.target as HTMLElement).closest("button")) return; if (atFloor) return; dragRef.current = { px: e.clientX, py: e.clientY, x: view.x, y: view.y, moved: false }; setPanning(true); e.currentTarget.classList.add("cursor-grabbing"); e.currentTarget.setPointerCapture(e.pointerId); }; const moveDrag = (e: React.PointerEvent) => { const d = dragRef.current; if (!d) return; d.moved = true; setView((v) => v.zoom <= fitZoom + 1e-9 ? v : { ...v, x: d.x + (e.clientX - d.px), y: d.y + (e.clientY - d.py) }, ); }; const endDrag = (e: React.PointerEvent) => { const d = dragRef.current; if (d) { dragRef.current = null; if (d.moved) userAdjustedRef.current = true; } setPanning(false); e.currentTarget.classList.remove("cursor-grabbing"); }; const stepZoom = (factor: number) => { userAdjustedRef.current = true; setView((v) => zoomedGraphView(v, factor, fitZoom, { x: size.w / 2, y: size.h / 2 }, box, size)); }; const fitView = () => { userAdjustedRef.current = false; if (!size.w || !size.h) return; setView(fitGraphView(box, size, fitZoom)); }; return (
Diagram from this lesson
{error ? (
setAttempt((a) => a + 1)} />
) : svg ? ( <>
); }