diff --git a/backend/api/schemas/block.ts b/backend/api/schemas/block.ts index 67e531ef7..8d8553651 100644 --- a/backend/api/schemas/block.ts +++ b/backend/api/schemas/block.ts @@ -42,6 +42,11 @@ export async function registerSchemas(app: FastifyInstance): Promise { description: 'Body the editor writes between the opening and closing lines when inserting the block, for a block whose content is other blocks. Empty for a block that takes none.' }, + contentEditor: { + type: 'string', + description: + "Names an editor for the block's body, which the markdown editor offers as an \"Edit Content\" lens above the block alongside \"Edit Block Parameters\". A key the frontend resolves to a component, for a block whose body is a fenced source the props form cannot describe. Empty for a block that names none, which is most of them." + }, props: { type: 'array', description: diff --git a/backend/locales/en.json b/backend/locales/en.json index 280cef032..db65d8c6e 100644 --- a/backend/locales/en.json +++ b/backend/locales/en.json @@ -1673,6 +1673,11 @@ "editor.assets.uploadFailed": "File upload failed.", "editor.backToEditor": "Back to Editor", "editor.blockNotEnabled": "This block is not enabled for this site, and will be removed when the page is saved. Blocks are managed in the administration area.", + "editor.blockContent.drawioLoading": "Loading the draw.io editor...", + "editor.blockContent.drawioTitle": "Draw.io editor", + "editor.blockContent.drawioUnreachable": "The draw.io editor could not be loaded. Check that this address is reachable from your browser.", + "editor.blockContent.title": "Edit Block Content", + "editor.blockContent.unknownEditor": "This block asks to be edited with something this wiki does not have.", "editor.blockParams.title": "Edit Block Parameters - {name}", "editor.blockPicker.blockUnavailable": "This block is not available on this site. Blocks are managed in the administration area.", "editor.blockPicker.insert": "Insert Block", @@ -1758,6 +1763,7 @@ "editor.markup.definitionListTerm": "Term", "editor.markup.distractionFreeMode": "Distraction Free Mode", "editor.markup.editBlock": "Edit Block Parameters", + "editor.markup.editBlockContent": "Edit Content", "editor.markup.editTable": "Edit in Table Editor", "editor.markup.header": "Header", "editor.markup.headerLevel": "Header {level}", diff --git a/backend/models/blocks.ts b/backend/models/blocks.ts index e37215b31..d021392ee 100644 --- a/backend/models/blocks.ts +++ b/backend/models/blocks.ts @@ -31,6 +31,18 @@ export interface BlockDefinition { isChild?: boolean /** Body the editor writes between the opening and closing lines when inserting the block. */ template?: string + /** + * Names an editor for the block's BODY, which the markdown editor then offers as a second lens + * above the block — "Edit Content", beside "Edit Block Parameters". + * + * For a block whose body is a fenced source the props form has nothing to say about: a diagram, a + * drawing. The value is a key the frontend resolves to a component, not a component or a URL, so + * that what a block declares stays a plain literal the manifest can be read out of. + * + * Absent for every other block, and absent is the answer: a body nobody named an editor for is + * edited in the page like any other content. + */ + contentEditor?: string } /** A block row as exposed by the API, with what its component says it can be given. */ @@ -45,6 +57,8 @@ export interface SiteBlock { config: Record props: BlockProp[] template: string + /** Empty for a block that names no body editor, which is most of them. */ + contentEditor: string } const blockSelection = { @@ -278,7 +292,8 @@ class Blocks { return { ...row, props: definition?.props ?? [], - template: definition?.template ?? '' + template: definition?.template ?? '', + contentEditor: definition?.contentEditor ?? '' } }) } diff --git a/blocks/block-drawio/component.js b/blocks/block-drawio/component.js new file mode 100644 index 000000000..dd936c692 --- /dev/null +++ b/blocks/block-drawio/component.js @@ -0,0 +1,257 @@ +import { LitElement, html, css } from 'lit' +import { deflateRaw } from 'pako' +import { DarkMode } from '../shared/theme.js' + +/** + * The draw.io that answers when the block names no server. + * + * Two hosts, because draw.io publishes two deployments of the same application: `viewer` is the one + * that opens a diagram read-only from a link, `embed` the one the editor talks to over postMessage + * (see `BlockContentDrawio.vue`). A self-hosted draw.io is one deployment doing both, which is why the + * `server` prop replaces both and defaults to neither. + */ +const PUBLIC_VIEWER = 'https://viewer.diagrams.net' + +/** How many bytes are turned into characters at a time, below. */ +const CHUNK_SIZE = 0x8000 + +/** + * A drawing as draw.io writes it into a link fragment. + * + * This is `Graph.compress` from draw.io itself, and every step of it matters to the other end: + * percent-encode, raw deflate (no zlib header, unlike Kroki's), then base64. The percent-encoding + * comes FIRST and is not a transport detail — draw.io decompresses and then `decodeURIComponent`s, so + * a diagram deflated without it comes back mangled at every non-ASCII character. + * + * `btoa` takes a string, and spreading a whole drawing into `String.fromCharCode` at once overflows + * the stack somewhere in the tens of thousands of bytes — hence a chunk at a time, as `block-kroki` + * does for the same reason. + */ +function compressForUrl(xml) { + const bytes = deflateRaw(encodeURIComponent(xml)) + let binary = '' + for (let i = 0; i < bytes.length; i += CHUNK_SIZE) { + binary += String.fromCharCode(...bytes.subarray(i, i + CHUNK_SIZE)) + } + return btoa(binary) +} + +/** + * Block Draw.io + */ +export class BlockDrawioElement extends LitElement { + /** + * Metadata for the admin area and the editor's block picker. Collected at build time into + * `compiled/blocks.manifest.json`, which the server reads to register the block. Values must be + * plain literals. See `props` in `block-index` for what the picker does with that list. + */ + static definition = { + block: 'drawio', + name: 'Draw.io', + description: 'A diagram drawn in draw.io, stored as its own XML and edited on a canvas.', + icon: 'web-design', + /* + Fenced, and `xml` because that is what a draw.io document is — so an author reading the page + source gets it highlighted, and markdown keeps its hands off it. Without the fence a drawing is + a document full of `<` and `_` and lines beginning with spaces, every one of which means + something to markdown. + */ + template: `\`\`\`xml + + + + + + + + + + +\`\`\``, + /* + The body is a drawing, so it is edited on a canvas rather than typed: this names the editor the + markdown editor offers above the block, as a second lens beside "Edit Block Parameters". The key + is resolved to a component by `BlockContentEditorOverlay`; nothing about draw.io reaches the + block system itself. + */ + contentEditor: 'drawio', + props: [ + { + name: 'server', + type: 'string', + label: 'Server', + hint: 'A self-hosted draw.io to draw and display with. The public diagrams.net services when left empty.' + }, + { + name: 'height', + type: 'number', + label: 'Height', + hint: 'How tall the diagram is, in pixels.', + default: 420 + }, + { + name: 'caption', + type: 'string', + label: 'Caption', + hint: 'Shown under the diagram.' + }, + { + name: 'align', + type: 'select', + label: 'Alignment', + options: ['left', 'center'], + default: 'left' + } + ] + } + + static get styles() { + return css` + :host { + display: block; + } + + /* -> The gap below the block. On this element rather than :host: see block-index. */ + .diagram, + .error { + margin-bottom: 16px; + } + + .diagram { + display: flex; + flex-direction: column; + align-items: flex-start; + gap: 8px; + } + .diagram.is-center { + align-items: center; + } + + /* + The frame carries the border rather than the iframe: draw.io's lightbox paints its own + background over the whole box, so a border on the iframe itself is drawn under it at the + corners. + */ + .frame { + width: 100%; + max-width: 100%; + border: 1px solid rgba(0, 0, 0, 0.12); + border-radius: 5px; + overflow: hidden; + background-color: #fff; + } + :host([dark]) .frame { + border-color: rgba(255, 255, 255, 0.12); + background-color: #18191a; + } + + iframe { + display: block; + width: 100%; + height: 100%; + border: 0; + } + + .caption { + font-size: 0.8em; + opacity: 0.7; + } + + .error { + padding: 12px; + border-radius: 5px; + border: 1px solid #c10015; + color: #c10015; + font-size: 0.9em; + } + ` + } + + static get properties() { + return { + /** A self-hosted draw.io, standing in for both public services. */ + server: { type: String }, + /** How tall the frame is, in pixels. */ + height: { type: Number }, + /** Shown under the diagram. */ + caption: { type: String }, + /** `left` or `center`. */ + align: { type: String }, + + _source: { state: true } + } + } + + constructor() { + super() + this.server = '' + this.height = 420 + this.caption = '' + this.align = 'left' + this._source = '' + /* + The lightbox reads its theme from the URL at load, so the frame is rebuilt when the reader + switches — which for a read-only diagram costs nothing, unlike doing the same to the editor. + */ + this._darkMode = new DarkMode(this) + } + + connectedCallback() { + super.connectedCallback() + this._readSource() + } + + /** + * The drawing, out of the body markdown left behind. + * + * `textContent` on the `
` is what undoes the escaping the fence went through — the same three
+   * lines every source block in this wiki uses.
+   */
+  _readSource() {
+    const fence = this.querySelector('pre')
+    this._source = ((fence ?? this).textContent ?? '').trim()
+  }
+
+  /**
+   * The link the frame opens.
+   *
+   * `lightbox=1` is draw.io's read-only viewer: no editing, no menus, just the drawing with zoom and
+   * a layers control. `edit=_blank` is deliberately absent — the pencil it adds opens the diagram in
+   * a copy of draw.io that has nowhere to save to, and the way to change a drawing here is the page.
+   */
+  _url() {
+    const server = (this.server || '').trim().replace(/\/+$/, '') || PUBLIC_VIEWER
+    const params = new URLSearchParams({
+      lightbox: '1',
+      nav: '1',
+      ui: this._darkMode.isDark ? 'dark' : 'kennedy'
+    })
+    return `${server}/?${params.toString()}#R${encodeURIComponent(compressForUrl(this._source))}`
+  }
+
+  render() {
+    if (!this._source) {
+      return html`
+        
+ This diagram is empty. Draw one with the Edit Content link above the block in the editor. +
+ ` + } + const height = Number(this.height) > 0 ? Number(this.height) : 420 + return html` +
+
+ +
+ ${this.caption ? html`
${this.caption}
` : ''} +
+ ` + } +} + +window.customElements.define('block-drawio', BlockDrawioElement) diff --git a/frontend/src/components/BlockContentDrawio.vue b/frontend/src/components/BlockContentDrawio.vue new file mode 100644 index 000000000..e9b7e74c1 --- /dev/null +++ b/frontend/src/components/BlockContentDrawio.vue @@ -0,0 +1,233 @@ +