/* Copyright 2026 Marimo. All rights reserved. */ /* oxlint-disable typescript/no-explicit-any */ import { useCallback, useEffect, useRef } from "react"; import { z } from "zod"; import { useEventListener } from "@/hooks/useEventListener"; import { createPlugin } from "@/plugins/core/builder"; import { isTrustedVirtualFileUrl } from "@/plugins/core/trusted-url"; import type { Model } from "@/plugins/impl/anywidget/model"; import { WIDGET_REGISTRY } from "@/plugins/impl/anywidget/registry"; import type { ModelState, WidgetModelId } from "@/plugins/impl/anywidget/types"; import type { IPluginProps } from "@/plugins/types"; import { downloadBlob } from "@/utils/download"; import { Logger } from "@/utils/Logger"; import { MplCommWebSocket } from "./mpl-websocket-shim"; import { Functions } from "@/utils/functions"; const MPL_SCOPE_CLASS = "mpl-interactive-figure"; interface Data { mplJsUrl: string; cssUrl: string; toolbarImages: Record; width: number; height: number; } interface ModelIdRef { model_id: WidgetModelId; } declare global { interface Window { mpl: { figure: new ( id: string, ws: MplCommWebSocket, ondownload: (figure: MplFigure, format: string) => void, element: HTMLElement, ) => MplFigure; toolbar_items: [ string | null, string | null, string | null, string | null, ][]; }; } } interface MplFigure { id: string; ws: MplCommWebSocket; root: HTMLElement; send_message: (type: string, properties: Record) => void; } export const MplInteractivePlugin = createPlugin( "marimo-mpl-interactive", ) .withData( z.object({ mplJsUrl: z.string(), cssUrl: z.string(), toolbarImages: z.record(z.string(), z.string()), width: z.number(), height: z.number(), }), ) .withFunctions({}) .renderer((props) => ); let mplJsLoading: Promise | null = null; async function ensureMplJs(jsUrl: string): Promise { if (window.mpl) { return; } if (!isTrustedVirtualFileUrl(jsUrl)) { throw new Error( `Refusing to load mpl.js from untrusted URL: ${String(jsUrl)}`, ); } if (mplJsLoading) { return mplJsLoading; } mplJsLoading = new Promise((resolve, reject) => { const script = document.createElement("script"); script.src = jsUrl; script.onload = () => resolve(); script.onerror = () => { mplJsLoading = null; reject(new Error("Failed to load mpl.js")); }; document.head.append(script); }); return mplJsLoading; } /** * Patch mpl.js toolbar image references to use inline data URIs. * * mpl.js sets `icon_img.src = '_images/' + image + '.png'` and * `icon_img.srcset = '_images/' + image + '_large.png 2x'`. * * We observe the container for new elements and rewrite their * src/srcset to the inlined base64 data URIs. */ function patchToolbarImages( container: HTMLElement, toolbarImages: Record, ): () => void { const patchImg = (img: HTMLImageElement) => { const src = img.getAttribute("src") || ""; const match = src.match(/_images\/(.+)\.png$/); if (match) { const name = match[1]; const dataUri = toolbarImages[name]; if (dataUri) { img.src = dataUri; } } const srcset = img.getAttribute("srcset") || ""; const srcsetMatch = srcset.match(/_images\/(.+)\.png\s+2x$/); if (srcsetMatch) { const name = srcsetMatch[1]; const dataUri = toolbarImages[name]; if (dataUri) { img.srcset = `${dataUri} 2x`; } } }; // Patch any existing images for (const img of container.querySelectorAll("img")) { patchImg(img); } // Observe for new images added by mpl.js const observer = new MutationObserver((mutations) => { for (const mutation of mutations) { for (const node of mutation.addedNodes) { if (node instanceof HTMLImageElement) { patchImg(node); } else if (node instanceof HTMLElement) { for (const img of node.querySelectorAll("img")) { patchImg(img); } } } } }); observer.observe(container, { childList: true, subtree: true }); return () => observer.disconnect(); } function injectCss(container: HTMLElement, cssUrl: string): () => void { if (!isTrustedVirtualFileUrl(cssUrl)) { Logger.error( `Refusing to load mpl CSS from untrusted URL: ${String(cssUrl)}`, ); return Functions.NOOP; } const link = document.createElement("link"); link.rel = "stylesheet"; link.href = cssUrl; container.append(link); return () => link.remove(); } const MplInteractiveSlot = (props: IPluginProps) => { const { mplJsUrl, cssUrl, toolbarImages, width, height } = props.data; const { model_id: modelId } = props.value; const containerRef = useRef(null); const figureRef = useRef(null); const wsRef = useRef(null); // Sends to the currently bound backend model. Re-pointed on every (re)bind // so the persistent socket and toolbar downloads always reach the live comm. const sendRef = useRef<(msg: unknown) => void>(Functions.NOOP); // Detaches the model bound by the most recent bindModel call. Shared between // the mount and rebind effects so a rerun disposes the prior model's // listener before attaching the next one (never stacking listeners). const boundModelCleanupRef = useRef<(() => void) | undefined>(undefined); // Latest model id, read by the mount effect without being a dependency: // the figure is built once, and switching to a new model is the rebind // effect's job, not a reason to tear the canvas down. const modelIdRef = useRef(modelId); modelIdRef.current = modelId; // The data attributes are re-parsed into fresh objects on every rerun, so // `toolbarImages` changes identity even when its contents do not. Read it // from a ref so it can't retrigger the mount effect and rebuild the canvas. const toolbarImagesRef = useRef(toolbarImages); toolbarImagesRef.current = toolbarImages; // Bind the already-rendered figure/socket to a backend model, leaving the // DOM, figure, and socket in place so they survive across reruns. Disposes // the previously bound model first and records the new cleanup in // boundModelCleanupRef, so exactly one model listener is ever attached. const bindModel = useCallback(async (id: WidgetModelId): Promise => { const fakeWs = wsRef.current; if (!fakeWs) { return; } let model: Model; try { model = await WIDGET_REGISTRY.getModel(id); } catch { Logger.error("Failed to get model for mpl interactive", id); return; } // The figure may have been torn down (unmount, or a structural rebuild) // while we awaited the model; don't wire a listener that would outlive it. if (wsRef.current !== fakeWs) { return; } // Detach the previously bound model before wiring the new one. boundModelCleanupRef.current?.(); // Re-point outbound traffic at this model without recreating the socket, // so mpl.js's onopen/onmessage wiring stays intact. const send = (msg: unknown) => model.send(msg); fakeWs.setSendHandler(send); sendRef.current = send; // Listen for backend → frontend messages via model custom events const handleCustomMessage = ( content: { type: string; data?: unknown; format?: string }, buffers?: readonly DataView[], ) => { if (!content) { return; } if (content.type === "json") { fakeWs.receiveJson(content.data); } else if (content.type === "binary" && buffers && buffers.length > 0) { fakeWs.receiveBinary(buffers[0]); } else if (content.type === "download" && buffers && buffers.length > 0) { const fmt = content.format || "png"; const dv = buffers[0]; const ab = dv.buffer.slice( dv.byteOffset, dv.byteOffset + dv.byteLength, ) as ArrayBuffer; downloadBlob(new Blob([ab], { type: `image/${fmt}` }), `figure.${fmt}`); } }; model.on("msg:custom", handleCustomMessage as any); // Replay the mpl.js handshake against the new comm so the backend // re-establishes image mode and pushes a frame. The figure DOM and the // backend manager's toolbar state are left untouched. fakeWs.onopen?.(); boundModelCleanupRef.current = () => { model.off("msg:custom", handleCustomMessage as any); if (sendRef.current === send) { sendRef.current = Functions.NOOP; } }; }, []); // Mount: build the DOM, mpl figure, and socket once. modelId is read from a // ref and intentionally omitted from the deps — rebinding to a new model is // handled by the effect below, not by rebuilding the canvas. useEffect(() => { const container = containerRef.current; if (!container) { return; } // Clear any previous content (handles re-render / cell re-run) container.innerHTML = ""; // Inject CSS const removeCss = injectCss(container, cssUrl); // Patch toolbar images const removeImageObserver = patchToolbarImages( container, toolbarImagesRef.current, ); let cancelled = false; const setup = async () => { // Load mpl.js globally (only once, via