M17–M19: #133–#137 (Kommentare inline, Statuszeile, Transklusion, Excalidraw, Checkbox-Fix) #138

Merged
fable-5 merged 10 commits from m17-m19-issues into main 2026-07-19 09:38:38 +02:00
11 changed files with 1722 additions and 0 deletions
Showing only changes of commit c164a031e4 - Show all commits

View File

@ -0,0 +1,2 @@
vendor/
dist/

View File

@ -0,0 +1,115 @@
// Builds the installable Excalidraw plugin. Unlike draw.io (which vendors a
// standalone webapp) Excalidraw is an npm React component: esbuild bundles the
// controller + React + Excalidraw into plugin.js, and its font/worker assets
// are copied from node_modules into `excalidraw-assets/` so the runtime loads
// them from the plugin's own asset path (EXCALIDRAW_ASSET_PATH) — nothing ever
// talks to excalidraw.com, and the sandbox CSP pins every request to self.
import { createRequire } from 'node:module';
import {
existsSync,
mkdirSync,
readFileSync,
readdirSync,
rmSync,
statSync,
writeFileSync,
} from 'node:fs';
import { dirname, join, relative } from 'node:path';
import { fileURLToPath } from 'node:url';
import { build } from 'esbuild';
import { zipSync } from 'fflate';
const root = dirname(fileURLToPath(import.meta.url));
const manifest = JSON.parse(readFileSync(join(root, 'manifest.json'), 'utf8'));
const require = createRequire(import.meta.url);
// --- 1. Locate Excalidraw's prebuilt font/worker assets --------------------
// The package's `exports` map hides ./package.json, so resolve the main entry
// and walk up to the package root instead.
function packageRootOf(specifier) {
let dir = dirname(require.resolve(specifier));
while (dir !== dirname(dir)) {
const pj = join(dir, 'package.json');
if (existsSync(pj)) {
try {
if (JSON.parse(readFileSync(pj, 'utf8')).name === specifier) return dir;
} catch {
// keep walking up
}
}
dir = dirname(dir);
}
throw new Error(`package root not found for ${specifier}`);
}
const excalidrawPkg = packageRootOf('@excalidraw/excalidraw');
const prodDir = join(excalidrawPkg, 'dist', 'prod');
if (!existsSync(prodDir)) {
throw new Error(`Excalidraw prod build not found at ${prodDir}. Check the dist layout.`);
}
// Runtime assets loaded relative to EXCALIDRAW_ASSET_PATH (set to the plugin's
// asset base in plugin.tsx): fonts render the sketch, locales translate the
// editor UI, data holds font metadata. Copied to the ZIP root so the sandbox
// CSP (self only) can serve them.
const ASSET_SUBDIRS = ['fonts', 'locales', 'data'];
/** Recursively collect files below `dir` (absolute), keyed by path relative to
* it, each prefixed with `prefix`. */
function collect(dir, prefix) {
const files = {};
for (const name of readdirSync(dir)) {
const abs = join(dir, name);
const rel = `${prefix}${name}`;
if (statSync(abs).isDirectory()) Object.assign(files, collect(abs, `${rel}/`));
else files[rel] = readFileSync(abs);
}
return files;
}
const assetFiles = {};
for (const sub of ASSET_SUBDIRS) {
const dir = join(prodDir, sub);
if (existsSync(dir)) Object.assign(assetFiles, collect(dir, `${sub}/`));
}
// --- 2. Bundle the plugin controller (React + Excalidraw) ------------------
mkdirSync(join(root, 'dist'), { recursive: true });
await build({
entryPoints: [join(root, 'src/plugin.tsx')],
bundle: true,
format: 'esm',
platform: 'browser',
// Excalidraw's `exports` gate index.js / index.css behind `production` /
// `development` conditions (no `default`); select the production build.
conditions: ['production'],
outfile: join(root, 'dist/plugin.js'),
minify: true,
jsx: 'automatic',
loader: { '.css': 'text', '.woff2': 'dataurl', '.ttf': 'dataurl', '.svg': 'text' },
define: {
'process.env.NODE_ENV': '"production"',
'process.env.IS_PREACT': '"false"',
},
});
// --- 3. Pack the ZIP --------------------------------------------------------
const files = {
'manifest.json': readFileSync(join(root, 'manifest.json')),
'plugin.js': readFileSync(join(root, 'dist/plugin.js')),
};
for (const name of readdirSync(join(root, 'i18n'))) {
files[`i18n/${name}`] = readFileSync(join(root, 'i18n', name));
}
// Asset paths already carry their subdir prefix (fonts/…, locales/…, data/…)
// and sit at the ZIP root so they resolve under EXCALIDRAW_ASSET_PATH.
Object.assign(files, assetFiles);
const target = join(root, 'dist', `${manifest.id}-${manifest.version}.zip`);
rmSync(target, { force: true });
writeFileSync(target, zipSync(files, { level: 6 }));
const unpacked = Object.values(files).reduce((sum, bytes) => sum + bytes.length, 0);
console.log(
`wrote ${relative(process.cwd(), target)} ` +
`(zip ${(statSync(target).size / 1024 / 1024).toFixed(1)} MiB, ` +
`unpacked ${(unpacked / 1024 / 1024).toFixed(1)} MiB, ` +
`${Object.keys(files).length} files)`,
);

