]> BookStack Code Mirror - hacks/commitdiff
Added interactive drawings hack
authorDan Brown <redacted>
Sun, 22 Jun 2025 11:43:00 +0000 (12:43 +0100)
committerDan Brown <redacted>
Sun, 22 Jun 2025 11:43:00 +0000 (12:43 +0100)
content/interactive-drawings/functions.php [new file with mode: 0644]
content/interactive-drawings/index.md [new file with mode: 0644]
content/interactive-drawings/layouts/parts/base-body-start.blade.php [new file with mode: 0644]

diff --git a/content/interactive-drawings/functions.php b/content/interactive-drawings/functions.php
new file mode 100644 (file)
index 0000000..25bacb4
--- /dev/null
@@ -0,0 +1,12 @@
+<?php
+
+use BookStack\Facades\Theme;
+use BookStack\Theming\ThemeEvents;
+
+// Update the application configuration to allow diagrams.net
+// viewer as an approved iframe source.
+Theme::listen(ThemeEvents::APP_BOOT, function () {
+    $iframeSources = config()->get('app.iframe_sources');
+    $iframeSources .= ' https://viewer.diagrams.net';
+    config()->set('app.iframe_sources', $iframeSources);
+});
diff --git a/content/interactive-drawings/index.md b/content/interactive-drawings/index.md
new file mode 100644 (file)
index 0000000..f233caf
--- /dev/null
@@ -0,0 +1,25 @@
++++
+title = "Interactive Embedded Page Drawings"
+author = "@ssddanbrown"
+date = 2025-06-22T00:00:00Z
+updated = 2025-06-22T00:00:00Z
+tested = "v25.05.1"
++++
+
+This hack will, on page view, attempt to convert any drawing images into interactive embedded drawing viewers so that you'll be able to pan & zoom around the drawings while also being able to interact with things like links within the drawings.
+
+#### Considerations
+
+- The drawings are loaded via the external "https://viewer.diagrams.net" site/service, and therefore this relies on that service being accessible from the browser, and drawing data is sent to that domain/location.
+- This hack will dynamically alter the `ALLOWED_IFRAME_SOURCES` option to allow the needed embedded viewers.
+- The embedded viewers will take up more space than the original drawing, as extra room is needed for the viewer toolbar/UI. This may result in extra page movement/jumping on page load.
+- While this has been tested with some drawings, this isn't built on public/strong standards & APIs so there may be cases where this does not work, and there's no assurance this will continue to work in the future.
+
+#### Usage
+
+After setup of the required hack files, this should automatically convert drawings when viewing a page.
+
+#### Code
+
+{{<hack file="layouts/parts/base-body-start.blade.php" type="visual">}}
+{{<hack file="functions.php" type="logical">}}
diff --git a/content/interactive-drawings/layouts/parts/base-body-start.blade.php b/content/interactive-drawings/layouts/parts/base-body-start.blade.php
new file mode 100644 (file)
index 0000000..65208df
--- /dev/null
@@ -0,0 +1,130 @@
+<script type="module" nonce="{{ $cspNonce ?? '' }}">
+    /**
+     * This script performs the following:
+     * - Finds drawings within page content on page view.
+     * - Fetches the data for those PNG-based drawings.
+     * - Extracts out the diagrams.net drawing data from the PNG data.
+     * - Builds embedded "viewer" iframes for the drawings.
+     * - Replaces the original drawings with embedded viewers.
+     */
+
+    /**
+     * Reads a given PNG data text chunk and returns drawing data if found.
+     * @param {Uint8Array} textData
+     * @returns {string|null}
+     */
+    function readTextChunkForDrawing(textData) {
+        const start = String.fromCharCode(...textData.slice(0, 7));
+        if (start !== "mxfile\0") {
+            return null;
+        }
+
+        const drawingText = String.fromCharCode(...textData.slice(7));
+        return decodeURIComponent(drawingText);
+    }
+
+    /**
+     * Attempts to extract drawing data from a PNG image.
+     * @param {Uint8Array} pngData
+     * @returns {string}
+     */
+    function extractDrawingFromPngData(pngData) {
+        // Ensure the file appears to be valid PNG file data
+        const signature = pngData.slice(0, 8).join(',');
+        if (signature !== '137,80,78,71,13,10,26,10') {
+            throw new Error('Invalid png signature');
+        }
+
+        // Search through the chunks of data within the PNG file
+        const dataView = new DataView(pngData.buffer);
+        let offset = 8;
+        let searching = true;
+        while (searching && offset < pngData.length) {
+            const length = dataView.getUint32(offset);
+            const chunkType = String.fromCharCode(...pngData.slice(offset + 4, offset + 8));
+
+            if (chunkType === 'tEXt') {
+                // Extract and return drawing data if found within a text data chunk
+                const textData = pngData.slice(offset + 8, offset + 8 + length);
+                const drawingData = readTextChunkForDrawing(textData);
+                if (drawingData !== null) {
+                    return drawingData;
+                }
+            } else if (chunkType === 'IEND') {
+                searching = false;
+            }
+
+            offset += 12 + length; // 12 = length + type + crc bytes
+        }
+
+        return '';
+    }
+
+    /**
+     * Creates an iframe-based viewer for the given drawing data.
+     * @param {string} drawingData
+     * @returns {HTMLElement}
+     */
+    function createViewerContainer(drawingData) {
+        const params = {
+            lightbox: '0',
+            highlight: '0000ff',
+            layers: '1',
+            nav: '1',
+            dark: 'auto',
+            toolbar: '1',
+        };
+
+        const query = (new URLSearchParams(params)).toString();
+        const hash = `R${encodeURIComponent(drawingData)}`;
+        const url = `https://viewer.diagrams.net/?${query}#${hash}`;
+
+        const el = document.createElement('iframe');
+        el.classList.add('mxgraph');
+        el.style.width = '100%';
+        el.style.maxWidth = '100%';
+        el.src = url;
+        el.frameBorder = '0';
+        return el;
+    }
+
+    /**
+     * Swap the given original drawing wrapper with the given viewer iframe.
+     * Attempts to somewhat match sizing based on original drawing size, but
+     * extra height is given to account for the viewer toolbar/UI.
+     * @param {HTMLElement} wrapper
+     * @param {HTMLElement} viewer
+     */
+    function swapDrawingWithViewer(wrapper, viewer) {
+        const size = wrapper.getBoundingClientRect();
+        viewer.style.height = (Math.round(size.height) + 146) + 'px';
+        wrapper.replaceWith(viewer);
+    }
+
+    /**
+     * Attempt to make a drawing interactive by converting it to an embedded iframe.
+     * @param {HTMLElement} wrapper
+     * @returns Promise<void>
+     */
+    async function makeDrawingInteractive(wrapper) {
+        const drawingUrl = wrapper.querySelector('img')?.src;
+        if (!drawingUrl) {
+            return;
+        }
+
+        const drawingPngData = await (await fetch(drawingUrl)).bytes();
+        const drawingData = extractDrawingFromPngData(drawingPngData);
+        if (!drawingData) {
+            return;
+        }
+
+        const viewer = createViewerContainer(drawingData);
+        swapDrawingWithViewer(wrapper, viewer);
+    }
+
+    // Cycle through found drawings on a page and update them to make them interactive
+    const drawings = document.querySelectorAll('.page-content [drawio-diagram]');
+    for (const drawingWrap of drawings) {
+        makeDrawingInteractive(drawingWrap);
+    }
+</script>
\ No newline at end of file