fix(mermaid): bring back pan/zoom

This commit is contained in:
Elian Doran 2025-03-21 22:53:52 +02:00
parent e0a8b64b4d
commit cbc6efdad2
No known key found for this signature in database
2 changed files with 188 additions and 202 deletions

View File

@ -1,239 +1,161 @@
import { t } from "../services/i18n.js"; // import { t } from "../services/i18n.js";
import libraryLoader from "../services/library_loader.js"; // import libraryLoader from "../services/library_loader.js";
import NoteContextAwareWidget from "./note_context_aware_widget.js"; // import NoteContextAwareWidget from "./note_context_aware_widget.js";
import server from "../services/server.js"; // import server from "../services/server.js";
import utils from "../services/utils.js"; // import utils from "../services/utils.js";
import { loadElkIfNeeded, postprocessMermaidSvg } from "../services/mermaid.js"; // import { loadElkIfNeeded, postprocessMermaidSvg } from "../services/mermaid.js";
import type FNote from "../entities/fnote.js"; // import type FNote from "../entities/fnote.js";
import type { EventData } from "../components/app_context.js"; // import type { EventData } from "../components/app_context.js";
const TPL = `<div class="mermaid-widget"> // const TPL = `<div class="mermaid-widget">
<style> // <style>
.mermaid-widget { // .mermaid-widget {
overflow: auto; // overflow: auto;
} // }
body.mobile .mermaid-widget { // body.mobile .mermaid-widget {
min-height: 200px; // min-height: 200px;
flex-grow: 2; // flex-grow: 2;
flex-basis: 0; // flex-basis: 0;
border-bottom: 1px solid var(--main-border-color); // border-bottom: 1px solid var(--main-border-color);
margin-bottom: 10px; // margin-bottom: 10px;
} // }
body.desktop .mermaid-widget + .gutter { // body.desktop .mermaid-widget + .gutter {
border-bottom: 1px solid var(--main-border-color); // border-bottom: 1px solid var(--main-border-color);
} // }
.mermaid-render { // .mermaid-render {
overflow: auto; // overflow: auto;
height: 100%; // height: 100%;
text-align: center; // text-align: center;
} // }
.mermaid-render svg { // .mermaid-render svg {
max-width: 100% !important; // max-width: 100% !important;
width: 100%; // width: 100%;
} // }
</style> // </style>
<div class="mermaid-error alert alert-warning"> // <div class="mermaid-error alert alert-warning">
<p><strong>${t("mermaid.diagram_error")}</strong></p> // <p><strong>${t("mermaid.diagram_error")}</strong></p>
<p class="error-content"></p> // <p class="error-content"></p>
</div> // </div>
<div class="mermaid-render"></div> // <div class="mermaid-render"></div>
</div>`; // </div>`;
let idCounter = 1; // export default class MermaidWidget extends NoteContextAwareWidget {
export default class MermaidWidget extends NoteContextAwareWidget { // private $display!: JQuery<HTMLElement>;
// private $errorContainer!: JQuery<HTMLElement>;
// private $errorMessage!: JQuery<HTMLElement>;
// private dirtyAttachment?: boolean;
// private lastNote?: FNote;
private $display!: JQuery<HTMLElement>; // isEnabled() {
private $errorContainer!: JQuery<HTMLElement>; // return super.isEnabled() && this.note?.type === "mermaid" && this.note.isContentAvailable() && this.noteContext?.viewScope?.viewMode === "default";
private $errorMessage!: JQuery<HTMLElement>; // }
private dirtyAttachment?: boolean;
private zoomHandler?: () => void;
private zoomInstance?: SvgPanZoom.Instance;
private lastNote?: FNote;
isEnabled() { // doRender() {
return super.isEnabled() && this.note?.type === "mermaid" && this.note.isContentAvailable() && this.noteContext?.viewScope?.viewMode === "default"; // this.$widget = $(TPL);
} // this.contentSized();
// this.$display = this.$widget.find(".mermaid-render");
// this.$errorContainer = this.$widget.find(".mermaid-error");
// this.$errorMessage = this.$errorContainer.find(".error-content");
// }
doRender() { // async refreshWithNote(note: FNote) {
this.$widget = $(TPL); // const isSameNote = (this.lastNote === note);
this.contentSized();
this.$display = this.$widget.find(".mermaid-render");
this.$errorContainer = this.$widget.find(".mermaid-error");
this.$errorMessage = this.$errorContainer.find(".error-content");
}
async refreshWithNote(note: FNote) { // this.cleanup();
const isSameNote = (this.lastNote === note); // this.$errorContainer.hide();
this.cleanup(); // if (!isSameNote) {
this.$errorContainer.hide(); // this.$display.empty();
// }
await libraryLoader.requireLibrary(libraryLoader.MERMAID); // this.$errorContainer.hide();
mermaid.mermaidAPI.initialize({ // try {
startOnLoad: false, // const svg = await this.renderSvg();
...(getMermaidConfig() as any)
});
if (!isSameNote) { // if (this.dirtyAttachment) {
this.$display.empty(); // const payload = {
} // role: "image",
// title: "mermaid-export.svg",
// mime: "image/svg+xml",
// content: svg,
// position: 0
// };
this.$errorContainer.hide(); // server.post(`notes/${this.noteId}/attachments?matchBy=title`, payload).then(() => {
// this.dirtyAttachment = false;
// });
// }
try { // this.$display.html(svg);
const svg = await this.renderSvg(); // this.$display.attr("id", `mermaid-render-${idCounter}`);
if (this.dirtyAttachment) { // // Enable pan to zoom.
const payload = { // this.#setupPanZoom($svg[0], isSameNote);
role: "image", // } catch (e: any) {
title: "mermaid-export.svg", // console.warn(e);
mime: "image/svg+xml", // this.#cleanUpZoom();
content: svg, // this.$display.empty();
position: 0 // this.$errorMessage.text(e.message);
}; // this.$errorContainer.show();
// }
server.post(`notes/${this.noteId}/attachments?matchBy=title`, payload).then(() => { // this.lastNote = note;
this.dirtyAttachment = false; // }
});
}
this.$display.html(svg); // cleanup() {
this.$display.attr("id", `mermaid-render-${idCounter}`); // super.cleanup();
// if (this.zoomHandler) {
// $(window).off("resize", this.zoomHandler);
// this.zoomHandler = undefined;
// }
// }
// Fit the image to bounds.
const $svg = this.$display.find("svg");
$svg.attr("width", "100%").attr("height", "100%");
// Enable pan to zoom.
this.#setupPanZoom($svg[0], isSameNote);
} catch (e: any) {
console.warn(e);
this.#cleanUpZoom();
this.$display.empty();
this.$errorMessage.text(e.message);
this.$errorContainer.show();
}
this.lastNote = note; // toggleInt(show: boolean | null | undefined): void {
} // super.toggleInt(show);
cleanup() { // if (!show) {
super.cleanup(); // this.cleanup();
if (this.zoomHandler) { // }
$(window).off("resize", this.zoomHandler); // }
this.zoomHandler = undefined;
}
}
#cleanUpZoom() { // async renderSvg() {
if (this.zoomInstance) {
this.zoomInstance.destroy();
this.zoomInstance = undefined;
}
}
toggleInt(show: boolean | null | undefined): void {
super.toggleInt(show);
if (!show) { // if (!this.note) {
this.cleanup(); // return "";
} // }
}
async renderSvg() { // await loadElkIfNeeded(content);
idCounter++;
if (!this.note) { // }
return "";
}
const blob = await this.note.getBlob();
const content = blob?.content || "";
await loadElkIfNeeded(content);
const { svg } = await mermaid.mermaidAPI.render(`mermaid-graph-${idCounter}`, content);
return postprocessMermaidSvg(svg);
}
async #setupPanZoom(svgEl: SVGElement, isSameNote: boolean) { // async entitiesReloadedEvent({ loadResults }: EventData<"entitiesReloaded">) {
// Clean up // if (this.noteId && loadResults.isNoteContentReloaded(this.noteId)) {
let pan = null; // this.dirtyAttachment = true;
let zoom = null;
if (this.zoomInstance) {
// Store pan and zoom for same note, when the user is editing the note.
if (isSameNote && this.zoomInstance) {
pan = this.zoomInstance.getPan();
zoom = this.zoomInstance.getZoom();
}
this.#cleanUpZoom();
}
const svgPanZoom = (await import("svg-pan-zoom")).default; // await this.refresh();
const zoomInstance = svgPanZoom(svgEl, { // }
zoomEnabled: true, // }
controlIconsEnabled: true
});
if (pan && zoom) { // async exportSvgEvent({ ntxId }: EventData<"exportSvg">) {
// Restore the pan and zoom. // if (!this.isNoteContext(ntxId) || this.note?.type !== "mermaid") {
zoomInstance.zoom(zoom); // return;
zoomInstance.pan(pan); // }
} else {
// New instance, reposition properly.
zoomInstance.center();
zoomInstance.fit();
}
this.zoomHandler = () => { // const svg = await this.renderSvg();
zoomInstance.resize(); // utils.downloadSvg(this.note.title, svg);
zoomInstance.fit(); // }
zoomInstance.center(); // }
};
this.zoomInstance = zoomInstance;
$(window).on("resize", this.zoomHandler);
}
async entitiesReloadedEvent({ loadResults }: EventData<"entitiesReloaded">) {
if (this.noteId && loadResults.isNoteContentReloaded(this.noteId)) {
this.dirtyAttachment = true;
await this.refresh();
}
}
async exportSvgEvent({ ntxId }: EventData<"exportSvg">) {
if (!this.isNoteContext(ntxId) || this.note?.type !== "mermaid") {
return;
}
const svg = await this.renderSvg();
utils.downloadSvg(this.note.title, svg);
}
}
export function getMermaidConfig(): MermaidConfig {
const documentStyle = window.getComputedStyle(document.documentElement);
const mermaidTheme = documentStyle.getPropertyValue("--mermaid-theme");
return {
theme: mermaidTheme.trim(),
securityLevel: "antiscript",
// TODO: Are all these options correct?
flow: { useMaxWidth: false },
sequence: { useMaxWidth: false },
gantt: { useMaxWidth: false },
class: { useMaxWidth: false },
state: { useMaxWidth: false },
pie: { useMaxWidth: true },
journey: { useMaxWidth: false },
git: { useMaxWidth: false }
};
}

