import { Component, type CSSProperties, type JSX, type PointerEvent as ReactPointerEvent, type ReactNode, type RefObject, useCallback, useRef, useState } from "react"; import { createPortal } from "react-dom"; import DOMPurify from "dompurify"; import mermaid from "mermaid"; import { Button, CopyIcon, RestoreIcon } from "@tutti-os/ui-system"; import { RotateCcwIcon, ZoomInIcon, ZoomOutIcon } from "lucide-react"; import { useTranslation } from "../i18n/index"; const MERMAID_MAX_TEXT_SIZE = 50_000; const MERMAID_MAX_EDGES = 500; const MERMAID_VIEWER_ZOOM_MIN = 0.5; const MERMAID_VIEWER_ZOOM_MAX = 4; const MERMAID_VIEWER_ZOOM_STEP = 0.25; type MermaidTheme = "dark" | "default"; interface RenderedMermaidDiagram { source: string; svg: string; theme: MermaidTheme; } interface MermaidViewTransform { x: number; y: number; zoom: number; } interface MermaidDragState { originX: number; originY: number; pointerId: number; startX: number; startY: number; } const RESET_VIEW_TRANSFORM: MermaidViewTransform = { x: 0, y: 0, zoom: 1 }; export function AgentMessageMermaid({ source, streaming }: { source: string; streaming: boolean; }): JSX.Element { const { t } = useTranslation(); const theme = useMermaidTheme(); return ( ); } interface MermaidRenderLifecycleProps { copyLabel: string; expandLabel: string; failureLabel: string; loadingLabel: string; source: string; streaming: boolean; theme: MermaidTheme; } interface MermaidRenderLifecycleState { failedRenderKey: string | null; isViewerOpen: boolean; rendered: RenderedMermaidDiagram | null; } class MermaidRenderLifecycle extends Component< MermaidRenderLifecycleProps, MermaidRenderLifecycleState > { state: MermaidRenderLifecycleState = { failedRenderKey: null, isViewerOpen: false, rendered: null }; private pendingRenderKey: string | null = null; private renderVersion = 0; componentDidMount(): void { this.ensureRendered(); } componentDidUpdate(previousProps: MermaidRenderLifecycleProps): void { if (previousProps.source !== this.props.source && this.state.isViewerOpen) { this.setState({ isViewerOpen: false }); } this.ensureRendered(); } componentWillUnmount(): void { this.renderVersion += 1; this.pendingRenderKey = null; } private ensureRendered(): void { const { source, streaming, theme } = this.props; const { failedRenderKey, rendered } = this.state; const renderKey = `${theme}:${source}`; if ( streaming || source.length > MERMAID_MAX_TEXT_SIZE || failedRenderKey === renderKey || this.pendingRenderKey === renderKey || (rendered?.source === source && rendered.theme === theme) ) { return; } const renderVersion = ++this.renderVersion; this.pendingRenderKey = renderKey; void renderMermaid(source, theme) .then((svg) => { if (this.renderVersion !== renderVersion) { return; } this.pendingRenderKey = null; this.setState({ failedRenderKey: null, rendered: { source, svg, theme } }); }) .catch(() => { if (this.renderVersion !== renderVersion) { return; } this.pendingRenderKey = null; this.setState({ failedRenderKey: renderKey }); }); } private copySource = (): void => { if (!navigator.clipboard) { return; } void navigator.clipboard.writeText(this.props.source); }; private closeViewer = (): void => { this.setState({ isViewerOpen: false }); }; private openViewer = (): void => { this.setState({ isViewerOpen: true }); }; render(): JSX.Element { const { copyLabel, expandLabel, failureLabel, loadingLabel, source, streaming, theme } = this.props; const { failedRenderKey, isViewerOpen, rendered } = this.state; const renderKey = `${theme}:${source}`; if (streaming) { return ; } if ( source.length > MERMAID_MAX_TEXT_SIZE || (failedRenderKey === renderKey && rendered?.source !== source) ) { return ( ); } if (rendered?.source !== source || !rendered.svg) { return ; } const isRefreshingTheme = rendered.theme !== theme; return ( <>
{!isViewerOpen ? (
{ if (event.key !== "Enter" && event.key !== " ") { return; } event.preventDefault(); this.openViewer(); }} >
) : ( {isViewerOpen ? createPortal( , document.body ) : null} ); } } function MermaidFailure({ copyLabel, label, onCopySource }: { copyLabel: string; label: string; onCopySource: () => void; }): JSX.Element { return (
{label}
); } function MermaidPlaceholder({ label }: { label: string }): JSX.Element { return (
); } function MermaidSvg({ svg }: { svg: string }): JSX.Element { return (
); } class MermaidViewerLifecycle extends Component<{ children: ReactNode; focusTargetRef: RefObject; onEscape: () => void; onSpaceDown: () => void; onSpaceUp: () => void; onUnmount: () => void; onWheel: (event: WheelEvent) => void; wheelTargetRef: RefObject; }> { componentDidMount(): void { window.addEventListener("keydown", this.handleKeyDown, true); window.addEventListener("keyup", this.handleKeyUp, true); window.addEventListener("blur", this.handleWindowBlur); this.props.wheelTargetRef.current?.addEventListener( "wheel", this.handleWheel, { passive: false } ); this.props.focusTargetRef.current?.focus(); } componentWillUnmount(): void { window.removeEventListener("keydown", this.handleKeyDown, true); window.removeEventListener("keyup", this.handleKeyUp, true); window.removeEventListener("blur", this.handleWindowBlur); this.props.wheelTargetRef.current?.removeEventListener( "wheel", this.handleWheel ); this.props.onUnmount(); } private handleKeyDown = (event: KeyboardEvent): void => { if (event.key === "Escape") { event.preventDefault(); event.stopPropagation(); this.props.onEscape(); return; } if (!isSpaceKey(event) || isInteractiveEventTarget(event.target)) { return; } event.preventDefault(); this.props.onSpaceDown(); }; private handleKeyUp = (event: KeyboardEvent): void => { if (!isSpaceKey(event)) { return; } event.preventDefault(); this.props.onSpaceUp(); }; private handleWindowBlur = (): void => { this.props.onSpaceUp(); }; private handleWheel = (event: WheelEvent): void => { this.props.onWheel(event); }; render(): ReactNode { return this.props.children; } } function MermaidViewer({ onClose, svg }: { onClose: () => void; svg: string; }): JSX.Element { const { t } = useTranslation(); const rootRef = useRef(null); const stageRef = useRef(null); const dragRef = useRef(null); const viewRef = useRef(RESET_VIEW_TRANSFORM); const viewAnimationFrameRef = useRef(null); const [isSpacePressed, setIsSpacePressed] = useState(false); const [isDragging, setIsDragging] = useState(false); const [view, setView] = useState(RESET_VIEW_TRANSFORM); const zoomPercent = Math.round(view.zoom * 100); const cancelPendingViewCommit = useCallback((): void => { if (viewAnimationFrameRef.current === null) { return; } cancelAnimationFrame(viewAnimationFrameRef.current); viewAnimationFrameRef.current = null; }, []); const scheduleViewCommit = useCallback((): void => { if (viewAnimationFrameRef.current !== null) { return; } viewAnimationFrameRef.current = requestAnimationFrame(() => { viewAnimationFrameRef.current = null; setView(viewRef.current); }); }, []); const commitViewImmediately = useCallback( (nextView: MermaidViewTransform): void => { cancelPendingViewCommit(); viewRef.current = nextView; setView(nextView); }, [cancelPendingViewCommit] ); const stopDragging = (): void => { const drag = dragRef.current; if (drag && stageRef.current?.hasPointerCapture?.(drag.pointerId)) { stageRef.current.releasePointerCapture?.(drag.pointerId); } dragRef.current = null; setIsDragging(false); }; const releaseSpace = (): void => { setIsSpacePressed(false); stopDragging(); }; const zoomAt = (factor: number, clientX?: number, clientY?: number): void => { const current = viewRef.current; const nextZoom = clampMermaidZoom(current.zoom * factor); if (nextZoom === current.zoom) { return; } const bounds = stageRef.current?.getBoundingClientRect(); const anchorX = bounds && clientX !== undefined ? clientX - (bounds.left + bounds.width / 2) : 0; const anchorY = bounds && clientY !== undefined ? clientY - (bounds.top + bounds.height / 2) : 0; viewRef.current = zoomMermaidViewAt(current, nextZoom, anchorX, anchorY); scheduleViewCommit(); }; const zoomByStep = (step: number): void => { const current = viewRef.current; commitViewImmediately( zoomMermaidViewAt(current, clampMermaidZoom(current.zoom + step), 0, 0) ); }; const handleWheel = (event: WheelEvent): void => { event.preventDefault(); event.stopPropagation(); if (event.deltaY === 0) { return; } zoomAt( Math.pow(2, resolveWheelZoomDelta(event)), event.clientX, event.clientY ); }; const handlePointerDown = ( event: ReactPointerEvent ): void => { if (!isSpacePressed || event.button !== 0) { return; } event.preventDefault(); event.currentTarget.setPointerCapture?.(event.pointerId); const current = viewRef.current; dragRef.current = { originX: current.x, originY: current.y, pointerId: event.pointerId, startX: event.clientX, startY: event.clientY }; setIsDragging(true); }; const handlePointerMove = ( event: ReactPointerEvent ): void => { const drag = dragRef.current; if ( !drag || (typeof drag.pointerId === "number" && typeof event.pointerId === "number" && drag.pointerId !== event.pointerId) ) { return; } event.preventDefault(); viewRef.current = resolveDraggedMermaidView( viewRef.current, drag, event.clientX, event.clientY ); scheduleViewCommit(); }; const transformStyle: CSSProperties = { transform: `translate3d(${formatTransformNumber(view.x)}px, ${formatTransformNumber(view.y)}px, 0) scale(${formatTransformNumber(view.zoom)})` }; return ( setIsSpacePressed(true)} onSpaceUp={releaseSpace} onUnmount={cancelPendingViewCommit} onWheel={handleWheel} wheelTargetRef={rootRef} >
{ if (event.relatedTarget instanceof Node) { if (event.currentTarget.contains(event.relatedTarget)) { return; } } releaseSpace(); }} >
{t("agentHost.agentGui.mermaidPanHint")}
{zoomPercent}%
); } function useMermaidTheme(): MermaidTheme { return resolveMermaidTheme(); } function resolveMermaidTheme(): MermaidTheme { const documentTheme = document.documentElement.dataset.theme; if (documentTheme === "dark") { return "dark"; } if (documentTheme === "light") { return "default"; } return typeof window.matchMedia === "function" && window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "default"; } function renderMermaid(source: string, theme: MermaidTheme): Promise { mermaid.initialize({ darkMode: theme === "dark", htmlLabels: false, maxEdges: MERMAID_MAX_EDGES, maxTextSize: MERMAID_MAX_TEXT_SIZE, secure: [ "secure", "securityLevel", "startOnLoad", "maxTextSize", "suppressErrorRendering", "maxEdges", "htmlLabels" ], securityLevel: "strict", startOnLoad: false, suppressErrorRendering: true, theme }); return mermaid .render(`agent-mermaid-${crypto.randomUUID()}`, source) .then((result) => sanitizeMermaidSvg(result.svg)); } export function sanitizeMermaidSvg(svg: string): string { return String( DOMPurify.sanitize(svg, { FORBID_TAGS: ["embed", "iframe", "object", "script"], USE_PROFILES: { html: true, svg: true, svgFilters: true } }) ); } function resolveWheelZoomDelta( event: Pick ): number { return ( -event.deltaY * (event.deltaMode === 1 ? 0.05 : event.deltaMode ? 1 : 0.002) * (event.ctrlKey ? 10 : 1) ); } function clampMermaidZoom(value: number): number { return Math.min( MERMAID_VIEWER_ZOOM_MAX, Math.max(MERMAID_VIEWER_ZOOM_MIN, value) ); } function zoomMermaidViewAt( current: MermaidViewTransform, nextZoom: number, anchorX: number, anchorY: number ): MermaidViewTransform { if (nextZoom === current.zoom) { return current; } const ratio = nextZoom / current.zoom; return { zoom: nextZoom, x: anchorX - (anchorX - current.x) * ratio, y: anchorY - (anchorY - current.y) * ratio }; } export function resolveDraggedMermaidView( current: MermaidViewTransform, drag: Pick, clientX: number, clientY: number ): MermaidViewTransform { return { ...current, x: drag.originX + clientX - drag.startX, y: drag.originY + clientY - drag.startY }; } function formatTransformNumber(value: number): string { return Number(value.toFixed(3)).toString(); } function isSpaceKey(event: { code: string; key: string }): boolean { return event.code === "Space" || event.key === " "; } function isInteractiveEventTarget(target: EventTarget | null): boolean { return ( target instanceof Element && Boolean( target.closest( "button, input, textarea, select, a, [contenteditable='true']" ) ) ); } export const agentMessageMermaidLimits = { maxEdges: MERMAID_MAX_EDGES, maxTextSize: MERMAID_MAX_TEXT_SIZE } as const;