View File

@ -0,0 +1,10 @@
{
"empty": "Noch keine Skizze.",
"editButton": "Skizze im Vollbild bearbeiten",
"createButton": "Skizze erstellen",
"loading": "Excalidraw wird geladen …",
"save": "Speichern & Beenden",
"cancel": "Abbrechen",
"saved": "Gespeichert — „Fertig“ schließt den Bearbeiten-Modus.",
"renderHint": "Zum Bearbeiten in den Bearbeiten-Modus der Seite wechseln."
}

View File

@ -0,0 +1,10 @@
{
"empty": "No sketch yet.",
"editButton": "Edit sketch in fullscreen",
"createButton": "Create sketch",
"loading": "Loading Excalidraw …",
"save": "Save & Exit",
"cancel": "Cancel",
"saved": "Saved — \"Done\" closes edit mode.",
"renderHint": "Switch the page to edit mode to change the sketch."
}

View File

@ -0,0 +1,19 @@
{
"id": "excalidraw",
"name": "Excalidraw Whiteboard",
"version": "1.0.0",
"apiVersion": "1",
"kind": "code",
"extensionPoints": [
{
"type": "block",
"id": "diagram",
"title": { "de": "Excalidraw-Skizze", "en": "Excalidraw sketch" }
}
],
"permissions": ["blockData", "ui"],
"fallback": { "type": "text", "value": "[Excalidraw]" },
"license": "MIT",
"homepage": "https://excalidraw.com",
"i18n": { "de": "i18n/de.json", "en": "i18n/en.json" }
}

View File

@ -0,0 +1,25 @@
{
"name": "@dorfteich/plugin-excalidraw",
"version": "0.0.0",
"private": true,
"description": "Reference block plugin: Excalidraw hand-drawn whiteboard sketches — fullscreen editing with the bundled Excalidraw editor, inline SVG rendering",
"license": "MIT",
"scripts": {
"build": "node build.mjs",
"typecheck": "tsc --noEmit",
"test": "vitest run --passWithNoTests"
},
"devDependencies": {
"@dorfteich/plugin-sdk": "workspace:*",
"@excalidraw/excalidraw": "0.18.1",
"@types/node": "^26.1.0",
"@types/react": "^19.0.0",
"@types/react-dom": "^19.0.0",
"esbuild": "^0.24.0",
"fflate": "^0.8.2",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"typescript": "^5.7.0",
"vitest": "^3.0.0"
}
}

View File

@ -0,0 +1,4 @@
// The Excalidraw stylesheet is bundled as a string via esbuild's `.css` → text
// loader (see build.mjs) and injected at runtime; declare the module so the
// import type-checks.
declare module '@excalidraw/excalidraw/index.css';

View File

@ -0,0 +1,268 @@
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();
}

View File

@ -0,0 +1,12 @@
{
"extends": "../../../tsconfig.base.json",
"compilerOptions": {
"module": "ESNext",
"moduleResolution": "Bundler",
"resolveJsonModule": true,
"noEmit": true,
"jsx": "react-jsx",
"lib": ["ES2022", "DOM", "DOM.Iterable"]
},
"include": ["src", "*.ts"]
}

1249
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@ -12,3 +12,11 @@ allowBuilds:
argon2: true argon2: true
esbuild: true esbuild: true
prisma: true prisma: true
# Excalidraw 0.18.1 (excalidraw plugin, issue #136) transitively pulls
# @floating-ui/react-dom@2.1.9, which requires @floating-ui/dom@^1.8.0 — a
# version not published on the registry (latest 1.7.6), breaking `pnpm install`
# repo-wide. Pin react-dom to 2.1.2, which needs @floating-ui/dom@^1.0.0. Only
# the excalidraw plugin pulls @floating-ui at all; drop this once upstream
# @floating-ui/dom@1.8.0 is published.
overrides:
'@floating-ui/react-dom': '2.1.2'