diff --git a/apps/api/src/plugins/plugin-assets.controller.ts b/apps/api/src/plugins/plugin-assets.controller.ts index c36f5a4..698c5c8 100644 --- a/apps/api/src/plugins/plugin-assets.controller.ts +++ b/apps/api/src/plugins/plugin-assets.controller.ts @@ -33,6 +33,10 @@ const CONTENT_TYPES: Record = { gif: 'image/gif', webp: 'image/webp', 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 { @@ -116,6 +120,18 @@ export class PluginAssetsController { response.set('X-Content-Type-Options', 'nosniff'); 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 // bundle cross-origin; allow it. These are public, immutable client // assets, never user data — a wildcard is safe. diff --git a/apps/api/src/plugins/plugin-frame.ts b/apps/api/src/plugins/plugin-frame.ts index 4ef150a..5242001 100644 --- a/apps/api/src/plugins/plugin-frame.ts +++ b/apps/api/src/plugins/plugin-frame.ts @@ -30,7 +30,15 @@ export function buildPluginFrameCsp(assetBase: string): string { `style-src ${assetBase} 'unsafe-inline'`, `img-src ${assetBase} data: blob:`, `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'`, `form-action 'none'`, ].join('; '); diff --git a/apps/api/src/plugins/plugin-package.service.test.ts b/apps/api/src/plugins/plugin-package.service.test.ts index 21575af..2a941f9 100644 --- a/apps/api/src/plugins/plugin-package.service.test.ts +++ b/apps/api/src/plugins/plugin-package.service.test.ts @@ -151,7 +151,7 @@ describe('PluginPackageService.parse — each invalid class', () => { }); 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( zip({ 'manifest.json': enc(JSON.stringify(codeManifest)), 'plugin.js': huge }), 'plugin_too_large', diff --git a/apps/api/src/plugins/plugin.constants.ts b/apps/api/src/plugins/plugin.constants.ts index b5e6e33..ff1e7ac 100644 --- a/apps/api/src/plugins/plugin.constants.ts +++ b/apps/api/src/plugins/plugin.constants.ts @@ -1,10 +1,12 @@ import type { PluginErrorCode } from '@dorfteich/shared'; /** 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. */ -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. */ export const MANIFEST_FILE = 'manifest.json'; diff --git a/apps/api/src/plugins/plugins.e2e.db.test.ts b/apps/api/src/plugins/plugins.e2e.db.test.ts index 20e82db..4743eda 100644 --- a/apps/api/src/plugins/plugins.e2e.db.test.ts +++ b/apps/api/src/plugins/plugins.e2e.db.test.ts @@ -140,7 +140,10 @@ describe.skipIf(!hasTestDb)('plugins install (e2e, issue #71)', () => { const csp = frame.headers['content-security-policy']; expect(csp).toContain(`default-src 'none'`); 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. await api().get('/api/v1/plugins/framer/9.9.9/frame').expect(404); diff --git a/apps/web/src/plugins/sandbox-host.ts b/apps/web/src/plugins/sandbox-host.ts index dc9d878..212a274 100644 --- a/apps/web/src/plugins/sandbox-host.ts +++ b/apps/web/src/plugins/sandbox-host.ts @@ -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)); 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. ...options.capabilities, }; diff --git a/apps/web/src/styles/base.css b/apps/web/src/styles/base.css index 56678db..8544c1b 100644 --- a/apps/web/src/styles/base.css +++ b/apps/web/src/styles/base.css @@ -2670,6 +2670,21 @@ button { 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-page { max-width: 32rem; diff --git a/docs/architecture/plugin-architecture.md b/docs/architecture/plugin-architecture.md index 1156f49..fd87cd8 100644 --- a/docs/architecture/plugin-architecture.md +++ b/docs/architecture/plugin-architecture.md @@ -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 the plugin bundle + its assets from the plugin's static path. - CSP on plugin frames: `default-src 'none'; script-src ; -img-src blob: data:; style-src 'unsafe-inline'`. - No network access (`connect-src 'none'`) in v1. +img-src blob: data:; style-src 'unsafe-inline'; +connect-src ; frame-src `. + 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 payloads. `packages/plugin-sdk` provides both sides: - 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 person looking at it could. -| Capability | Methods | -| ----------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `readCurrentPage` | `getOutline()`, `getContent()` (Markdown), `getMeta()` | -| `readPond` | `listPages()`, `getPageOutline(pageId)`, `getPageContent(pageId)` | -| `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) | -| `ui` | `resize(height)`, `openPage(pageId)` (host navigates), `toast(msgKey)`, `scrollToHeading(headingId)` (host scrolls to an outline entry, #77) | +| Capability | Methods | +| ----------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `readCurrentPage` | `getOutline()`, `getContent()` (Markdown), `getMeta()` | +| `readPond` | `listPages()`, `getPageOutline(pageId)`, `getPageContent(pageId)` | +| `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) | +| `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 @@ -117,6 +123,11 @@ person looking at it could. - `page-index` (`pageTool`): filtered page list by label. - `mermaid` (`block`): diagram block rendering Mermaid source — proves the 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 double as the plugin-SDK integration tests. diff --git a/packages/plugin-sdk/src/capabilities.ts b/packages/plugin-sdk/src/capabilities.ts index fcf6c00..ee7b901 100644 --- a/packages/plugin-sdk/src/capabilities.ts +++ b/packages/plugin-sdk/src/capabilities.ts @@ -30,7 +30,7 @@ export const CAPABILITY_METHODS = { readPond: ['listPages', 'getPageOutline', 'getPageContent'], readBlock: ['getBlock'], blockData: ['getData', 'setData'], - ui: ['resize', 'openPage', 'toast', 'scrollToHeading'], + ui: ['resize', 'openPage', 'toast', 'scrollToHeading', 'enterFullscreen', 'exitFullscreen'], } as const satisfies Record; /** Every host method name across all capabilities. */ diff --git a/packages/plugin-sdk/src/plugin.ts b/packages/plugin-sdk/src/plugin.ts index 733c6d5..220acf0 100644 --- a/packages/plugin-sdk/src/plugin.ts +++ b/packages/plugin-sdk/src/plugin.ts @@ -60,6 +60,15 @@ export interface PluginHost { toast: (messageKey: string) => Promise; /** Scroll the host page to a heading by its outline id (issue #77). */ scrollToHeading: (headingId: string) => Promise; + /** + * 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; + exitFullscreen: () => Promise; }; } diff --git a/packages/plugins/drawio/.gitignore b/packages/plugins/drawio/.gitignore new file mode 100644 index 0000000..224f6c1 --- /dev/null +++ b/packages/plugins/drawio/.gitignore @@ -0,0 +1,2 @@ +vendor/ +dist/ diff --git a/packages/plugins/drawio/build.mjs b/packages/plugins/drawio/build.mjs new file mode 100644 index 0000000..24e44aa --- /dev/null +++ b/packages/plugins/drawio/build.mjs @@ -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)`, +); diff --git a/packages/plugins/drawio/i18n/de.json b/packages/plugins/drawio/i18n/de.json new file mode 100644 index 0000000..d652831 --- /dev/null +++ b/packages/plugins/drawio/i18n/de.json @@ -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." +} diff --git a/packages/plugins/drawio/i18n/en.json b/packages/plugins/drawio/i18n/en.json new file mode 100644 index 0000000..1a25eb9 --- /dev/null +++ b/packages/plugins/drawio/i18n/en.json @@ -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." +} diff --git a/packages/plugins/drawio/manifest.json b/packages/plugins/drawio/manifest.json new file mode 100644 index 0000000..88a61f8 --- /dev/null +++ b/packages/plugins/drawio/manifest.json @@ -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" } +} diff --git a/packages/plugins/drawio/package.json b/packages/plugins/drawio/package.json new file mode 100644 index 0000000..60294ca --- /dev/null +++ b/packages/plugins/drawio/package.json @@ -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" + } +} diff --git a/packages/plugins/drawio/src/plugin.ts b/packages/plugins/drawio/src/plugin.ts new file mode 100644 index 0000000..a6eb633 --- /dev/null +++ b/packages/plugins/drawio/src/plugin.ts @@ -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> = { 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 { + 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); +} diff --git a/packages/plugins/drawio/tsconfig.json b/packages/plugins/drawio/tsconfig.json new file mode 100644 index 0000000..1dbe32b --- /dev/null +++ b/packages/plugins/drawio/tsconfig.json @@ -0,0 +1,11 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "module": "ESNext", + "moduleResolution": "Bundler", + "resolveJsonModule": true, + "noEmit": true, + "lib": ["ES2022", "DOM"] + }, + "include": ["src", "*.ts"] +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 205e6dd..2cddbfc 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -383,6 +383,27 @@ importers: 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/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: devDependencies: '@dorfteich/plugin-sdk':