From 6ab820f172b56957ef5a32835f0c25f9d2cd2751 Mon Sep 17 00:00:00 2001 From: Elian Doran Date: Sat, 22 Mar 2025 15:20:58 +0200 Subject: [PATCH 1/8] refactor(export/single): make note type mapping testable --- src/services/export/single.ts | 25 +++++++++++++++---------- 1 file changed, 15 insertions(+), 10 deletions(-) diff --git a/src/services/export/single.ts b/src/services/export/single.ts index 6f355c61a..18ea83036 100644 --- a/src/services/export/single.ts +++ b/src/services/export/single.ts @@ -8,6 +8,7 @@ import becca from "../../becca/becca.js"; import type TaskContext from "../task_context.js"; import type BBranch from "../../becca/entities/bbranch.js"; import type { Response } from "express"; +import type BNote from "../../becca/entities/bnote.js"; function exportSingleNote(taskContext: TaskContext, branch: BBranch, format: "html" | "markdown", res: Response) { const note = branch.getNote(); @@ -20,9 +21,21 @@ function exportSingleNote(taskContext: TaskContext, branch: BBranch, format: "ht return [400, `Unrecognized format '${format}'`]; } + const { payload, extension, mime } = mapByNoteType(note, note.getContent(), format); + const fileName = `${note.title}.${extension}`; + + res.setHeader("Content-Disposition", getContentDisposition(fileName)); + res.setHeader("Content-Type", `${mime}; charset=UTF-8`); + + res.send(payload); + + taskContext.increaseProgressCount(); + taskContext.taskSucceeded(); +} + +export function mapByNoteType(note: BNote, content: string | Buffer, format: "html" | "markdown") { let payload, extension, mime; - let content = note.getContent(); if (typeof content !== "string") { throw new Error("Unsupported content type for export."); } @@ -58,15 +71,7 @@ function exportSingleNote(taskContext: TaskContext, branch: BBranch, format: "ht mime = "application/json"; } - const fileName = `${note.title}.${extension}`; - - res.setHeader("Content-Disposition", getContentDisposition(fileName)); - res.setHeader("Content-Type", `${mime}; charset=UTF-8`); - - res.send(payload); - - taskContext.increaseProgressCount(); - taskContext.taskSucceeded(); + return { payload, extension, mime }; } function inlineAttachments(content: string) { From 16cbd2f793d9e13879a062e5337f216c556b397c Mon Sep 17 00:00:00 2001 From: Elian Doran Date: Sat, 22 Mar 2025 15:22:55 +0200 Subject: [PATCH 2/8] feat(export/single): mermaid with right extension and MIME --- src/services/export/single.spec.ts | 17 +++++++++++++++++ src/services/export/single.ts | 4 ++++ 2 files changed, 21 insertions(+) create mode 100644 src/services/export/single.spec.ts diff --git a/src/services/export/single.spec.ts b/src/services/export/single.spec.ts new file mode 100644 index 000000000..cf9d391fc --- /dev/null +++ b/src/services/export/single.spec.ts @@ -0,0 +1,17 @@ +import { describe, expect, it } from "vitest"; +import BNote from "../../becca/entities/bnote.js"; +import { mapByNoteType } from "./single.js"; + +describe("Note type mappings", () => { + it("supports mermaid note", () => { + const note = new BNote({ + type: "mermaid", + title: "New note" + }); + + expect(mapByNoteType(note, "", "html")).toMatchObject({ + extension: "mermaid", + mime: "text/vnd.mermaid" + }); + }); +}); diff --git a/src/services/export/single.ts b/src/services/export/single.ts index 18ea83036..b626bf919 100644 --- a/src/services/export/single.ts +++ b/src/services/export/single.ts @@ -65,6 +65,10 @@ export function mapByNoteType(note: BNote, content: string | Buffer Date: Sat, 22 Mar 2025 15:41:56 +0200 Subject: [PATCH 3/8] feat(import/single): mermaid with .mermaid extension --- src/services/import/mime.spec.ts | 5 +++++ src/services/import/mime.ts | 9 ++++++-- src/services/import/samples/New note.mermaid | 5 +++++ src/services/import/single.spec.ts | 9 ++++++++ src/services/import/single.ts | 22 ++++++++++++++++++++ src/services/utils.ts | 1 + 6 files changed, 49 insertions(+), 2 deletions(-) create mode 100644 src/services/import/samples/New note.mermaid diff --git a/src/services/import/mime.spec.ts b/src/services/import/mime.spec.ts index 1e32ba3eb..7281d31c2 100644 --- a/src/services/import/mime.spec.ts +++ b/src/services/import/mime.spec.ts @@ -26,6 +26,11 @@ describe("#getMime", () => { ["test.excalidraw"], "application/json" ], + [ + "File extension ('.mermaid') that is defined in EXTENSION_TO_MIME", + ["test.mermaid"], "text/vnd.mermaid" + ], + [ "File extension with inconsistent capitalization that is defined in EXTENSION_TO_MIME", ["test.gRoOvY"], "text/x-groovy" diff --git a/src/services/import/mime.ts b/src/services/import/mime.ts index bab661c63..cb400b8d8 100644 --- a/src/services/import/mime.ts +++ b/src/services/import/mime.ts @@ -3,6 +3,7 @@ import mimeTypes from "mime-types"; import path from "path"; import type { TaskData } from "../task_context_interface.js"; +import type { NoteType } from "../../becca/entities/rows.js"; const CODE_MIME_TYPES = new Set([ "application/json", @@ -68,7 +69,8 @@ const EXTENSION_TO_MIME = new Map([ [".scala", "text/x-scala"], [".swift", "text/x-swift"], [".ts", "text/x-typescript"], - [".excalidraw", "application/json"] + [".excalidraw", "application/json"], + [".mermaid", "text/vnd.mermaid"] ]); /** @returns false if MIME is not detected */ @@ -85,7 +87,7 @@ function getMime(fileName: string) { return mimeFromExt || mimeTypes.lookup(fileNameLc); } -function getType(options: TaskData, mime: string) { +function getType(options: TaskData, mime: string): NoteType { const mimeLc = mime?.toLowerCase(); switch (true) { @@ -98,6 +100,9 @@ function getType(options: TaskData, mime: string) { case mime.startsWith("image/"): return "image"; + case mime === "text/vnd.mermaid": + return "mermaid"; + default: return "file"; } diff --git a/src/services/import/samples/New note.mermaid b/src/services/import/samples/New note.mermaid new file mode 100644 index 000000000..577e63359 --- /dev/null +++ b/src/services/import/samples/New note.mermaid @@ -0,0 +1,5 @@ +graph TD; + A-->B; + A-->C; + B-->D; + C-->D; \ No newline at end of file diff --git a/src/services/import/single.spec.ts b/src/services/import/single.spec.ts index 1eacd261b..060801eb9 100644 --- a/src/services/import/single.spec.ts +++ b/src/services/import/single.spec.ts @@ -96,4 +96,13 @@ describe("processNoteContent", () => { expect(importedNote.type).toBe("canvas"); expect(importedNote.title).toBe("New note"); }); + + it("supports mermaid note", async () => { + const { importedNote } = await testImport("New note.mermaid", "application/json"); + expect(importedNote).toMatchObject({ + mime: "text/vnd.mermaid", + type: "mermaid", + title: "New note" + }); + }); }); diff --git a/src/services/import/single.ts b/src/services/import/single.ts index 4105e55e3..c1597a562 100644 --- a/src/services/import/single.ts +++ b/src/services/import/single.ts @@ -27,6 +27,10 @@ function importSingleFile(taskContext: TaskContext, file: File, parentNote: BNot } } + if (mime === "text/vnd.mermaid") { + return importCustomType(taskContext, file, parentNote, "mermaid", mime); + } + if (taskContext?.data?.codeImportedAsCode && mimeService.getType(taskContext.data, mime) === "code") { return importCodeNote(taskContext, file, parentNote); } @@ -93,6 +97,24 @@ function importCodeNote(taskContext: TaskContext, file: File, parentNote: BNote) return note; } +function importCustomType(taskContext: TaskContext, file: File, parentNote: BNote, type: NoteType, mime: string) { + const title = getNoteTitle(file.originalname, !!taskContext.data?.replaceUnderscoresWithSpaces); + const content = processStringOrBuffer(file.buffer); + + const { note } = noteService.createNewNote({ + parentNoteId: parentNote.noteId, + title, + content, + type, + mime: mime, + isProtected: parentNote.isProtected && protectedSessionService.isProtectedSessionAvailable() + }); + + taskContext.increaseProgressCount(); + + return note; +} + function importPlainText(taskContext: TaskContext, file: File, parentNote: BNote) { const title = getNoteTitle(file.originalname, !!taskContext.data?.replaceUnderscoresWithSpaces); const plainTextContent = processStringOrBuffer(file.buffer); diff --git a/src/services/utils.ts b/src/services/utils.ts index 3cb84d3a1..87dcf2d67 100644 --- a/src/services/utils.ts +++ b/src/services/utils.ts @@ -181,6 +181,7 @@ export function removeTextFileExtension(filePath: string) { case ".html": case ".htm": case ".excalidraw": + case ".mermaid": return filePath.substring(0, filePath.length - extension.length); default: return filePath; From 858ad91708718a0ed59184bcc51786330cc48666 Mon Sep 17 00:00:00 2001 From: Elian Doran Date: Sat, 22 Mar 2025 15:45:36 +0200 Subject: [PATCH 4/8] feat(import/single): mermaid with .mmd extension --- src/services/import/mime.spec.ts | 4 ++++ src/services/import/mime.ts | 3 ++- src/services/import/samples/New note.mmd | 5 +++++ src/services/import/single.spec.ts | 11 ++++++++++- src/services/utils.ts | 1 + 5 files changed, 22 insertions(+), 2 deletions(-) create mode 100644 src/services/import/samples/New note.mmd diff --git a/src/services/import/mime.spec.ts b/src/services/import/mime.spec.ts index 7281d31c2..5b04d0f2a 100644 --- a/src/services/import/mime.spec.ts +++ b/src/services/import/mime.spec.ts @@ -30,6 +30,10 @@ describe("#getMime", () => { "File extension ('.mermaid') that is defined in EXTENSION_TO_MIME", ["test.mermaid"], "text/vnd.mermaid" ], + [ + "File extension ('.mermaid') that is defined in EXTENSION_TO_MIME", + ["test.mmd"], "text/vnd.mermaid" + ], [ "File extension with inconsistent capitalization that is defined in EXTENSION_TO_MIME", diff --git a/src/services/import/mime.ts b/src/services/import/mime.ts index cb400b8d8..5f050184f 100644 --- a/src/services/import/mime.ts +++ b/src/services/import/mime.ts @@ -70,7 +70,8 @@ const EXTENSION_TO_MIME = new Map([ [".swift", "text/x-swift"], [".ts", "text/x-typescript"], [".excalidraw", "application/json"], - [".mermaid", "text/vnd.mermaid"] + [".mermaid", "text/vnd.mermaid"], + [".mmd", "text/vnd.mermaid"] ]); /** @returns false if MIME is not detected */ diff --git a/src/services/import/samples/New note.mmd b/src/services/import/samples/New note.mmd new file mode 100644 index 000000000..577e63359 --- /dev/null +++ b/src/services/import/samples/New note.mmd @@ -0,0 +1,5 @@ +graph TD; + A-->B; + A-->C; + B-->D; + C-->D; \ No newline at end of file diff --git a/src/services/import/single.spec.ts b/src/services/import/single.spec.ts index 060801eb9..e26934b08 100644 --- a/src/services/import/single.spec.ts +++ b/src/services/import/single.spec.ts @@ -97,7 +97,7 @@ describe("processNoteContent", () => { expect(importedNote.title).toBe("New note"); }); - it("supports mermaid note", async () => { + it("imports .mermaid as mermaid note", async () => { const { importedNote } = await testImport("New note.mermaid", "application/json"); expect(importedNote).toMatchObject({ mime: "text/vnd.mermaid", @@ -105,4 +105,13 @@ describe("processNoteContent", () => { title: "New note" }); }); + + it("imports .mmd as mermaid note", async () => { + const { importedNote } = await testImport("New note.mmd", "application/json"); + expect(importedNote).toMatchObject({ + mime: "text/vnd.mermaid", + type: "mermaid", + title: "New note" + }); + }); }); diff --git a/src/services/utils.ts b/src/services/utils.ts index 87dcf2d67..926e24fb4 100644 --- a/src/services/utils.ts +++ b/src/services/utils.ts @@ -182,6 +182,7 @@ export function removeTextFileExtension(filePath: string) { case ".htm": case ".excalidraw": case ".mermaid": + case ".mmd": return filePath.substring(0, filePath.length - extension.length); default: return filePath; From 5282f9f0bf6efa205f44f97241a3664289704b01 Mon Sep 17 00:00:00 2001 From: Elian Doran Date: Sat, 22 Mar 2025 15:48:03 +0200 Subject: [PATCH 5/8] feat(mermaid): set right mime type --- src/services/note_types.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/services/note_types.ts b/src/services/note_types.ts index 3e086acf4..a242fc7b2 100644 --- a/src/services/note_types.ts +++ b/src/services/note_types.ts @@ -8,7 +8,7 @@ const noteTypes = [ { type: "relationMap", defaultMime: "application/json" }, { type: "book", defaultMime: "" }, { type: "noteMap", defaultMime: "" }, - { type: "mermaid", defaultMime: "text/plain" }, + { type: "mermaid", defaultMime: "text/vnd.mermaid" }, { type: "canvas", defaultMime: "application/json" }, { type: "webView", defaultMime: "" }, { type: "launcher", defaultMime: "" }, From 9e75c32dedacb38311556d6223920dd01ea06906 Mon Sep 17 00:00:00 2001 From: Elian Doran Date: Sat, 22 Mar 2025 15:51:21 +0200 Subject: [PATCH 6/8] fix(mermaid): enforce vertical layout on mobile --- .../app/widgets/type_widgets/abstract_split_type_widget.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/public/app/widgets/type_widgets/abstract_split_type_widget.ts b/src/public/app/widgets/type_widgets/abstract_split_type_widget.ts index c14460fc5..151363555 100644 --- a/src/public/app/widgets/type_widgets/abstract_split_type_widget.ts +++ b/src/public/app/widgets/type_widgets/abstract_split_type_widget.ts @@ -184,7 +184,7 @@ export default abstract class AbstractSplitTypeWidget extends TypeWidget { } // Vertical vs horizontal layout - const layoutOrientation = options.get("splitEditorOrientation") ?? "horizontal"; + const layoutOrientation = (!utils.isMobile() ? options.get("splitEditorOrientation") ?? "horizontal" : "vertical"); if (this.layoutOrientation === layoutOrientation && this.isReadOnly === isReadOnly) { return; } From 047c4dc4ca721059803047dc3fa8049a36a77ba6 Mon Sep 17 00:00:00 2001 From: Elian Doran Date: Sat, 22 Mar 2025 15:55:55 +0200 Subject: [PATCH 7/8] fix(mermaid): not scrolling up properly (closes #282) --- .../app/widgets/type_widgets/abstract_split_type_widget.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/public/app/widgets/type_widgets/abstract_split_type_widget.ts b/src/public/app/widgets/type_widgets/abstract_split_type_widget.ts index 151363555..c05ed2d7c 100644 --- a/src/public/app/widgets/type_widgets/abstract_split_type_widget.ts +++ b/src/public/app/widgets/type_widgets/abstract_split_type_widget.ts @@ -40,6 +40,10 @@ const TPL = `\ flex-grow: 1; } + .note-detail-split .note-detail-split-editor .note-detail-code { + contain: size !important; + } + .note-detail-split .note-detail-error-container { font-family: var(--monospace-font-family); margin: 5px; From 7cc8dd082d6de7a2cc2a1964ba0a41ced34d33b4 Mon Sep 17 00:00:00 2001 From: Elian Doran Date: Sat, 22 Mar 2025 16:30:19 +0200 Subject: [PATCH 8/8] feat(mermaid): enable export as PNG (closes #536) --- src/public/app/components/app_context.ts | 5 +- src/public/app/layouts/desktop_layout.ts | 2 + src/public/app/services/utils.ts | 66 ++++++++++++++++++- .../floating_buttons/png_export_button.ts | 24 +++++++ .../abstract_svg_split_type_widget.ts | 8 +++ src/public/translations/en/translation.json | 3 + 6 files changed, 103 insertions(+), 5 deletions(-) create mode 100644 src/public/app/widgets/floating_buttons/png_export_button.ts 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" } }