From 5c9503732dc0117a6551ed4637a1af68689493d8 Mon Sep 17 00:00:00 2001 From: lzinga Date: Thu, 4 Dec 2025 11:08:44 -0800 Subject: [PATCH 1/4] fix(mind-map): show text in links between nodes on export --- apps/client/package.json | 1 + apps/client/src/services/utils.ts | 1 + .../src/widgets/type_widgets/MindMap.tsx | 31 ++++++++++++++++--- pnpm-lock.yaml | 8 +++++ 4 files changed, 36 insertions(+), 5 deletions(-) diff --git a/apps/client/package.json b/apps/client/package.json index 8eab329fc..6406d4d56 100644 --- a/apps/client/package.json +++ b/apps/client/package.json @@ -33,6 +33,7 @@ "@triliumnext/highlightjs": "workspace:*", "@triliumnext/share-theme": "workspace:*", "@triliumnext/split.js": "workspace:*", + "@zumer/snapdom": "2.0.1", "autocomplete.js": "0.38.1", "bootstrap": "5.3.8", "boxicons": "2.1.4", diff --git a/apps/client/src/services/utils.ts b/apps/client/src/services/utils.ts index 2045cd4d7..5722dbc6f 100644 --- a/apps/client/src/services/utils.ts +++ b/apps/client/src/services/utils.ts @@ -925,6 +925,7 @@ export default { areObjectsEqual, copyHtmlToClipboard, createImageSrcUrl, + triggerDownload, downloadSvg, downloadSvgAsPng, compareVersions, diff --git a/apps/client/src/widgets/type_widgets/MindMap.tsx b/apps/client/src/widgets/type_widgets/MindMap.tsx index f8409c75c..626af1b6a 100644 --- a/apps/client/src/widgets/type_widgets/MindMap.tsx +++ b/apps/client/src/widgets/type_widgets/MindMap.tsx @@ -11,6 +11,7 @@ import { useEditorSpacedUpdate, useNoteLabelBoolean, useSyncedRef, useTriliumEve import { refToJQuerySelector } from "../react/react_utils"; import utils from "../../services/utils"; import { DISPLAYABLE_LOCALE_IDS } from "@triliumnext/commons"; +import { snapdom, SnapdomOptions } from "@zumer/snapdom"; const NEW_TOPIC_NAME = ""; @@ -45,11 +46,26 @@ export default function MindMap({ note, ntxId, noteContext }: TypeWidgetProps) { const apiRef = useRef(null); const containerRef = useRef(null); const [ isReadOnly ] = useNoteLabelBoolean(note, "readOnly"); + + // Shared options for snapdom screenshot generation used in both attachment saving and exports + const imageOptions : SnapdomOptions = { + backgroundColor: "transparent", + scale: 2 + }; + const spacedUpdate = useEditorSpacedUpdate({ note, noteContext, getData: async () => { if (!apiRef.current) return; + + const result = await snapdom(apiRef.current.nodes, imageOptions); + + // a data URL in the format: "data:image/svg+xml;charset=utf-8," + // We need to extract the content after the comma and decode the URL encoding (%3C to <, %20 to space, etc.) + // to get raw SVG content that Trilium's backend can store as an attachment + const svgContent = decodeURIComponent(result.url.split(',')[1]); + return { content: apiRef.current.getDataString(), attachments: [ @@ -57,7 +73,7 @@ export default function MindMap({ note, ntxId, noteContext }: TypeWidgetProps) { role: "image", title: "mindmap-export.svg", mime: "image/svg+xml", - content: await apiRef.current.exportSvg().text(), + content: svgContent, position: 0 } ] @@ -88,13 +104,18 @@ export default function MindMap({ note, ntxId, noteContext }: TypeWidgetProps) { // Export as PNG or SVG. useTriliumEvents([ "exportSvg", "exportPng" ], async ({ ntxId: eventNtxId }, eventName) => { if (eventNtxId !== ntxId || !apiRef.current) return; - const title = note.title; - const svg = await apiRef.current.exportSvg().text(); + + const result = await snapdom(apiRef.current.nodes, imageOptions); + + let dataUrl; if (eventName === "exportSvg") { - utils.downloadSvg(title, svg); + dataUrl = result.url; // Native SVG Data URL } else { - utils.downloadSvgAsPng(title, svg); + const pngImg = await result.toPng(); + dataUrl = pngImg.src; // PNG Data URL } + + await utils.triggerDownload(`${note.title}.${eventName === "exportSvg" ? "svg" : "png"}`, dataUrl); }); const onKeyDown = useCallback((e: KeyboardEvent) => { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e9647fbf5..0894e014f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -211,6 +211,9 @@ importers: '@triliumnext/split.js': specifier: workspace:* version: link:../../packages/splitjs + '@zumer/snapdom': + specifier: 2.0.1 + version: 2.0.1 autocomplete.js: specifier: 0.38.1 version: 0.38.1 @@ -5754,6 +5757,9 @@ packages: resolution: {integrity: sha512-PI6UdgpSeVoGvzguKHmy2bwOqI3UYkntLZOCpyJSKIi7234c5aJmQYkJB/P4P2YUJkqhbqvu7iM2/0eJZ178nA==} engines: {bun: '>=0.7.0', deno: '>=1.0.0', node: '>=16.5.0'} + '@zumer/snapdom@2.0.1': + resolution: {integrity: sha512-78/qbYl2FTv4H6qaXcNfAujfIOSzdvs83NW63VbyC9QA3sqNPfPvhn4xYMO6Gy11hXwJUEhd0z65yKiNzDwy9w==} + abab@2.0.6: resolution: {integrity: sha512-j2afSsaIENvHZN2B8GOpF566vZ5WVk5opAiMTvWgaQT8DkbOqsTfvNAvHoRGU2zzP8cPoqys+xHTRDWW8L+/BA==} deprecated: Use your platform's native atob() and btoa() methods instead @@ -20671,6 +20677,8 @@ snapshots: '@zip.js/zip.js@2.8.2': {} + '@zumer/snapdom@2.0.1': {} + abab@2.0.6: {} abbrev@1.1.1: {} From ce1fd64aa9479179b631c488938ec47a233b0dc4 Mon Sep 17 00:00:00 2001 From: lzinga Date: Thu, 4 Dec 2025 12:49:10 -0800 Subject: [PATCH 2/4] feat(export): enhance SVG and PNG export functionality with snapdom integration --- apps/client/src/services/utils.ts | 129 ++++++++++-------- .../src/translations/en/translation.json | 3 +- .../src/widgets/type_widgets/MindMap.tsx | 11 +- .../type_widgets/helpers/SvgSplitEditor.tsx | 17 ++- 4 files changed, 89 insertions(+), 71 deletions(-) diff --git a/apps/client/src/services/utils.ts b/apps/client/src/services/utils.ts index 5722dbc6f..3a75b90d6 100644 --- a/apps/client/src/services/utils.ts +++ b/apps/client/src/services/utils.ts @@ -1,6 +1,7 @@ import { dayjs } from "@triliumnext/commons"; import type { ViewScope } from "./link.js"; import FNote from "../entities/fnote"; +import { snapdom } from "@zumer/snapdom"; const SVG_MIME = "image/svg+xml"; @@ -628,16 +629,66 @@ export function createImageSrcUrl(note: FNote) { return `api/images/${note.noteId}/${encodeURIComponent(note.title)}?timestamp=${Date.now()}`; } + + + + /** - * Given a string representation of an SVG, triggers a download of the file on the client device. + * Helper function to prepare an element for snapdom rendering. + * Handles string parsing and temporary DOM attachment for style computation. + * + * @param source - Either an SVG/HTML string to be parsed, or an existing SVG/HTML element. + * @returns An object containing the prepared element and a cleanup function. + * The cleanup function removes temporarily attached elements from the DOM, + * or is a no-op if the element was already in the DOM. + */ +function prepareElementForSnapdom(source: string | SVGElement | HTMLElement): { + element: SVGElement | HTMLElement; + cleanup: () => void; +} { + if (typeof source === 'string') { + const parser = new DOMParser(); + + // Detect if content is SVG or HTML + const isSvg = source.trim().startsWith(' document.body.removeChild(element) + }; + } + + return { + element: source, + cleanup: () => {} // No-op for existing elements + }; +} + +/** + * Downloads an SVG using snapdom for proper rendering. Can accept either an SVG string, an SVG element, or an HTML element. * * @param nameWithoutExtension the name of the file. The .svg suffix is automatically added to it. - * @param svgContent the content of the SVG file download. + * @param svgSource either an SVG string, an SVGElement, or an HTMLElement to be downloaded. */ -function downloadSvg(nameWithoutExtension: string, svgContent: string) { - const filename = `${nameWithoutExtension}.svg`; - const dataUrl = `data:image/svg+xml;charset=utf-8,${encodeURIComponent(svgContent)}`; - triggerDownload(filename, dataUrl); +async function downloadAsSvg(nameWithoutExtension: string, svgSource: string | SVGElement | HTMLElement) { + const { element, cleanup } = prepareElementForSnapdom(svgSource); + + try { + const result = await snapdom(element); + await triggerDownload(`${nameWithoutExtension}.svg`, result.url); + } finally { + cleanup(); + } } /** @@ -658,62 +709,23 @@ function triggerDownload(fileName: string, dataUrl: string) { document.body.removeChild(element); } - /** - * Given a string representation of an SVG, renders the SVG to PNG and triggers a download of the file on the client device. - * - * Note that the SVG must specify its width and height as attributes in order for it to be rendered. + * Downloads an SVG as PNG using snapdom. Can accept either an SVG string, an SVG element, or an HTML element. * * @param nameWithoutExtension the name of the file. The .png suffix is automatically added to it. - * @param svgContent the content of the SVG file download. - * @returns a promise which resolves if the operation was successful, or rejects if it failed (permissions issue or some other issue). + * @param svgSource either an SVG string, an SVGElement, or an HTMLElement to be converted to PNG. */ -function downloadSvgAsPng(nameWithoutExtension: string, svgContent: string) { - return new Promise((resolve, reject) => { - // First, we need to determine the width and the height from the input SVG. - const result = getSizeFromSvg(svgContent); - if (!result) { - reject(); - return; - } +async function downloadAsPng(nameWithoutExtension: string, svgSource: string | SVGElement | HTMLElement) { + const { element, cleanup } = prepareElementForSnapdom(svgSource); - // Convert the image to a blob. - const { width, height } = result; - - // Create an image element and load the SVG. - const imageEl = new Image(); - imageEl.width = width; - imageEl.height = height; - imageEl.crossOrigin = "anonymous"; - imageEl.onload = () => { - try { - // Draw the image with a canvas. - const canvasEl = document.createElement("canvas"); - canvasEl.width = imageEl.width; - canvasEl.height = imageEl.height; - document.body.appendChild(canvasEl); - - const ctx = canvasEl.getContext("2d"); - if (!ctx) { - reject(); - } - - ctx?.drawImage(imageEl, 0, 0); - - const imgUri = canvasEl.toDataURL("image/png") - triggerDownload(`${nameWithoutExtension}.png`, imgUri); - document.body.removeChild(canvasEl); - resolve(); - } catch (e) { - console.warn(e); - reject(); - } - }; - imageEl.onerror = (e) => reject(e); - imageEl.src = `data:image/svg+xml;charset=utf-8,${encodeURIComponent(svgContent)}`; - }); + try { + const result = await snapdom(element); + const pngImg = await result.toPng(); + await triggerDownload(`${nameWithoutExtension}.png`, pngImg.src); + } finally { + cleanup(); + } } - export function getSizeFromSvg(svgContent: string) { const svgDocument = (new DOMParser()).parseFromString(svgContent, SVG_MIME); @@ -925,9 +937,8 @@ export default { areObjectsEqual, copyHtmlToClipboard, createImageSrcUrl, - triggerDownload, - downloadSvg, - downloadSvgAsPng, + downloadAsSvg, + downloadAsPng, compareVersions, isUpdateAvailable, isLaunchBarConfig diff --git a/apps/client/src/translations/en/translation.json b/apps/client/src/translations/en/translation.json index 5e2934f34..ce3ef665f 100644 --- a/apps/client/src/translations/en/translation.json +++ b/apps/client/src/translations/en/translation.json @@ -1968,7 +1968,8 @@ "button_title": "Export diagram as PNG" }, "svg": { - "export_to_png": "The diagram could not be exported to PNG." + "export_to_png": "The diagram could not be exported to PNG.", + "export_to_svg": "The diagram could not be exported to SVG." }, "code_theme": { "title": "Appearance", diff --git a/apps/client/src/widgets/type_widgets/MindMap.tsx b/apps/client/src/widgets/type_widgets/MindMap.tsx index 626af1b6a..3741d8fbf 100644 --- a/apps/client/src/widgets/type_widgets/MindMap.tsx +++ b/apps/client/src/widgets/type_widgets/MindMap.tsx @@ -104,18 +104,13 @@ export default function MindMap({ note, ntxId, noteContext }: TypeWidgetProps) { // Export as PNG or SVG. useTriliumEvents([ "exportSvg", "exportPng" ], async ({ ntxId: eventNtxId }, eventName) => { if (eventNtxId !== ntxId || !apiRef.current) return; - - const result = await snapdom(apiRef.current.nodes, imageOptions); - - let dataUrl; + const nodes = apiRef.current.nodes; if (eventName === "exportSvg") { - dataUrl = result.url; // Native SVG Data URL + await utils.downloadAsSvg(note.title, nodes); } else { - const pngImg = await result.toPng(); - dataUrl = pngImg.src; // PNG Data URL + await utils.downloadAsPng(note.title, nodes); } - await utils.triggerDownload(`${note.title}.${eventName === "exportSvg" ? "svg" : "png"}`, dataUrl); }); const onKeyDown = useCallback((e: KeyboardEvent) => { diff --git a/apps/client/src/widgets/type_widgets/helpers/SvgSplitEditor.tsx b/apps/client/src/widgets/type_widgets/helpers/SvgSplitEditor.tsx index a3170d945..3d144e2ac 100644 --- a/apps/client/src/widgets/type_widgets/helpers/SvgSplitEditor.tsx +++ b/apps/client/src/widgets/type_widgets/helpers/SvgSplitEditor.tsx @@ -77,14 +77,25 @@ export default function SvgSplitEditor({ ntxId, note, attachmentName, renderSvg, }, [ note ]); // Import/export - useTriliumEvent("exportSvg", ({ ntxId: eventNtxId }) => { + useTriliumEvent("exportSvg", async({ ntxId: eventNtxId }) => { if (eventNtxId !== ntxId || !svg) return; - utils.downloadSvg(note.title, svg); + + try { + const svgEl = containerRef.current?.querySelector("svg"); + if (!svgEl) throw new Error("SVG element not found"); + await utils.downloadAsSvg(note.title + '.svg', svgEl); + } catch (e) { + console.warn(e); + toast.showError(t("svg.export_to_svg")); + } }); + useTriliumEvent("exportPng", async ({ ntxId: eventNtxId }) => { if (eventNtxId !== ntxId || !svg) return; try { - await utils.downloadSvgAsPng(note.title, svg); + const svgEl = containerRef.current?.querySelector("svg"); + if (!svgEl) throw new Error("SVG element not found"); + await utils.downloadAsPng(note.title + '.png', svgEl); } catch (e) { console.warn(e); toast.showError(t("svg.export_to_png")); From 2666c1e196c2ed8f565c094c6bdc0d83131f704a Mon Sep 17 00:00:00 2001 From: lzinga Date: Thu, 4 Dec 2025 12:52:01 -0800 Subject: [PATCH 3/4] feat(snapdom): update screenshot generation options for SVG and PNG exports --- apps/client/src/services/utils.ts | 10 ++++++++-- apps/client/src/widgets/type_widgets/MindMap.tsx | 10 ++++------ 2 files changed, 12 insertions(+), 8 deletions(-) diff --git a/apps/client/src/services/utils.ts b/apps/client/src/services/utils.ts index 3a75b90d6..7e064d25f 100644 --- a/apps/client/src/services/utils.ts +++ b/apps/client/src/services/utils.ts @@ -684,7 +684,10 @@ async function downloadAsSvg(nameWithoutExtension: string, svgSource: string | S const { element, cleanup } = prepareElementForSnapdom(svgSource); try { - const result = await snapdom(element); + const result = await snapdom(element, { + backgroundColor: "transparent", + scale: 2 + }); await triggerDownload(`${nameWithoutExtension}.svg`, result.url); } finally { cleanup(); @@ -719,7 +722,10 @@ async function downloadAsPng(nameWithoutExtension: string, svgSource: string | S const { element, cleanup } = prepareElementForSnapdom(svgSource); try { - const result = await snapdom(element); + const result = await snapdom(element, { + backgroundColor: "transparent", + scale: 2 + }); const pngImg = await result.toPng(); await triggerDownload(`${nameWithoutExtension}.png`, pngImg.src); } finally { diff --git a/apps/client/src/widgets/type_widgets/MindMap.tsx b/apps/client/src/widgets/type_widgets/MindMap.tsx index 3741d8fbf..db46da025 100644 --- a/apps/client/src/widgets/type_widgets/MindMap.tsx +++ b/apps/client/src/widgets/type_widgets/MindMap.tsx @@ -47,11 +47,6 @@ export default function MindMap({ note, ntxId, noteContext }: TypeWidgetProps) { const containerRef = useRef(null); const [ isReadOnly ] = useNoteLabelBoolean(note, "readOnly"); - // Shared options for snapdom screenshot generation used in both attachment saving and exports - const imageOptions : SnapdomOptions = { - backgroundColor: "transparent", - scale: 2 - }; const spacedUpdate = useEditorSpacedUpdate({ note, @@ -59,7 +54,10 @@ export default function MindMap({ note, ntxId, noteContext }: TypeWidgetProps) { getData: async () => { if (!apiRef.current) return; - const result = await snapdom(apiRef.current.nodes, imageOptions); + const result = await snapdom(apiRef.current.nodes, { + backgroundColor: "transparent", + scale: 2 + }); // a data URL in the format: "data:image/svg+xml;charset=utf-8," // We need to extract the content after the comma and decode the URL encoding (%3C to <, %20 to space, etc.) From f976dd8d309ac076a70da8182449aecda6f4ee55 Mon Sep 17 00:00:00 2001 From: Lucas Date: Thu, 4 Dec 2025 13:08:28 -0800 Subject: [PATCH 4/4] Apply suggestions from code review Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com> --- apps/client/src/services/utils.ts | 2 +- apps/client/src/widgets/type_widgets/MindMap.tsx | 2 +- .../src/widgets/type_widgets/helpers/SvgSplitEditor.tsx | 4 ++-- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/apps/client/src/services/utils.ts b/apps/client/src/services/utils.ts index 7e064d25f..579f8d2b8 100644 --- a/apps/client/src/services/utils.ts +++ b/apps/client/src/services/utils.ts @@ -688,7 +688,7 @@ async function downloadAsSvg(nameWithoutExtension: string, svgSource: string | S backgroundColor: "transparent", scale: 2 }); - await triggerDownload(`${nameWithoutExtension}.svg`, result.url); + triggerDownload(`${nameWithoutExtension}.svg`, result.url); } finally { cleanup(); } diff --git a/apps/client/src/widgets/type_widgets/MindMap.tsx b/apps/client/src/widgets/type_widgets/MindMap.tsx index db46da025..a76c26bf3 100644 --- a/apps/client/src/widgets/type_widgets/MindMap.tsx +++ b/apps/client/src/widgets/type_widgets/MindMap.tsx @@ -102,7 +102,7 @@ export default function MindMap({ note, ntxId, noteContext }: TypeWidgetProps) { // Export as PNG or SVG. useTriliumEvents([ "exportSvg", "exportPng" ], async ({ ntxId: eventNtxId }, eventName) => { if (eventNtxId !== ntxId || !apiRef.current) return; - const nodes = apiRef.current.nodes; + const nodes = apiRef.current.nodes; if (eventName === "exportSvg") { await utils.downloadAsSvg(note.title, nodes); } else { diff --git a/apps/client/src/widgets/type_widgets/helpers/SvgSplitEditor.tsx b/apps/client/src/widgets/type_widgets/helpers/SvgSplitEditor.tsx index 3d144e2ac..3c9eff27a 100644 --- a/apps/client/src/widgets/type_widgets/helpers/SvgSplitEditor.tsx +++ b/apps/client/src/widgets/type_widgets/helpers/SvgSplitEditor.tsx @@ -83,7 +83,7 @@ export default function SvgSplitEditor({ ntxId, note, attachmentName, renderSvg, try { const svgEl = containerRef.current?.querySelector("svg"); if (!svgEl) throw new Error("SVG element not found"); - await utils.downloadAsSvg(note.title + '.svg', svgEl); + await utils.downloadAsSvg(note.title, svgEl); } catch (e) { console.warn(e); toast.showError(t("svg.export_to_svg")); @@ -95,7 +95,7 @@ export default function SvgSplitEditor({ ntxId, note, attachmentName, renderSvg, try { const svgEl = containerRef.current?.querySelector("svg"); if (!svgEl) throw new Error("SVG element not found"); - await utils.downloadAsPng(note.title + '.png', svgEl); + await utils.downloadAsPng(note.title, svgEl); } catch (e) { console.warn(e); toast.showError(t("svg.export_to_png"));