mirror of
https://github.com/zadam/trilium.git
synced 2025-12-05 15:04:24 +01:00
Merge f976dd8d309ac076a70da8182449aecda6f4ee55 into 1195cbd772ffd68f7757855f272a7c5c2c29eb78
This commit is contained in:
commit
1164fa811e
@ -33,6 +33,7 @@
|
|||||||
"@triliumnext/highlightjs": "workspace:*",
|
"@triliumnext/highlightjs": "workspace:*",
|
||||||
"@triliumnext/share-theme": "workspace:*",
|
"@triliumnext/share-theme": "workspace:*",
|
||||||
"@triliumnext/split.js": "workspace:*",
|
"@triliumnext/split.js": "workspace:*",
|
||||||
|
"@zumer/snapdom": "2.0.1",
|
||||||
"autocomplete.js": "0.38.1",
|
"autocomplete.js": "0.38.1",
|
||||||
"bootstrap": "5.3.8",
|
"bootstrap": "5.3.8",
|
||||||
"boxicons": "2.1.4",
|
"boxicons": "2.1.4",
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
import { dayjs } from "@triliumnext/commons";
|
import { dayjs } from "@triliumnext/commons";
|
||||||
import type { ViewScope } from "./link.js";
|
import type { ViewScope } from "./link.js";
|
||||||
import FNote from "../entities/fnote";
|
import FNote from "../entities/fnote";
|
||||||
|
import { snapdom } from "@zumer/snapdom";
|
||||||
|
|
||||||
const SVG_MIME = "image/svg+xml";
|
const SVG_MIME = "image/svg+xml";
|
||||||
|
|
||||||
@ -628,16 +629,69 @@ export function createImageSrcUrl(note: FNote) {
|
|||||||
return `api/images/${note.noteId}/${encodeURIComponent(note.title)}?timestamp=${Date.now()}`;
|
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('<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
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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 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) {
|
async function downloadAsSvg(nameWithoutExtension: string, svgSource: string | SVGElement | HTMLElement) {
|
||||||
const filename = `${nameWithoutExtension}.svg`;
|
const { element, cleanup } = prepareElementForSnapdom(svgSource);
|
||||||
const dataUrl = `data:image/svg+xml;charset=utf-8,${encodeURIComponent(svgContent)}`;
|
|
||||||
triggerDownload(filename, dataUrl);
|
try {
|
||||||
|
const result = await snapdom(element, {
|
||||||
|
backgroundColor: "transparent",
|
||||||
|
scale: 2
|
||||||
|
});
|
||||||
|
triggerDownload(`${nameWithoutExtension}.svg`, result.url);
|
||||||
|
} finally {
|
||||||
|
cleanup();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -658,62 +712,26 @@ function triggerDownload(fileName: string, dataUrl: string) {
|
|||||||
|
|
||||||
document.body.removeChild(element);
|
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.
|
* Downloads an SVG as PNG using snapdom. Can accept either an SVG string, an SVG element, or an HTML element.
|
||||||
*
|
|
||||||
* Note that the SVG must specify its width and height as attributes in order for it to be rendered.
|
|
||||||
*
|
*
|
||||||
* @param nameWithoutExtension the name of the file. The .png suffix is automatically added to it.
|
* @param nameWithoutExtension the name of the file. The .png 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 converted to PNG.
|
||||||
* @returns a promise which resolves if the operation was successful, or rejects if it failed (permissions issue or some other issue).
|
|
||||||
*/
|
*/
|
||||||
function downloadSvgAsPng(nameWithoutExtension: string, svgContent: string) {
|
async function downloadAsPng(nameWithoutExtension: string, svgSource: string | SVGElement | HTMLElement) {
|
||||||
return new Promise<void>((resolve, reject) => {
|
const { element, cleanup } = prepareElementForSnapdom(svgSource);
|
||||||
// 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.
|
try {
|
||||||
const { width, height } = result;
|
const result = await snapdom(element, {
|
||||||
|
backgroundColor: "transparent",
|
||||||
// Create an image element and load the SVG.
|
scale: 2
|
||||||
const imageEl = new Image();
|
});
|
||||||
imageEl.width = width;
|
const pngImg = await result.toPng();
|
||||||
imageEl.height = height;
|
await triggerDownload(`${nameWithoutExtension}.png`, pngImg.src);
|
||||||
imageEl.crossOrigin = "anonymous";
|
} finally {
|
||||||
imageEl.onload = () => {
|
cleanup();
|
||||||
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)}`;
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getSizeFromSvg(svgContent: string) {
|
export function getSizeFromSvg(svgContent: string) {
|
||||||
const svgDocument = (new DOMParser()).parseFromString(svgContent, SVG_MIME);
|
const svgDocument = (new DOMParser()).parseFromString(svgContent, SVG_MIME);
|
||||||
|
|
||||||
@ -925,8 +943,8 @@ export default {
|
|||||||
areObjectsEqual,
|
areObjectsEqual,
|
||||||
copyHtmlToClipboard,
|
copyHtmlToClipboard,
|
||||||
createImageSrcUrl,
|
createImageSrcUrl,
|
||||||
downloadSvg,
|
downloadAsSvg,
|
||||||
downloadSvgAsPng,
|
downloadAsPng,
|
||||||
compareVersions,
|
compareVersions,
|
||||||
isUpdateAvailable,
|
isUpdateAvailable,
|
||||||
isLaunchBarConfig
|
isLaunchBarConfig
|
||||||
|
|||||||
@ -1968,7 +1968,8 @@
|
|||||||
"button_title": "Export diagram as PNG"
|
"button_title": "Export diagram as PNG"
|
||||||
},
|
},
|
||||||
"svg": {
|
"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": {
|
"code_theme": {
|
||||||
"title": "Appearance",
|
"title": "Appearance",
|
||||||
|
|||||||
@ -11,6 +11,7 @@ import { useEditorSpacedUpdate, useNoteLabelBoolean, useSyncedRef, useTriliumEve
|
|||||||
import { refToJQuerySelector } from "../react/react_utils";
|
import { refToJQuerySelector } from "../react/react_utils";
|
||||||
import utils from "../../services/utils";
|
import utils from "../../services/utils";
|
||||||
import { DISPLAYABLE_LOCALE_IDS } from "@triliumnext/commons";
|
import { DISPLAYABLE_LOCALE_IDS } from "@triliumnext/commons";
|
||||||
|
import { snapdom, SnapdomOptions } from "@zumer/snapdom";
|
||||||
|
|
||||||
const NEW_TOPIC_NAME = "";
|
const NEW_TOPIC_NAME = "";
|
||||||
|
|
||||||
@ -45,11 +46,24 @@ export default function MindMap({ note, ntxId, noteContext }: TypeWidgetProps) {
|
|||||||
const apiRef = useRef<MindElixirInstance>(null);
|
const apiRef = useRef<MindElixirInstance>(null);
|
||||||
const containerRef = useRef<HTMLDivElement>(null);
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
const [ isReadOnly ] = useNoteLabelBoolean(note, "readOnly");
|
const [ isReadOnly ] = useNoteLabelBoolean(note, "readOnly");
|
||||||
|
|
||||||
|
|
||||||
const spacedUpdate = useEditorSpacedUpdate({
|
const spacedUpdate = useEditorSpacedUpdate({
|
||||||
note,
|
note,
|
||||||
noteContext,
|
noteContext,
|
||||||
getData: async () => {
|
getData: async () => {
|
||||||
if (!apiRef.current) return;
|
if (!apiRef.current) return;
|
||||||
|
|
||||||
|
const result = await snapdom(apiRef.current.nodes, {
|
||||||
|
backgroundColor: "transparent",
|
||||||
|
scale: 2
|
||||||
|
});
|
||||||
|
|
||||||
|
// 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 {
|
return {
|
||||||
content: apiRef.current.getDataString(),
|
content: apiRef.current.getDataString(),
|
||||||
attachments: [
|
attachments: [
|
||||||
@ -57,7 +71,7 @@ export default function MindMap({ note, ntxId, noteContext }: TypeWidgetProps) {
|
|||||||
role: "image",
|
role: "image",
|
||||||
title: "mindmap-export.svg",
|
title: "mindmap-export.svg",
|
||||||
mime: "image/svg+xml",
|
mime: "image/svg+xml",
|
||||||
content: await apiRef.current.exportSvg().text(),
|
content: svgContent,
|
||||||
position: 0
|
position: 0
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
@ -88,13 +102,13 @@ export default function MindMap({ note, ntxId, noteContext }: TypeWidgetProps) {
|
|||||||
// Export as PNG or SVG.
|
// Export as PNG or SVG.
|
||||||
useTriliumEvents([ "exportSvg", "exportPng" ], async ({ ntxId: eventNtxId }, eventName) => {
|
useTriliumEvents([ "exportSvg", "exportPng" ], async ({ ntxId: eventNtxId }, eventName) => {
|
||||||
if (eventNtxId !== ntxId || !apiRef.current) return;
|
if (eventNtxId !== ntxId || !apiRef.current) return;
|
||||||
const title = note.title;
|
const nodes = apiRef.current.nodes;
|
||||||
const svg = await apiRef.current.exportSvg().text();
|
|
||||||
if (eventName === "exportSvg") {
|
if (eventName === "exportSvg") {
|
||||||
utils.downloadSvg(title, svg);
|
await utils.downloadAsSvg(note.title, nodes);
|
||||||
} else {
|
} else {
|
||||||
utils.downloadSvgAsPng(title, svg);
|
await utils.downloadAsPng(note.title, nodes);
|
||||||
}
|
}
|
||||||
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const onKeyDown = useCallback((e: KeyboardEvent) => {
|
const onKeyDown = useCallback((e: KeyboardEvent) => {
|
||||||
|
|||||||
@ -77,14 +77,25 @@ export default function SvgSplitEditor({ ntxId, note, attachmentName, renderSvg,
|
|||||||
}, [ note ]);
|
}, [ note ]);
|
||||||
|
|
||||||
// Import/export
|
// Import/export
|
||||||
useTriliumEvent("exportSvg", ({ ntxId: eventNtxId }) => {
|
useTriliumEvent("exportSvg", async({ ntxId: eventNtxId }) => {
|
||||||
if (eventNtxId !== ntxId || !svg) return;
|
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, svgEl);
|
||||||
|
} catch (e) {
|
||||||
|
console.warn(e);
|
||||||
|
toast.showError(t("svg.export_to_svg"));
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
useTriliumEvent("exportPng", async ({ ntxId: eventNtxId }) => {
|
useTriliumEvent("exportPng", async ({ ntxId: eventNtxId }) => {
|
||||||
if (eventNtxId !== ntxId || !svg) return;
|
if (eventNtxId !== ntxId || !svg) return;
|
||||||
try {
|
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, svgEl);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.warn(e);
|
console.warn(e);
|
||||||
toast.showError(t("svg.export_to_png"));
|
toast.showError(t("svg.export_to_png"));
|
||||||
|
|||||||
8
pnpm-lock.yaml
generated
8
pnpm-lock.yaml
generated
@ -211,6 +211,9 @@ importers:
|
|||||||
'@triliumnext/split.js':
|
'@triliumnext/split.js':
|
||||||
specifier: workspace:*
|
specifier: workspace:*
|
||||||
version: link:../../packages/splitjs
|
version: link:../../packages/splitjs
|
||||||
|
'@zumer/snapdom':
|
||||||
|
specifier: 2.0.1
|
||||||
|
version: 2.0.1
|
||||||
autocomplete.js:
|
autocomplete.js:
|
||||||
specifier: 0.38.1
|
specifier: 0.38.1
|
||||||
version: 0.38.1
|
version: 0.38.1
|
||||||
@ -5754,6 +5757,9 @@ packages:
|
|||||||
resolution: {integrity: sha512-PI6UdgpSeVoGvzguKHmy2bwOqI3UYkntLZOCpyJSKIi7234c5aJmQYkJB/P4P2YUJkqhbqvu7iM2/0eJZ178nA==}
|
resolution: {integrity: sha512-PI6UdgpSeVoGvzguKHmy2bwOqI3UYkntLZOCpyJSKIi7234c5aJmQYkJB/P4P2YUJkqhbqvu7iM2/0eJZ178nA==}
|
||||||
engines: {bun: '>=0.7.0', deno: '>=1.0.0', node: '>=16.5.0'}
|
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:
|
abab@2.0.6:
|
||||||
resolution: {integrity: sha512-j2afSsaIENvHZN2B8GOpF566vZ5WVk5opAiMTvWgaQT8DkbOqsTfvNAvHoRGU2zzP8cPoqys+xHTRDWW8L+/BA==}
|
resolution: {integrity: sha512-j2afSsaIENvHZN2B8GOpF566vZ5WVk5opAiMTvWgaQT8DkbOqsTfvNAvHoRGU2zzP8cPoqys+xHTRDWW8L+/BA==}
|
||||||
deprecated: Use your platform's native atob() and btoa() methods instead
|
deprecated: Use your platform's native atob() and btoa() methods instead
|
||||||
@ -20671,6 +20677,8 @@ snapshots:
|
|||||||
|
|
||||||
'@zip.js/zip.js@2.8.2': {}
|
'@zip.js/zip.js@2.8.2': {}
|
||||||
|
|
||||||
|
'@zumer/snapdom@2.0.1': {}
|
||||||
|
|
||||||
abab@2.0.6: {}
|
abab@2.0.6: {}
|
||||||
|
|
||||||
abbrev@1.1.1: {}
|
abbrev@1.1.1: {}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user