dorfteich/packages/plugins/excalidraw/src/plugin.tsx
Claude Fable 5 c164a031e4
Some checks failed
CI / Lint, typecheck, test (pull_request) Failing after 4m19s
CI / Auth e2e pack (pull_request) Has been skipped
CI / Import/export fidelity gate (pull_request) Has been skipped
CI / Build container images (pull_request) Has been skipped
#136 Excalidraw-Block-Plugin
Neues Referenz-Block-Plugin „Excalidraw" (handgezeichnete Whiteboard-
Skizzen), analog zum draw.io-Plugin. Anders als draw.io (vendored Webapp)
ist Excalidraw eine React-npm-Lib: esbuild bündelt Controller + React +
Excalidraw in plugin.js, die Font-/Locale-/Data-Assets werden aus
node_modules in den ZIP-Root kopiert und zur Laufzeit über
EXCALIDRAW_ASSET_PATH (Plugin-Asset-Basis) geladen — nichts spricht mit
excalidraw.com, die Sandbox-CSP pinnt jede Anfrage auf self.

- manifest.json: kind=code, Block-Extension-Point diagram,
  permissions blockData+ui, fallback "[Excalidraw]".
- src/plugin.tsx: Render-Modus zeigt gespeichertes SVG; Edit-Modus zeigt
  Snapshot + Bearbeiten-Knopf (leerer Block öffnet direkt); Vollbild via
  host.ui.enterFullscreen mountet <Excalidraw> (React), „Speichern &
  Beenden" exportiert per exportToSvg, persistiert {scene, svg} über
  host.blockData.setData → Fallback-Renderer bedient Lese-/Public-Ansicht
  + Exporte ohne Backend-Änderung.
- build.mjs: esbuild (jsx automatic, css→text, production-conditions) +
  fflate-ZIP. Build erzeugt excalidraw-1.0.0.zip: 15,5 MiB zip /
  22,3 MiB unpacked (Limits 64/256 MiB — passt).
- i18n de+en, globals.d.ts (CSS-Modul-Deklaration).

pnpm-Override @floating-ui/react-dom@2.1.2: Excalidraw 0.18.1 zieht sonst
@floating-ui/dom@^1.8.0, das (noch) nicht im Registry ist und `pnpm
install` repo-weit bricht (dokumentiert in pnpm-workspace.yaml).

VERIFIZIERT: typecheck/lint, Manifest-Validierung (SDK), Build+ZIP-Größe.
NICHT lokal verifiziert (braucht Preview/Test-Stage): Laufzeit —
Excalidraw-Rendering + Speichern unter Sandbox-CSP, Font-Laden vom
Asset-Pfad. Prod-Installation macht Stefan als Site-Admin.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0155v2aT8AG1kZDQEZiCLBWC
2026-07-19 04:18:04 +02:00

269 lines
9.2 KiB
TypeScript

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<string, unknown>;
getFiles: () => Record<string, unknown>;
}
// 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<string, Record<string, string>> = { 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<string, unknown>;
files: Record<string, unknown>;
} {
if (!scene) return { elements: [], appState: {}, files: {} };
try {
const parsed = JSON.parse(scene) as {
elements?: unknown[];
appState?: Record<string, unknown>;
files?: Record<string, unknown>;
};
return {
elements: parsed.elements ?? [],
appState: parsed.appState ?? {},
files: parsed.files ?? {},
};
} catch {
return { elements: [], appState: {}, files: {} };
}
}
async function openEditor(context: RenderContext): Promise<void> {
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<typeof Excalidraw>[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<typeof serializeAsJSON>[0],
appState as Parameters<typeof serializeAsJSON>[1],
files as Parameters<typeof serializeAsJSON>[2],
'local',
);
const svgEl = await exportToSvg({
elements,
appState: { ...appState, exportBackground: true, exportWithDarkMode: false },
files,
} as Parameters<typeof exportToSvg>[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();
}