import { createPlugin, windowTransport, type RenderContext } from '@dorfteich/plugin-sdk'; import { Excalidraw, exportToSvg, serializeAsJSON } from '@excalidraw/excalidraw'; import { createElement } from 'react'; import { createRoot, type Root } from 'react-dom/client'; /** The subset of Excalidraw's imperative API this plugin uses — kept local so * the deep types-path export (which moves between versions) is not imported. */ interface ExcalidrawApi { getSceneElements: () => readonly unknown[]; getAppState: () => Record; getFiles: () => Record; } // Bundled as a string (esbuild `.css` → text loader) and injected once, since // the sandbox frame document loads only `plugin.js` and the CSP forbids remote // stylesheets. import excalidrawCss from '@excalidraw/excalidraw/index.css'; import de from '../i18n/de.json'; import en from '../i18n/en.json'; /** * Excalidraw reference plugin: hand-drawn whiteboard sketches edited in the * REAL Excalidraw editor, which the package bundles (npm, esbuild) together * with its font assets — nothing is ever loaded from excalidraw.com; the * sandbox CSP pins every request to the plugin's own version-pinned asset path * (`EXCALIDRAW_ASSET_PATH`, set below). * * Block data: `{ scene, svg }` — `scene` is the Excalidraw scene as a JSON * string (document of record), `svg` the rendered snapshot as raw markup, so * render mode, office/PDF exports (fallback renderer) and the public read view * can all show the sketch without running any editor code. */ const STRINGS: Record> = { de, en }; function labelFor(locale: string, key: string): string { const base = locale.split('-')[0] ?? locale; return STRINGS[base]?.[key] ?? STRINGS.en?.[key] ?? key; } interface SketchData { /** Excalidraw scene as a serialized JSON string (serializeAsJSON). */ scene?: string; /** Rendered snapshot as raw SVG markup. */ svg?: string; } function dataOf(context: RenderContext): SketchData { return context.data && typeof context.data === 'object' ? (context.data as SketchData) : {}; } // Fonts, locales and font metadata load from the plugin's own asset path (the // directory this module was served from), which the sandbox CSP allows. // Excalidraw resolves them relative to EXCALIDRAW_ASSET_PATH (e.g. `fonts/…`); // build.mjs copies dist/prod/{fonts,locales,data} to the ZIP root. (window as unknown as { EXCALIDRAW_ASSET_PATH: string }).EXCALIDRAW_ASSET_PATH = new URL( '.', import.meta.url, ).href; let cssInjected = false; function ensureCss(): void { if (cssInjected) return; const style = document.createElement('style'); style.textContent = excalidrawCss; document.head.appendChild(style); cssInjected = true; } const { host } = createPlugin({ transport: windowTransport({ target: { postMessage: (message) => window.parent.postMessage(message, '*') }, source: window, }), onRender: (context) => renderMode(context), onEdit: (context) => editMode(context), onDestroy: () => closeEditor(), }); function resize(): void { void host.ui.resize(Math.max(64, document.body.scrollHeight + 16)); } /** The stored snapshot as an inline drawing, or the empty-state hint. */ function snapshotElement(data: SketchData, locale: string): HTMLElement { if (data.svg && data.svg.trim() !== '') { const holder = document.createElement('div'); holder.className = 'dt-excalidraw__snapshot'; holder.innerHTML = data.svg; const svg = holder.querySelector('svg'); if (svg) { svg.style.maxWidth = '100%'; svg.style.height = 'auto'; } return holder; } const hint = document.createElement('p'); hint.textContent = labelFor(locale, 'empty'); hint.style.color = '#6b7280'; return hint; } function renderMode(context: RenderContext): void { closeEditor(); const data = dataOf(context); document.body.textContent = ''; document.body.className = 'dt-excalidraw dt-excalidraw--render'; document.body.appendChild(snapshotElement(data, context.locale)); resize(); } function editMode(context: RenderContext): void { closeEditor(); const data = dataOf(context); document.body.textContent = ''; document.body.className = 'dt-excalidraw dt-excalidraw--edit'; document.body.appendChild(snapshotElement(data, context.locale)); const button = document.createElement('button'); button.type = 'button'; button.textContent = labelFor(context.locale, data.svg ? 'editButton' : 'createButton'); button.style.cssText = 'display:block;margin:8px 0;padding:6px 12px;cursor:pointer;font:inherit;'; button.addEventListener('click', () => void openEditor(context)); document.body.appendChild(button); resize(); // A block that has no sketch yet goes straight into the editor. if (!data.svg && !data.scene) void openEditor(context); } /** The active fullscreen editing session, if any. */ let session: { container: HTMLDivElement; root: Root } | null = null; function closeEditor(): void { if (!session) return; const { container, root } = session; session = null; root.unmount(); container.remove(); void host.ui.exitFullscreen(); } function parseScene(scene: string | undefined): { elements: readonly unknown[]; appState: Record; files: Record; } { if (!scene) return { elements: [], appState: {}, files: {} }; try { const parsed = JSON.parse(scene) as { elements?: unknown[]; appState?: Record; files?: Record; }; return { elements: parsed.elements ?? [], appState: parsed.appState ?? {}, files: parsed.files ?? {}, }; } catch { return { elements: [], appState: {}, files: {} }; } } async function openEditor(context: RenderContext): Promise { if (session) return; ensureCss(); const data = dataOf(context); const initial = parseScene(data.scene); await host.ui.enterFullscreen(); const container = document.createElement('div'); container.className = 'dt-excalidraw__editor'; container.style.cssText = 'position:fixed;inset:0;background:#fff;display:flex;flex-direction:column;'; document.body.appendChild(container); const bar = document.createElement('div'); bar.style.cssText = 'display:flex;gap:8px;justify-content:flex-end;padding:8px;border-bottom:1px solid #e5e7eb;background:#fff;z-index:5;'; const cancel = document.createElement('button'); cancel.type = 'button'; cancel.textContent = labelFor(context.locale, 'cancel'); cancel.style.cssText = 'padding:6px 12px;cursor:pointer;font:inherit;'; const save = document.createElement('button'); save.type = 'button'; save.textContent = labelFor(context.locale, 'save'); save.style.cssText = 'padding:6px 12px;cursor:pointer;font:inherit;font-weight:600;'; bar.append(cancel, save); container.appendChild(bar); const canvas = document.createElement('div'); canvas.style.cssText = 'flex:1;min-height:0;position:relative;'; container.appendChild(canvas); let api: ExcalidrawApi | null = null; const root = createRoot(canvas); session = { container, root }; root.render( createElement(Excalidraw, { initialData: { elements: initial.elements, appState: initial.appState, files: initial.files, }, excalidrawAPI: (instance: ExcalidrawApi) => { api = instance; }, } as Parameters[0]), ); cancel.addEventListener('click', () => { closeEditor(); finishEdit(dataOf(context), context.locale); }); save.addEventListener('click', () => { void (async () => { if (!api) return; const elements = api.getSceneElements(); const appState = api.getAppState(); const files = api.getFiles(); const scene = serializeAsJSON( elements as Parameters[0], appState as Parameters[1], files as Parameters[2], 'local', ); const svgEl = await exportToSvg({ elements, appState: { ...appState, exportBackground: true, exportWithDarkMode: false }, files, } as Parameters[0]); const svg = new XMLSerializer().serializeToString(svgEl); await host.blockData.setData({ scene, svg } satisfies SketchData); closeEditor(); finishEdit({ scene, svg }, context.locale); })(); }); } /** Redraws the inline edit surface after the fullscreen editor closed. */ function finishEdit(data: SketchData, locale: string): void { document.body.textContent = ''; document.body.className = 'dt-excalidraw dt-excalidraw--edit'; document.body.appendChild(snapshotElement(data, locale)); const button = document.createElement('button'); button.type = 'button'; button.textContent = labelFor(locale, data.svg ? 'editButton' : 'createButton'); button.style.cssText = 'display:block;margin:8px 0;padding:6px 12px;cursor:pointer;font:inherit;'; button.addEventListener( 'click', () => void openEditor({ extensionPointId: 'diagram', locale, data }), ); document.body.appendChild(button); if (data.svg) { const note = document.createElement('p'); note.textContent = labelFor(locale, 'saved'); note.style.cssText = 'color:#6b7280;font-size:13px;'; document.body.appendChild(note); } resize(); }