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
269 lines
9.2 KiB
TypeScript
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();
|
|
}
|