draw.io reference plugin: fullscreen editing, inline SVG rendering
All checks were successful
CD / Build and push images (push) Successful in 3m54s
CD / Deploy to Test (push) Successful in 10s
CI / Lint, typecheck, test (push) Successful in 4m9s
CI / Build container images (push) Has been skipped
CD / Smoke tests against Test (push) Successful in 1m13s
CD / Promote to Int (push) Successful in 13s
CI / Auth e2e pack (push) Successful in 5m36s
CI / Import/export fidelity gate (push) Successful in 47s
All checks were successful
CD / Build and push images (push) Successful in 3m54s
CD / Deploy to Test (push) Successful in 10s
CI / Lint, typecheck, test (push) Successful in 4m9s
CI / Build container images (push) Has been skipped
CD / Smoke tests against Test (push) Successful in 1m13s
CD / Promote to Int (push) Successful in 13s
CI / Auth e2e pack (push) Successful in 5m36s
CI / Import/export fidelity gate (push) Successful in 47s
A new block plugin bundling the OFFICIAL draw.io editor — nothing ever
loads from diagrams.net; the sandbox CSP pins every request to the
plugin's own version-pinned asset path (zero-external-network verified
live via a request-capture run).
Plugin (packages/plugins/drawio):
- block data { xml, svg }: xml is the draw.io source (document of
record), svg the rendered snapshot as raw markup — render mode,
office/PDF exports (the existing fallback renderer already inlines
data.svg) and the public view all show the diagram without running
diagram code
- edit mode: snapshot + "edit in fullscreen" (an empty block opens the
editor immediately); the bundled editor runs in a child iframe of the
plugin's own assets and speaks draw.io's JSON embed protocol —
Save & Exit exports xmlsvg, persists { xml, svg } via blockData, and
drops back to the inline size
- build.mjs fetches the pinned release (v30.3.6) into a gitignored
vendor/ cache (fonts-build pattern; skipped in CI — plugin.js still
bundles, the installable ZIP needs a dev machine) and packs a trimmed
webapp subset: no dev sources, no embed.diagrams.net integrations
bundle, no standalone viewers, no MathJax/templates/PWA — 27 MiB ZIP,
85 MiB unpacked, de+en editor languages
Host/SDK extensions (generic, not drawio-specific):
- new ui.enterFullscreen()/exitFullscreen(): the surface's frame becomes
a viewport-covering overlay — same sandboxed iframe, only geometry
changes; destroy removes the frame, so a vanished plugin can never
leave the app covered
- sandbox CSP: connect-src/frame-src now allow the plugin's OWN asset
path (was 'none') — bundled apps lazy-load their resources and run in
a child frame, but the api and external hosts stay unreachable; HTML
assets are served with the same CSP so a packaged page cannot widen
the rules, and child frames inherit the sandbox attribute
- plugin size limits raised (ZIP 5→64 MiB, unpacked 20→256 MiB) for
bundled-app plugins; content types for xml/txt/ico assets
Verified end to end against a local stack (9/9): install via dropzone
(85 MiB validation), block insert, fullscreen entry, bundled editor
boots inside the double sandbox (German UI), shape drawn, Save & Exit
persists, snapshot renders inline, survives reload, zero off-origin
requests throughout.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EwZ4jR4KFAPvpjWevfUGX1
This commit is contained in:
parent
203f7c98a5
commit
97f94f247b
@ -33,6 +33,10 @@ const CONTENT_TYPES: Record<string, string> = {
|
|||||||
gif: 'image/gif',
|
gif: 'image/gif',
|
||||||
webp: 'image/webp',
|
webp: 'image/webp',
|
||||||
woff2: 'font/woff2',
|
woff2: 'font/woff2',
|
||||||
|
// Bundled-app plugins (drawio): lazy-loaded stencils/resources/icons.
|
||||||
|
xml: 'text/xml; charset=utf-8',
|
||||||
|
txt: 'text/plain; charset=utf-8',
|
||||||
|
ico: 'image/x-icon',
|
||||||
};
|
};
|
||||||
|
|
||||||
function contentTypeFor(path: string): string {
|
function contentTypeFor(path: string): string {
|
||||||
@ -116,6 +120,18 @@ export class PluginAssetsController {
|
|||||||
|
|
||||||
response.set('X-Content-Type-Options', 'nosniff');
|
response.set('X-Content-Type-Options', 'nosniff');
|
||||||
response.set('Cache-Control', 'public, max-age=31536000, immutable');
|
response.set('Cache-Control', 'public, max-age=31536000, immutable');
|
||||||
|
// HTML assets can become documents (a plugin's own child frame, e.g. the
|
||||||
|
// bundled drawio editor) — stamp the same restrictive CSP the frame
|
||||||
|
// document carries, so no packaged page can widen the sandbox's network
|
||||||
|
// or embedding rules.
|
||||||
|
if (relPath.toLowerCase().endsWith('.html')) {
|
||||||
|
response.set(
|
||||||
|
'Content-Security-Policy',
|
||||||
|
buildPluginFrameCsp(
|
||||||
|
buildPluginAssetBase(new URL(this.config.env.APP_BASE_URL).origin, id, version),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
// The sandbox frame has a null (opaque) origin, so it fetches its own
|
// The sandbox frame has a null (opaque) origin, so it fetches its own
|
||||||
// bundle cross-origin; allow it. These are public, immutable client
|
// bundle cross-origin; allow it. These are public, immutable client
|
||||||
// assets, never user data — a wildcard is safe.
|
// assets, never user data — a wildcard is safe.
|
||||||
|
|||||||
@ -30,7 +30,15 @@ export function buildPluginFrameCsp(assetBase: string): string {
|
|||||||
`style-src ${assetBase} 'unsafe-inline'`,
|
`style-src ${assetBase} 'unsafe-inline'`,
|
||||||
`img-src ${assetBase} data: blob:`,
|
`img-src ${assetBase} data: blob:`,
|
||||||
`font-src ${assetBase}`,
|
`font-src ${assetBase}`,
|
||||||
`connect-src 'none'`,
|
// A plugin may talk to its OWN version-pinned assets (bundled apps like
|
||||||
|
// drawio lazy-load stencils/resources via XHR) — and to nothing else:
|
||||||
|
// no api, no external hosts. The zero-external-network guarantee holds.
|
||||||
|
`connect-src ${assetBase}`,
|
||||||
|
// Bundled sub-apps may run in a child frame of the plugin's own assets
|
||||||
|
// (the drawio editor); the child inherits the sandbox attribute and,
|
||||||
|
// being served from the same asset path, this same CSP (the asset
|
||||||
|
// controller stamps it on every text/html asset).
|
||||||
|
`frame-src ${assetBase}`,
|
||||||
`base-uri 'none'`,
|
`base-uri 'none'`,
|
||||||
`form-action 'none'`,
|
`form-action 'none'`,
|
||||||
].join('; ');
|
].join('; ');
|
||||||
|
|||||||
@ -151,7 +151,7 @@ describe('PluginPackageService.parse — each invalid class', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('rejects a package that inflates beyond the unpacked limit', () => {
|
it('rejects a package that inflates beyond the unpacked limit', () => {
|
||||||
const huge = new Uint8Array(21 * 1024 * 1024); // zeros compress tiny, inflate large
|
const huge = new Uint8Array(257 * 1024 * 1024); // zeros compress tiny, inflate large
|
||||||
expectReject(
|
expectReject(
|
||||||
zip({ 'manifest.json': enc(JSON.stringify(codeManifest)), 'plugin.js': huge }),
|
zip({ 'manifest.json': enc(JSON.stringify(codeManifest)), 'plugin.js': huge }),
|
||||||
'plugin_too_large',
|
'plugin_too_large',
|
||||||
|
|||||||
@ -1,10 +1,12 @@
|
|||||||
import type { PluginErrorCode } from '@dorfteich/shared';
|
import type { PluginErrorCode } from '@dorfteich/shared';
|
||||||
|
|
||||||
/** Compressed upload ceiling for a plugin ZIP (multer rejects larger). */
|
/** Compressed upload ceiling for a plugin ZIP (multer rejects larger). */
|
||||||
export const MAX_PLUGIN_ZIP_BYTES = 5 * 1024 * 1024; // 5 MiB
|
// Raised for bundled-app plugins (drawio ships its whole editor as assets);
|
||||||
|
// still a hard bound against runaway uploads.
|
||||||
|
export const MAX_PLUGIN_ZIP_BYTES = 64 * 1024 * 1024; // 64 MiB
|
||||||
|
|
||||||
/** Total decompressed ceiling — guards against a zip bomb inflating in memory. */
|
/** Total decompressed ceiling — guards against a zip bomb inflating in memory. */
|
||||||
export const MAX_PLUGIN_UNPACKED_BYTES = 20 * 1024 * 1024; // 20 MiB
|
export const MAX_PLUGIN_UNPACKED_BYTES = 256 * 1024 * 1024; // 256 MiB
|
||||||
|
|
||||||
/** The two files the package structure hinges on. */
|
/** The two files the package structure hinges on. */
|
||||||
export const MANIFEST_FILE = 'manifest.json';
|
export const MANIFEST_FILE = 'manifest.json';
|
||||||
|
|||||||
@ -140,7 +140,10 @@ describe.skipIf(!hasTestDb)('plugins install (e2e, issue #71)', () => {
|
|||||||
const csp = frame.headers['content-security-policy'];
|
const csp = frame.headers['content-security-policy'];
|
||||||
expect(csp).toContain(`default-src 'none'`);
|
expect(csp).toContain(`default-src 'none'`);
|
||||||
expect(csp).toMatch(/script-src https?:\/\/[^ ]+\/api\/v1\/plugins\/framer\/1\.0\.0\//);
|
expect(csp).toMatch(/script-src https?:\/\/[^ ]+\/api\/v1\/plugins\/framer\/1\.0\.0\//);
|
||||||
expect(csp).toContain(`connect-src 'none'`);
|
// Network + frames are pinned to the plugin's OWN asset path (bundled
|
||||||
|
// apps like drawio) — still zero external network, zero api access.
|
||||||
|
expect(csp).toMatch(/connect-src https?:\/\/[^ ]+\/api\/v1\/plugins\/framer\/1\.0\.0\//);
|
||||||
|
expect(csp).toMatch(/frame-src https?:\/\/[^ ]+\/api\/v1\/plugins\/framer\/1\.0\.0\//);
|
||||||
|
|
||||||
// Only the installed current version has a frame; anything else 404s.
|
// Only the installed current version has a frame; anything else 404s.
|
||||||
await api().get('/api/v1/plugins/framer/9.9.9/frame').expect(404);
|
await api().get('/api/v1/plugins/framer/9.9.9/frame').expect(404);
|
||||||
|
|||||||
@ -102,6 +102,16 @@ export function createPluginSandbox(options: CreateSandboxOptions): PluginSandbo
|
|||||||
const clamped = Math.min(MAX_FRAME_HEIGHT_PX, Math.max(MIN_FRAME_HEIGHT_PX, height));
|
const clamped = Math.min(MAX_FRAME_HEIGHT_PX, Math.max(MIN_FRAME_HEIGHT_PX, height));
|
||||||
iframe.style.height = `${clamped}px`;
|
iframe.style.height = `${clamped}px`;
|
||||||
},
|
},
|
||||||
|
// Built-in fullscreen (drawio-class editors, #ref drawio plugin): only
|
||||||
|
// the frame's geometry changes — it stays the same sandboxed iframe, so
|
||||||
|
// nothing new becomes reachable. Destroy removes the frame entirely, so
|
||||||
|
// a vanished plugin can never leave the app covered.
|
||||||
|
enterFullscreen: () => {
|
||||||
|
iframe.classList.add('plugin-frame--fullscreen');
|
||||||
|
},
|
||||||
|
exitFullscreen: () => {
|
||||||
|
iframe.classList.remove('plugin-frame--fullscreen');
|
||||||
|
},
|
||||||
// Surface-specific overrides (e.g. #76 blockData) win over the defaults.
|
// Surface-specific overrides (e.g. #76 blockData) win over the defaults.
|
||||||
...options.capabilities,
|
...options.capabilities,
|
||||||
};
|
};
|
||||||
|
|||||||
@ -2670,6 +2670,21 @@ button {
|
|||||||
margin-right: var(--space-1);
|
margin-right: var(--space-1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Fullscreen plugin surface (ui.enterFullscreen — drawio-class editors):
|
||||||
|
covers the whole viewport, above every app layer, until the plugin exits
|
||||||
|
or the surface is destroyed. */
|
||||||
|
.plugin-block__mount .plugin-frame.plugin-frame--fullscreen,
|
||||||
|
.plugin-frame.plugin-frame--fullscreen {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
width: 100vw;
|
||||||
|
height: 100vh !important;
|
||||||
|
z-index: 1000;
|
||||||
|
border: none;
|
||||||
|
border-radius: 0;
|
||||||
|
background: var(--color-surface, #fff);
|
||||||
|
}
|
||||||
|
|
||||||
/* Maintenance screen during an in-app restore (issue #103) */
|
/* Maintenance screen during an in-app restore (issue #103) */
|
||||||
.maintenance-page {
|
.maintenance-page {
|
||||||
max-width: 32rem;
|
max-width: 32rem;
|
||||||
|
|||||||
@ -70,8 +70,14 @@ the contract fails the install with `plugin_css_unsafe`.
|
|||||||
parent DOM. The iframe document is generated by the host and loads only
|
parent DOM. The iframe document is generated by the host and loads only
|
||||||
the plugin bundle + its assets from the plugin's static path.
|
the plugin bundle + its assets from the plugin's static path.
|
||||||
- CSP on plugin frames: `default-src 'none'; script-src <plugin path>;
|
- CSP on plugin frames: `default-src 'none'; script-src <plugin path>;
|
||||||
img-src <plugin path> blob: data:; style-src <plugin path> 'unsafe-inline'`.
|
img-src <plugin path> blob: data:; style-src <plugin path> 'unsafe-inline';
|
||||||
No network access (`connect-src 'none'`) in v1.
|
connect-src <plugin path>; frame-src <plugin path>`.
|
||||||
|
Network and child frames are pinned to the plugin's OWN version-pinned
|
||||||
|
asset path — bundled sub-apps (the drawio editor) may lazy-load their
|
||||||
|
resources and run in a child iframe of the plugin's assets, but nothing
|
||||||
|
can reach the api or any external host. HTML assets are served with the
|
||||||
|
same CSP, so a packaged page cannot widen the rules; child frames also
|
||||||
|
inherit the `sandbox` attribute (opaque origin, no storage).
|
||||||
- Host ↔ plugin communication: `postMessage` RPC with structured-clone
|
- Host ↔ plugin communication: `postMessage` RPC with structured-clone
|
||||||
payloads. `packages/plugin-sdk` provides both sides:
|
payloads. `packages/plugin-sdk` provides both sides:
|
||||||
- plugin side: `createPlugin({ onRender, onEdit, … })`, typed `host.*`
|
- plugin side: `createPlugin({ onRender, onEdit, … })`, typed `host.*`
|
||||||
@ -85,13 +91,13 @@ All calls are mediated by the host and executed against the REST API with
|
|||||||
the **viewing user's** session — a plugin can never read more than the
|
the **viewing user's** session — a plugin can never read more than the
|
||||||
person looking at it could.
|
person looking at it could.
|
||||||
|
|
||||||
| Capability | Methods |
|
| Capability | Methods |
|
||||||
| ----------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
| ----------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
||||||
| `readCurrentPage` | `getOutline()`, `getContent()` (Markdown), `getMeta()` |
|
| `readCurrentPage` | `getOutline()`, `getContent()` (Markdown), `getMeta()` |
|
||||||
| `readPond` | `listPages()`, `getPageOutline(pageId)`, `getPageContent(pageId)` |
|
| `readPond` | `listPages()`, `getPageOutline(pageId)`, `getPageContent(pageId)` |
|
||||||
| `readBlock` | `getBlock(pageId, blockId)` — cross-page block embedding |
|
| `readBlock` | `getBlock(pageId, blockId)` — cross-page block embedding |
|
||||||
| `blockData` | `getData()` / `setData(data)` for the plugin's own block instance (writes go through the editor as a normal document change — requires the viewer to have write permission) |
|
| `blockData` | `getData()` / `setData(data)` for the plugin's own block instance (writes go through the editor as a normal document change — requires the viewer to have write permission) |
|
||||||
| `ui` | `resize(height)`, `openPage(pageId)` (host navigates), `toast(msgKey)`, `scrollToHeading(headingId)` (host scrolls to an outline entry, #77) |
|
| `ui` | `resize(height)`, `openPage(pageId)` (host navigates), `toast(msgKey)`, `scrollToHeading(headingId)` (host scrolls to an outline entry, #77), `enterFullscreen()`/`exitFullscreen()` (the frame becomes a viewport-covering overlay — drawio-class editors; destroy always restores) |
|
||||||
|
|
||||||
## Lifecycle & administration
|
## Lifecycle & administration
|
||||||
|
|
||||||
@ -117,6 +123,11 @@ person looking at it could.
|
|||||||
- `page-index` (`pageTool`): filtered page list by label.
|
- `page-index` (`pageTool`): filtered page list by label.
|
||||||
- `mermaid` (`block`): diagram block rendering Mermaid source — proves the
|
- `mermaid` (`block`): diagram block rendering Mermaid source — proves the
|
||||||
code-block path end to end (editing UI inside the sandbox).
|
code-block path end to end (editing UI inside the sandbox).
|
||||||
|
- `drawio` (`block`): draw.io diagrams — proves the bundled-app path: the
|
||||||
|
official draw.io editor ships as plugin assets (pinned release fetched at
|
||||||
|
build time into `vendor/`, gitignored) and runs fullscreen in the
|
||||||
|
sandbox; blocks store `{ xml, svg }`, render mode and exports use the
|
||||||
|
SVG snapshot.
|
||||||
|
|
||||||
These live in `packages/plugins/` in the monorepo, are built by CI, and
|
These live in `packages/plugins/` in the monorepo, are built by CI, and
|
||||||
double as the plugin-SDK integration tests.
|
double as the plugin-SDK integration tests.
|
||||||
|
|||||||
@ -30,7 +30,7 @@ export const CAPABILITY_METHODS = {
|
|||||||
readPond: ['listPages', 'getPageOutline', 'getPageContent'],
|
readPond: ['listPages', 'getPageOutline', 'getPageContent'],
|
||||||
readBlock: ['getBlock'],
|
readBlock: ['getBlock'],
|
||||||
blockData: ['getData', 'setData'],
|
blockData: ['getData', 'setData'],
|
||||||
ui: ['resize', 'openPage', 'toast', 'scrollToHeading'],
|
ui: ['resize', 'openPage', 'toast', 'scrollToHeading', 'enterFullscreen', 'exitFullscreen'],
|
||||||
} as const satisfies Record<Capability, readonly string[]>;
|
} as const satisfies Record<Capability, readonly string[]>;
|
||||||
|
|
||||||
/** Every host method name across all capabilities. */
|
/** Every host method name across all capabilities. */
|
||||||
|
|||||||
@ -60,6 +60,15 @@ export interface PluginHost {
|
|||||||
toast: (messageKey: string) => Promise<void>;
|
toast: (messageKey: string) => Promise<void>;
|
||||||
/** Scroll the host page to a heading by its outline id (issue #77). */
|
/** Scroll the host page to a heading by its outline id (issue #77). */
|
||||||
scrollToHeading: (headingId: string) => Promise<void>;
|
scrollToHeading: (headingId: string) => Promise<void>;
|
||||||
|
/**
|
||||||
|
* Promote this surface's frame to a viewport-covering overlay — for
|
||||||
|
* plugins whose editing UI needs the whole screen (drawio). The frame
|
||||||
|
* stays the same sandboxed iframe; only its geometry changes. Always
|
||||||
|
* pair with {@link exitFullscreen}; the host also restores normal
|
||||||
|
* geometry when the surface is destroyed.
|
||||||
|
*/
|
||||||
|
enterFullscreen: () => Promise<void>;
|
||||||
|
exitFullscreen: () => Promise<void>;
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
2
packages/plugins/drawio/.gitignore
vendored
Normal file
2
packages/plugins/drawio/.gitignore
vendored
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
vendor/
|
||||||
|
dist/
|
||||||
137
packages/plugins/drawio/build.mjs
Normal file
137
packages/plugins/drawio/build.mjs
Normal file
@ -0,0 +1,137 @@
|
|||||||
|
// Builds the installable draw.io plugin. Unlike the other reference plugins
|
||||||
|
// this one carries a bundled application: the official draw.io editor webapp
|
||||||
|
// (pinned release, fetched once into vendor/ — same pattern as the font
|
||||||
|
// build, deploy/fonts/build-fonts.mjs) ships as static assets and runs
|
||||||
|
// entirely inside the sandbox; the frame CSP pins every request to the
|
||||||
|
// plugin's own asset path, so nothing ever talks to diagrams.net.
|
||||||
|
import { execFileSync } from 'node:child_process';
|
||||||
|
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 DRAWIO_VERSION = '30.3.6';
|
||||||
|
const DRAWIO_TARBALL = `https://github.com/jgraph/drawio/archive/refs/tags/v${DRAWIO_VERSION}.tar.gz`;
|
||||||
|
|
||||||
|
const root = dirname(fileURLToPath(import.meta.url));
|
||||||
|
const manifest = JSON.parse(readFileSync(join(root, 'manifest.json'), 'utf8'));
|
||||||
|
const vendor = join(root, 'vendor');
|
||||||
|
const webapp = join(vendor, `drawio-${DRAWIO_VERSION}`, 'src', 'main', 'webapp');
|
||||||
|
|
||||||
|
// --- 1. Fetch + unpack the pinned draw.io release (cached in vendor/) -----
|
||||||
|
// In CI the vendor fetch is skipped (network + 60 MB — the fonts-build
|
||||||
|
// lesson): the controller bundle still builds, only the installable ZIP
|
||||||
|
// needs a dev machine (or a pre-populated vendor/ cache).
|
||||||
|
if (!existsSync(webapp)) {
|
||||||
|
if (process.env.CI) {
|
||||||
|
console.log('CI: skipping draw.io vendor fetch — bundling plugin.js only, no ZIP');
|
||||||
|
await bundleController();
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
mkdirSync(vendor, { recursive: true });
|
||||||
|
const tarball = join(vendor, `drawio-${DRAWIO_VERSION}.tar.gz`);
|
||||||
|
if (!existsSync(tarball)) {
|
||||||
|
console.log(`fetching draw.io v${DRAWIO_VERSION} …`);
|
||||||
|
execFileSync('curl', ['-sfL', '-o', tarball, DRAWIO_TARBALL], { stdio: 'inherit' });
|
||||||
|
}
|
||||||
|
execFileSync('tar', ['-xzf', tarball, '-C', vendor, `drawio-${DRAWIO_VERSION}/src/main/webapp`], {
|
||||||
|
stdio: 'inherit',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- 2. Select the runtime subset -----------------------------------------
|
||||||
|
// Included: everything the production bootstrap chain and its lazy loaders
|
||||||
|
// reach. Excluded: dev sources (js/diagramly, js/grapheditor — app.min.js
|
||||||
|
// contains them), the embed.diagrams.net integrations bundle, standalone
|
||||||
|
// viewers, MathJax (math typesetting off), file templates, server-side
|
||||||
|
// leftovers, and the PWA service worker.
|
||||||
|
const EXCLUDE_TOP = new Set([
|
||||||
|
'WEB-INF',
|
||||||
|
'META-INF',
|
||||||
|
'templates',
|
||||||
|
'math4',
|
||||||
|
'plugins',
|
||||||
|
'service-worker.js',
|
||||||
|
'service-worker.js.map',
|
||||||
|
'workbox-05b6c01b.js',
|
||||||
|
'workbox-05b6c01b.js.map',
|
||||||
|
'manifest.json', // PWA manifest, collides with the plugin manifest anyway
|
||||||
|
]);
|
||||||
|
const EXCLUDE_JS = new Set([
|
||||||
|
'integrate.min.js',
|
||||||
|
'viewer.min.js',
|
||||||
|
'viewer-static.min.js',
|
||||||
|
'diagramly',
|
||||||
|
'grapheditor',
|
||||||
|
'embed.dev.js',
|
||||||
|
]);
|
||||||
|
// Editor UI languages shipped: keep the product languages + the fallback.
|
||||||
|
const RESOURCE_KEEP = new Set(['dia.txt', 'dia_de.txt']);
|
||||||
|
|
||||||
|
/** Recursively collect files below `dir` (absolute), applying the filters. */
|
||||||
|
function collect(dir, baseRel) {
|
||||||
|
const files = {};
|
||||||
|
for (const name of readdirSync(dir)) {
|
||||||
|
const abs = join(dir, name);
|
||||||
|
const rel = baseRel === '' ? name : `${baseRel}/${name}`;
|
||||||
|
const top = rel.split('/')[0];
|
||||||
|
if (baseRel === '' && EXCLUDE_TOP.has(name)) continue;
|
||||||
|
if (top === 'js' && rel.split('/').length === 2 && EXCLUDE_JS.has(name)) continue;
|
||||||
|
if (top === 'resources' && !RESOURCE_KEEP.has(name) && !statSync(abs).isDirectory()) continue;
|
||||||
|
if (name.endsWith('.map')) continue;
|
||||||
|
if (statSync(abs).isDirectory()) {
|
||||||
|
Object.assign(files, collect(abs, rel));
|
||||||
|
} else {
|
||||||
|
files[rel] = readFileSync(abs);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return files;
|
||||||
|
}
|
||||||
|
|
||||||
|
const drawioFiles = collect(webapp, '');
|
||||||
|
|
||||||
|
// --- 3. Bundle the plugin controller ---------------------------------------
|
||||||
|
async function bundleController() {
|
||||||
|
mkdirSync(join(root, 'dist'), { recursive: true });
|
||||||
|
await build({
|
||||||
|
entryPoints: [join(root, 'src/plugin.ts')],
|
||||||
|
bundle: true,
|
||||||
|
format: 'esm',
|
||||||
|
outfile: join(root, 'dist/plugin.js'),
|
||||||
|
minify: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
await bundleController();
|
||||||
|
|
||||||
|
// --- 4. 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));
|
||||||
|
}
|
||||||
|
for (const [rel, bytes] of Object.entries(drawioFiles)) {
|
||||||
|
files[`assets/drawio/${rel}`] = bytes;
|
||||||
|
}
|
||||||
|
|
||||||
|
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)`,
|
||||||
|
);
|
||||||
8
packages/plugins/drawio/i18n/de.json
Normal file
8
packages/plugins/drawio/i18n/de.json
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"empty": "Noch kein Diagramm.",
|
||||||
|
"editButton": "Diagramm im Vollbild bearbeiten",
|
||||||
|
"createButton": "Diagramm erstellen",
|
||||||
|
"loading": "draw.io wird geladen …",
|
||||||
|
"saved": "Gespeichert — „Fertig“ schließt den Bearbeiten-Modus.",
|
||||||
|
"renderHint": "Zum Bearbeiten in den Bearbeiten-Modus der Seite wechseln."
|
||||||
|
}
|
||||||
8
packages/plugins/drawio/i18n/en.json
Normal file
8
packages/plugins/drawio/i18n/en.json
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"empty": "No diagram yet.",
|
||||||
|
"editButton": "Edit diagram in fullscreen",
|
||||||
|
"createButton": "Create diagram",
|
||||||
|
"loading": "Loading draw.io …",
|
||||||
|
"saved": "Saved — \"Done\" closes edit mode.",
|
||||||
|
"renderHint": "Switch the page to edit mode to change the diagram."
|
||||||
|
}
|
||||||
19
packages/plugins/drawio/manifest.json
Normal file
19
packages/plugins/drawio/manifest.json
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
{
|
||||||
|
"id": "drawio",
|
||||||
|
"name": "draw.io Diagrams",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"apiVersion": "1",
|
||||||
|
"kind": "code",
|
||||||
|
"extensionPoints": [
|
||||||
|
{
|
||||||
|
"type": "block",
|
||||||
|
"id": "diagram",
|
||||||
|
"title": { "de": "draw.io-Diagramm", "en": "draw.io diagram" }
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"permissions": ["blockData", "ui"],
|
||||||
|
"fallback": { "type": "text", "value": "[draw.io diagram]" },
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"homepage": "https://www.drawio.com",
|
||||||
|
"i18n": { "de": "i18n/de.json", "en": "i18n/en.json" }
|
||||||
|
}
|
||||||
20
packages/plugins/drawio/package.json
Normal file
20
packages/plugins/drawio/package.json
Normal file
@ -0,0 +1,20 @@
|
|||||||
|
{
|
||||||
|
"name": "@dorfteich/plugin-drawio",
|
||||||
|
"version": "0.0.0",
|
||||||
|
"private": true,
|
||||||
|
"description": "Reference block plugin: draw.io diagrams — fullscreen editing with the bundled draw.io editor, inline SVG rendering",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"scripts": {
|
||||||
|
"build": "node build.mjs",
|
||||||
|
"typecheck": "tsc --noEmit",
|
||||||
|
"test": "vitest run --passWithNoTests"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@dorfteich/plugin-sdk": "workspace:*",
|
||||||
|
"@types/node": "^26.1.0",
|
||||||
|
"esbuild": "^0.24.0",
|
||||||
|
"fflate": "^0.8.2",
|
||||||
|
"typescript": "^5.7.0",
|
||||||
|
"vitest": "^3.0.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
219
packages/plugins/drawio/src/plugin.ts
Normal file
219
packages/plugins/drawio/src/plugin.ts
Normal file
@ -0,0 +1,219 @@
|
|||||||
|
import { createPlugin, windowTransport, type RenderContext } from '@dorfteich/plugin-sdk';
|
||||||
|
|
||||||
|
import de from '../i18n/de.json';
|
||||||
|
import en from '../i18n/en.json';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* draw.io reference plugin: diagrams edited in the REAL draw.io editor,
|
||||||
|
* which the package bundles as static assets — nothing is ever loaded from
|
||||||
|
* diagrams.net; the sandbox CSP pins every request to the plugin's own
|
||||||
|
* version-pinned asset path.
|
||||||
|
*
|
||||||
|
* Block data: `{ xml, svg }` — `xml` is the draw.io source (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 diagram without running any diagram code.
|
||||||
|
*
|
||||||
|
* Render mode shows the snapshot. Edit mode shows the snapshot plus an
|
||||||
|
* "edit in fullscreen" button (an empty block opens the editor
|
||||||
|
* immediately): the plugin asks the host for a viewport-covering frame
|
||||||
|
* (`ui.enterFullscreen`), boots the bundled editor in a child iframe of
|
||||||
|
* its own assets, and speaks draw.io's JSON embed protocol with it —
|
||||||
|
* Save & Exit exports the SVG, persists `{ xml, svg }` through
|
||||||
|
* `blockData.setData`, and returns the frame to its inline size.
|
||||||
|
*/
|
||||||
|
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 DiagramData {
|
||||||
|
xml?: string;
|
||||||
|
svg?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function dataOf(context: RenderContext): DiagramData {
|
||||||
|
return context.data && typeof context.data === 'object' ? (context.data as DiagramData) : {};
|
||||||
|
}
|
||||||
|
|
||||||
|
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: DiagramData, locale: string): HTMLElement {
|
||||||
|
if (data.svg && data.svg.trim() !== '') {
|
||||||
|
const holder = document.createElement('div');
|
||||||
|
holder.className = 'dt-drawio__snapshot';
|
||||||
|
holder.innerHTML = data.svg;
|
||||||
|
const svg = holder.querySelector('svg');
|
||||||
|
// The export SVG carries fixed pixel dimensions; scale down to fit.
|
||||||
|
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-drawio dt-drawio--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-drawio dt-drawio--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 diagram yet goes straight into the editor — the
|
||||||
|
// user just inserted it and clearly wants to draw.
|
||||||
|
if (!data.svg && !data.xml) void openEditor(context);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The active fullscreen editing session, if any. */
|
||||||
|
let session: { frame: HTMLIFrameElement; onMessage: (event: MessageEvent) => void } | null = null;
|
||||||
|
|
||||||
|
function closeEditor(): void {
|
||||||
|
if (!session) return;
|
||||||
|
window.removeEventListener('message', session.onMessage);
|
||||||
|
session.frame.remove();
|
||||||
|
session = null;
|
||||||
|
void host.ui.exitFullscreen();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openEditor(context: RenderContext): Promise<void> {
|
||||||
|
if (session) return;
|
||||||
|
const data = dataOf(context);
|
||||||
|
const locale = context.locale.split('-')[0] ?? 'en';
|
||||||
|
|
||||||
|
await host.ui.enterFullscreen();
|
||||||
|
|
||||||
|
const frame = document.createElement('iframe');
|
||||||
|
frame.className = 'dt-drawio__editor';
|
||||||
|
frame.style.cssText =
|
||||||
|
'position:fixed;inset:0;width:100%;height:100%;border:none;background:#fff;';
|
||||||
|
frame.title = 'draw.io';
|
||||||
|
// The bundled editor, resolved relative to this frame's document — i.e.
|
||||||
|
// from the same version-pinned plugin asset path the CSP allows.
|
||||||
|
const params = new URLSearchParams({
|
||||||
|
embed: '1',
|
||||||
|
proto: 'json',
|
||||||
|
spin: '1',
|
||||||
|
libraries: '1',
|
||||||
|
saveAndExit: '1',
|
||||||
|
noSaveBtn: '1',
|
||||||
|
lang: locale,
|
||||||
|
});
|
||||||
|
frame.src = `./assets/drawio/index.html?${params.toString()}`;
|
||||||
|
|
||||||
|
let latestXml = data.xml ?? '';
|
||||||
|
const post = (message: object): void => {
|
||||||
|
frame.contentWindow?.postMessage(JSON.stringify(message), '*');
|
||||||
|
};
|
||||||
|
|
||||||
|
const onMessage = (event: MessageEvent): void => {
|
||||||
|
if (event.source !== frame.contentWindow || typeof event.data !== 'string') return;
|
||||||
|
let message: { event?: string; xml?: string; data?: string };
|
||||||
|
try {
|
||||||
|
message = JSON.parse(event.data) as typeof message;
|
||||||
|
} catch {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
switch (message.event) {
|
||||||
|
case 'init':
|
||||||
|
post({ action: 'load', xml: latestXml, autosave: 0 });
|
||||||
|
break;
|
||||||
|
case 'save':
|
||||||
|
// Save (& Exit): remember the source, then ask for the SVG render —
|
||||||
|
// the pair is persisted together when the export answer arrives.
|
||||||
|
latestXml = message.xml ?? latestXml;
|
||||||
|
post({ action: 'export', format: 'xmlsvg' });
|
||||||
|
break;
|
||||||
|
case 'export': {
|
||||||
|
const svg = decodeSvgDataUri(message.data ?? '');
|
||||||
|
latestXml = message.xml ?? latestXml;
|
||||||
|
void host.blockData.setData({ xml: latestXml, svg } satisfies DiagramData).then(() => {
|
||||||
|
closeEditor();
|
||||||
|
finishEdit({ xml: latestXml, svg }, context.locale);
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'exit':
|
||||||
|
closeEditor();
|
||||||
|
finishEdit(dataOf(context), context.locale);
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
window.addEventListener('message', onMessage);
|
||||||
|
session = { frame, onMessage };
|
||||||
|
document.body.appendChild(frame);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Redraws the inline edit surface after the fullscreen editor closed. */
|
||||||
|
function finishEdit(data: DiagramData, locale: string): void {
|
||||||
|
document.body.textContent = '';
|
||||||
|
document.body.className = 'dt-drawio dt-drawio--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();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** draw.io's `xmlsvg` export arrives as a base64 data URI; the block stores
|
||||||
|
* raw SVG markup (like the mermaid plugin) so the api-side fallback renderer
|
||||||
|
* can sanitize and inline it into exports and the public read view. */
|
||||||
|
function decodeSvgDataUri(dataUri: string): string {
|
||||||
|
const base64 = dataUri.split(',')[1] ?? '';
|
||||||
|
const bytes = Uint8Array.from(atob(base64), (char) => char.charCodeAt(0));
|
||||||
|
return new TextDecoder().decode(bytes);
|
||||||
|
}
|
||||||
11
packages/plugins/drawio/tsconfig.json
Normal file
11
packages/plugins/drawio/tsconfig.json
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
{
|
||||||
|
"extends": "../../../tsconfig.base.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "Bundler",
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"lib": ["ES2022", "DOM"]
|
||||||
|
},
|
||||||
|
"include": ["src", "*.ts"]
|
||||||
|
}
|
||||||
21
pnpm-lock.yaml
generated
21
pnpm-lock.yaml
generated
@ -383,6 +383,27 @@ importers:
|
|||||||
specifier: ^3.0.0
|
specifier: ^3.0.0
|
||||||
version: 3.2.6(@types/node@26.1.0)(jiti@2.7.0)(jsdom@26.1.0)(terser@5.48.0)(tsx@4.23.0)
|
version: 3.2.6(@types/node@26.1.0)(jiti@2.7.0)(jsdom@26.1.0)(terser@5.48.0)(tsx@4.23.0)
|
||||||
|
|
||||||
|
packages/plugins/drawio:
|
||||||
|
devDependencies:
|
||||||
|
'@dorfteich/plugin-sdk':
|
||||||
|
specifier: workspace:*
|
||||||
|
version: link:../../plugin-sdk
|
||||||
|
'@types/node':
|
||||||
|
specifier: ^26.1.0
|
||||||
|
version: 26.1.0
|
||||||
|
esbuild:
|
||||||
|
specifier: ^0.24.0
|
||||||
|
version: 0.24.2
|
||||||
|
fflate:
|
||||||
|
specifier: ^0.8.2
|
||||||
|
version: 0.8.3
|
||||||
|
typescript:
|
||||||
|
specifier: ^5.7.0
|
||||||
|
version: 5.9.3
|
||||||
|
vitest:
|
||||||
|
specifier: ^3.0.0
|
||||||
|
version: 3.2.6(@types/node@26.1.0)(jiti@2.7.0)(jsdom@26.1.0)(terser@5.48.0)(tsx@4.23.0)
|
||||||
|
|
||||||
packages/plugins/mermaid:
|
packages/plugins/mermaid:
|
||||||
devDependencies:
|
devDependencies:
|
||||||
'@dorfteich/plugin-sdk':
|
'@dorfteich/plugin-sdk':
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user