View File

@ -12,10 +12,14 @@ import AbstractSplitTypeWidget from "./abstract_split_type_widget.js";
export default abstract class AbstractSvgSplitTypeWidget extends AbstractSplitTypeWidget { export default abstract class AbstractSvgSplitTypeWidget extends AbstractSplitTypeWidget {
private $renderContainer!: JQuery<HTMLElement>; private $renderContainer!: JQuery<HTMLElement>;
private zoomHandler?: () => void;
private zoomInstance?: SvgPanZoom.Instance;
doRender(): void { doRender(): void {
super.doRender(); super.doRender();
this.$renderContainer = $(`<div class="render-container"></div>`); this.$renderContainer = $(`<div>`)
.addClass("render-container")
.css("height", "100%");
this.$preview.append(this.$renderContainer); this.$preview.append(this.$renderContainer);
} }
@ -42,9 +46,15 @@ export default abstract class AbstractSvgSplitTypeWidget extends AbstractSplitTy
if (this.note) { if (this.note) {
const svg = await this.renderSvg(content); const svg = await this.renderSvg(content);
this.$renderContainer.html(svg); this.$renderContainer.html(svg);
await this.#setupPanZoom();
} }
} }
cleanup(): void {
this.#cleanUpZoom();
super.cleanup();
}
/** /**
* Called upon when the SVG preview needs refreshing, such as when the editor has switched to a new note or the content has switched. * Called upon when the SVG preview needs refreshing, such as when the editor has switched to a new note or the content has switched.
* *
@ -54,4 +64,58 @@ export default abstract class AbstractSvgSplitTypeWidget extends AbstractSplitTy
*/ */
abstract renderSvg(content: string): Promise<string>; abstract renderSvg(content: string): Promise<string>;
async #setupPanZoom() {
// Clean up
let pan = null;
let zoom = null;
if (this.zoomInstance) {
// Store pan and zoom for same note, when the user is editing the note.
pan = this.zoomInstance.getPan();
zoom = this.zoomInstance.getZoom();
this.#cleanUpZoom();
}
const $svgEl = this.$renderContainer.find("svg");
// Fit the image to bounds
$svgEl.attr("width", "100%")
.attr("height", "100%")
.css("max-width", "100%");
if (!$svgEl.length) {
return;
}
const svgPanZoom = (await import("svg-pan-zoom")).default;
const zoomInstance = svgPanZoom($svgEl[0], {
zoomEnabled: true,
controlIconsEnabled: true
});
if (pan && zoom) {
// Restore the pan and zoom.
zoomInstance.zoom(zoom);
zoomInstance.pan(pan);
} else {
// New instance, reposition properly.
zoomInstance.center();
zoomInstance.fit();
}
this.zoomHandler = () => {
zoomInstance.resize();
zoomInstance.fit();
zoomInstance.center();
};
this.zoomInstance = zoomInstance;
$(window).on("resize", this.zoomHandler);
}
#cleanUpZoom() {
if (this.zoomInstance) {
this.zoomInstance.destroy();
this.zoomInstance = undefined;
}
}
} }