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;