diff --git a/src/public/app/components/app_context.ts b/src/public/app/components/app_context.ts index 7063a8de3..1bbe6b616 100644 --- a/src/public/app/components/app_context.ts +++ b/src/public/app/components/app_context.ts @@ -343,9 +343,8 @@ type EventMappings = { noteContextRemoved: { ntxIds: string[]; }; - exportSvg: { - ntxId: string | null | undefined; - }; + exportSvg: { ntxId: string | null | undefined; }; + exportPng: { ntxId: string | null | undefined; }; geoMapCreateChildNote: { ntxId: string | null | undefined; // TODO: deduplicate ntxId }; diff --git a/src/public/app/layouts/desktop_layout.ts b/src/public/app/layouts/desktop_layout.ts index 9d8a06ac1..8a6a8befd 100644 --- a/src/public/app/layouts/desktop_layout.ts +++ b/src/public/app/layouts/desktop_layout.ts @@ -91,6 +91,7 @@ import type { AppContext } from "./../components/app_context.js"; import type { WidgetsByParent } from "../services/bundle.js"; import SwitchSplitOrientationButton from "../widgets/floating_buttons/switch_layout_button.js"; import ToggleReadOnlyButton from "../widgets/floating_buttons/toggle_read_only_button.js"; +import PngExportButton from "../widgets/floating_buttons/png_export_button.js"; export default class DesktopLayout { @@ -214,6 +215,7 @@ export default class DesktopLayout { .child(new GeoMapButtons()) .child(new CopyImageReferenceButton()) .child(new SvgExportButton()) + .child(new PngExportButton()) .child(new BacklinksWidget()) .child(new ContextualHelpButton()) .child(new HideFloatingButtonsButton()) diff --git a/src/public/app/services/utils.ts b/src/public/app/services/utils.ts index ee76093bb..aef3985c5 100644 --- a/src/public/app/services/utils.ts +++ b/src/public/app/services/utils.ts @@ -609,9 +609,20 @@ function createImageSrcUrl(note: { noteId: string; title: string }) { */ function downloadSvg(nameWithoutExtension: string, svgContent: string) { const filename = `${nameWithoutExtension}.svg`; + const dataUrl = `data:image/svg+xml;charset=utf-8,${encodeURIComponent(svgContent)}`; + triggerDownload(filename, dataUrl); +} + +/** + * Downloads the given data URL on the client device, with a custom file name. + * + * @param fileName the name to give the downloaded file. + * @param dataUrl the data URI to download. + */ +function triggerDownload(fileName: string, dataUrl: string) { const element = document.createElement("a"); - element.setAttribute("href", `data:image/svg+xml;charset=utf-8,${encodeURIComponent(svgContent)}`); - element.setAttribute("download", filename); + element.setAttribute("href", dataUrl); + element.setAttribute("download", fileName); element.style.display = "none"; document.body.appendChild(element); @@ -621,6 +632,56 @@ function downloadSvg(nameWithoutExtension: string, svgContent: 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. + * + * @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 `true` if the operation succeeded (width/height present), or `false` if the download was not triggered. + */ +function downloadSvgAsPng(nameWithoutExtension: string, svgContent: string) { + const mime = "image/svg+xml"; + + // First, we need to determine the width and the height from the input SVG. + const svgDocument = (new DOMParser()).parseFromString(svgContent, mime); + const width = svgDocument.documentElement?.getAttribute("width"); + const height = svgDocument.documentElement?.getAttribute("height"); + + if (!width || !height) { + return false; + } + + // Convert the image to a blob. + const svgBlob = new Blob([ svgContent ], { + type: mime + }) + + // Create an image element and load the SVG. + const imageEl = new Image(); + imageEl.width = parseFloat(width); + imageEl.height = parseFloat(height); + imageEl.src = URL.createObjectURL(svgBlob); + imageEl.onload = () => { + // 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"); + ctx?.drawImage(imageEl, 0, 0); + URL.revokeObjectURL(imageEl.src); + + const imgUri = canvasEl.toDataURL("image/png") + triggerDownload(`${nameWithoutExtension}.png`, imgUri); + document.body.removeChild(canvasEl); + }; + + return true; +} + /** * Compares two semantic version strings. * Returns: @@ -719,6 +780,7 @@ export default { copyHtmlToClipboard, createImageSrcUrl, downloadSvg, + downloadSvgAsPng, compareVersions, isUpdateAvailable, isLaunchBarConfig diff --git a/src/public/app/widgets/floating_buttons/png_export_button.ts b/src/public/app/widgets/floating_buttons/png_export_button.ts new file mode 100644 index 000000000..c1a04bed9 --- /dev/null +++ b/src/public/app/widgets/floating_buttons/png_export_button.ts @@ -0,0 +1,24 @@ +import { t } from "../../services/i18n.js"; +import NoteContextAwareWidget from "../note_context_aware_widget.js"; + +const TPL = ` + +`; + +export default class PngExportButton extends NoteContextAwareWidget { + isEnabled() { + return super.isEnabled() && ["mermaid"].includes(this.note?.type ?? "") && this.note?.isContentAvailable() && this.noteContext?.viewScope?.viewMode === "default"; + } + + doRender() { + super.doRender(); + + this.$widget = $(TPL); + this.$widget.on("click", () => this.triggerEvent("exportPng", { ntxId: this.ntxId })); + this.contentSized(); + } +} diff --git a/src/public/app/widgets/type_widgets/abstract_svg_split_type_widget.ts b/src/public/app/widgets/type_widgets/abstract_svg_split_type_widget.ts index 47d7b5d0b..aeea96089 100644 --- a/src/public/app/widgets/type_widgets/abstract_svg_split_type_widget.ts +++ b/src/public/app/widgets/type_widgets/abstract_svg_split_type_widget.ts @@ -217,4 +217,12 @@ export default abstract class AbstractSvgSplitTypeWidget extends AbstractSplitTy utils.downloadSvg(this.note.title, this.svg); } + async exportPngEvent({ ntxId }: EventData<"exportPng">) { + if (!this.isNoteContext(ntxId) || this.note?.type !== "mermaid" || !this.svg) { + return; + } + + utils.downloadSvgAsPng(this.note.title, this.svg); + } + } diff --git a/src/public/translations/en/translation.json b/src/public/translations/en/translation.json index 46bf2ed4d..865d0dca0 100644 --- a/src/public/translations/en/translation.json +++ b/src/public/translations/en/translation.json @@ -1708,5 +1708,8 @@ "toggle_read_only_button": { "unlock-editing": "Unlock editing", "lock-editing": "Lock editing" + }, + "png_export_button": { + "button_title": "Export diagram as PNG" } }