Skip to content
Merged
Show file tree
Hide file tree
Changes from 5 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions apps/client/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
134 changes: 76 additions & 58 deletions apps/client/src/services/utils.ts
Original file line number Diff line number Diff line change
@@ -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";

Expand Down Expand Up @@ -628,16 +629,69 @@ export function createImageSrcUrl(note: FNote) {
return `api/images/${note.noteId}/${encodeURIComponent(note.title)}?timestamp=${Date.now()}`;
}





/**
* 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('<svg');
const mimeType = isSvg ? SVG_MIME : 'text/html';

const doc = parser.parseFromString(source, mimeType);
const element = doc.documentElement;

// Temporarily attach to DOM for proper style computation
element.style.position = 'absolute';
element.style.left = '-9999px';
element.style.top = '-9999px';
document.body.appendChild(element);

return {
element,
cleanup: () => document.body.removeChild(element)
};
}

return {
element: source,
cleanup: () => {} // No-op for existing elements
};
}

/**
* Given a string representation of an SVG, triggers a download of the file on the client device.
* 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, {
backgroundColor: "transparent",
scale: 2
});
Comment on lines +687 to +690
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The options for snapdom are duplicated in downloadAsPng on line 725. To improve maintainability and ensure consistency, consider extracting these options into a shared constant defined above both functions. For example:

const SNAPDOM_OPTIONS = {
    backgroundColor: "transparent",
    scale: 2
};

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think this is needed at this point, if we use it in more places then I would say lets put it into its own object.

await triggerDownload(`${nameWithoutExtension}.svg`, result.url);
} finally {
cleanup();
}
}

/**
Expand All @@ -658,62 +712,26 @@ 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<void>((resolve, reject) => {
// First, we need to determine the width and the height from the input SVG.
const result = getSizeFromSvg(svgContent);
if (!result) {
reject();
return;
}

// 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();
}
async function downloadAsPng(nameWithoutExtension: string, svgSource: string | SVGElement | HTMLElement) {
const { element, cleanup } = prepareElementForSnapdom(svgSource);

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, {
backgroundColor: "transparent",
scale: 2
});
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);

Expand Down Expand Up @@ -925,8 +943,8 @@ export default {
areObjectsEqual,
copyHtmlToClipboard,
createImageSrcUrl,
downloadSvg,
downloadSvgAsPng,
downloadAsSvg,
downloadAsPng,
compareVersions,
isUpdateAvailable,
isLaunchBarConfig
Expand Down
3 changes: 2 additions & 1 deletion apps/client/src/translations/en/translation.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
24 changes: 19 additions & 5 deletions apps/client/src/widgets/type_widgets/MindMap.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 = "";

Expand Down Expand Up @@ -45,19 +46,32 @@ export default function MindMap({ note, ntxId, noteContext }: TypeWidgetProps) {
const apiRef = useRef<MindElixirInstance>(null);
const containerRef = useRef<HTMLDivElement>(null);
const [ isReadOnly ] = useNoteLabelBoolean(note, "readOnly");


const spacedUpdate = useEditorSpacedUpdate({
note,
noteContext,
getData: async () => {
if (!apiRef.current) return;

const result = await snapdom(apiRef.current.nodes, {
backgroundColor: "transparent",
scale: 2
});
Comment on lines +57 to +60
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

These snapdom options are also used in utils.ts. It might be beneficial to have a shared configuration for snapdom to ensure consistency across different usages. For example, you could export the options object from utils.ts and import it here.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think this is needed at this point, if we use it in more places then I would say lets put it into its own object.


// a data URL in the format: "data:image/svg+xml;charset=utf-8,<url-encoded-svg>"
// 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: [
{
role: "image",
title: "mindmap-export.svg",
mime: "image/svg+xml",
content: await apiRef.current.exportSvg().text(),
content: svgContent,
position: 0
}
]
Expand Down Expand Up @@ -88,13 +102,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 title = note.title;
const svg = await apiRef.current.exportSvg().text();
const nodes = apiRef.current.nodes;
if (eventName === "exportSvg") {
utils.downloadSvg(title, svg);
await utils.downloadAsSvg(note.title, nodes);
} else {
utils.downloadSvgAsPng(title, svg);
await utils.downloadAsPng(note.title, nodes);
}

});

const onKeyDown = useCallback((e: KeyboardEvent) => {
Expand Down
17 changes: 14 additions & 3 deletions apps/client/src/widgets/type_widgets/helpers/SvgSplitEditor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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"));
Expand Down
8 changes: 8 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading