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_split_type_widget.ts b/src/public/app/widgets/type_widgets/abstract_split_type_widget.ts
index c14460fc5..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;
@@ -184,7 +188,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;
}
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 40ce5a0ef..fc66ba09d 100644
--- a/src/public/translations/en/translation.json
+++ b/src/public/translations/en/translation.json
@@ -1724,5 +1724,8 @@
"toggle_read_only_button": {
"unlock-editing": "Unlock editing",
"lock-editing": "Lock editing"
+ },
+ "png_export_button": {
+ "button_title": "Export diagram as PNG"
}
}
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 6f355c61a..b626bf919 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.");
}
@@ -52,21 +65,17 @@ function exportSingleNote(taskContext: TaskContext, branch: BBranch, format: "ht
payload = content;
extension = "excalidraw";
mime = "application/json";
+ } else if (note.type === "mermaid") {
+ payload = content;
+ extension = "mermaid";
+ mime = "text/vnd.mermaid";
} else if (note.type === "relationMap" || note.type === "search") {
payload = content;
extension = "json";
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) {
diff --git a/src/services/import/mime.spec.ts b/src/services/import/mime.spec.ts
index 1e32ba3eb..5b04d0f2a 100644
--- a/src/services/import/mime.spec.ts
+++ b/src/services/import/mime.spec.ts
@@ -26,6 +26,15 @@ describe("#getMime", () => {
["test.excalidraw"], "application/json"
],
+ [
+ "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",
["test.gRoOvY"], "text/x-groovy"
diff --git a/src/services/import/mime.ts b/src/services/import/mime.ts
index bab661c63..5f050184f 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,9 @@ 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"],
+ [".mmd", "text/vnd.mermaid"]
]);
/** @returns false if MIME is not detected */
@@ -85,7 +88,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 +101,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/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 1eacd261b..e26934b08 100644
--- a/src/services/import/single.spec.ts
+++ b/src/services/import/single.spec.ts
@@ -96,4 +96,22 @@ describe("processNoteContent", () => {
expect(importedNote.type).toBe("canvas");
expect(importedNote.title).toBe("New note");
});
+
+ it("imports .mermaid as mermaid note", async () => {
+ const { importedNote } = await testImport("New note.mermaid", "application/json");
+ expect(importedNote).toMatchObject({
+ mime: "text/vnd.mermaid",
+ type: "mermaid",
+ 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/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/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: "" },
diff --git a/src/services/utils.ts b/src/services/utils.ts
index 3cb84d3a1..926e24fb4 100644
--- a/src/services/utils.ts
+++ b/src/services/utils.ts
@@ -181,6 +181,8 @@ export function removeTextFileExtension(filePath: string) {
case ".html":
case ".htm":
case ".excalidraw":
+ case ".mermaid":
+ case ".mmd":
return filePath.substring(0, filePath.length - extension.length);
default:
return filePath;