fix(mermaid): bring back pan/zoom

pull/1492/head
Elian Doran 2025-03-21 22:53:52 +07:00
parent e0a8b64b4d
commit cbc6efdad2
No known key found for this signature in database
2 changed files with 226 additions and 240 deletions

@ -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 $display!: JQuery<HTMLElement>; // private $errorMessage!: JQuery<HTMLElement>;
private $errorContainer!: JQuery<HTMLElement>; // private dirtyAttachment?: boolean;
private $errorMessage!: JQuery<HTMLElement>; // private lastNote?: FNote;
private dirtyAttachment?: boolean;
private zoomHandler?: () => void; // isEnabled() {
private zoomInstance?: SvgPanZoom.Instance; // return super.isEnabled() && this.note?.type === "mermaid" && this.note.isContentAvailable() && this.noteContext?.viewScope?.viewMode === "default";
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");
doRender() { // this.$errorContainer = this.$widget.find(".mermaid-error");
this.$widget = $(TPL); // this.$errorMessage = this.$errorContainer.find(".error-content");
this.contentSized(); // }
this.$display = this.$widget.find(".mermaid-render");
this.$errorContainer = this.$widget.find(".mermaid-error"); // async refreshWithNote(note: FNote) {
this.$errorMessage = this.$errorContainer.find(".error-content"); // const isSameNote = (this.lastNote === note);
}
// this.cleanup();
async refreshWithNote(note: FNote) { // this.$errorContainer.hide();
const isSameNote = (this.lastNote === note);
// if (!isSameNote) {
this.cleanup(); // this.$display.empty();
this.$errorContainer.hide(); // }
await libraryLoader.requireLibrary(libraryLoader.MERMAID); // this.$errorContainer.hide();
mermaid.mermaidAPI.initialize({ // try {
startOnLoad: false, // const svg = await this.renderSvg();
...(getMermaidConfig() as any)
}); // if (this.dirtyAttachment) {
// const payload = {
if (!isSameNote) { // role: "image",
this.$display.empty(); // title: "mermaid-export.svg",
} // mime: "image/svg+xml",
// content: svg,
this.$errorContainer.hide(); // position: 0
// };
try {
const svg = await this.renderSvg(); // server.post(`notes/${this.noteId}/attachments?matchBy=title`, payload).then(() => {
// this.dirtyAttachment = false;
if (this.dirtyAttachment) { // });
const payload = { // }
role: "image",
title: "mermaid-export.svg", // this.$display.html(svg);
mime: "image/svg+xml", // this.$display.attr("id", `mermaid-render-${idCounter}`);
content: svg,
position: 0 // // Enable pan to zoom.
}; // this.#setupPanZoom($svg[0], isSameNote);
// } catch (e: any) {
server.post(`notes/${this.noteId}/attachments?matchBy=title`, payload).then(() => { // console.warn(e);
this.dirtyAttachment = false; // this.#cleanUpZoom();
}); // this.$display.empty();
} // this.$errorMessage.text(e.message);
// this.$errorContainer.show();
this.$display.html(svg); // }
this.$display.attr("id", `mermaid-render-${idCounter}`);
// this.lastNote = note;
// Fit the image to bounds. // }
const $svg = this.$display.find("svg");
$svg.attr("width", "100%").attr("height", "100%"); // cleanup() {
// super.cleanup();
// Enable pan to zoom. // if (this.zoomHandler) {
this.#setupPanZoom($svg[0], isSameNote); // $(window).off("resize", this.zoomHandler);
} catch (e: any) { // this.zoomHandler = undefined;
console.warn(e); // }
this.#cleanUpZoom(); // }
this.$display.empty();
this.$errorMessage.text(e.message);
this.$errorContainer.show();
} // toggleInt(show: boolean | null | undefined): void {
// super.toggleInt(show);
this.lastNote = note;
} // if (!show) {
// this.cleanup();
cleanup() { // }
super.cleanup(); // }
if (this.zoomHandler) {
$(window).off("resize", this.zoomHandler); // async renderSvg() {
this.zoomHandler = undefined;
}
} // if (!this.note) {
// return "";
#cleanUpZoom() { // }
if (this.zoomInstance) {
this.zoomInstance.destroy(); // await loadElkIfNeeded(content);
this.zoomInstance = undefined;
} // }
}
toggleInt(show: boolean | null | undefined): void {
super.toggleInt(show); // async entitiesReloadedEvent({ loadResults }: EventData<"entitiesReloaded">) {
// if (this.noteId && loadResults.isNoteContentReloaded(this.noteId)) {
if (!show) { // this.dirtyAttachment = true;
this.cleanup();
} // await this.refresh();
} // }
// }
async renderSvg() {
idCounter++; // async exportSvgEvent({ ntxId }: EventData<"exportSvg">) {
// if (!this.isNoteContext(ntxId) || this.note?.type !== "mermaid") {
if (!this.note) { // return;
return ""; // }
}
// const svg = await this.renderSvg();
const blob = await this.note.getBlob(); // utils.downloadSvg(this.note.title, svg);
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) {
// 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.
if (isSameNote && this.zoomInstance) {
pan = this.zoomInstance.getPan();
zoom = this.zoomInstance.getZoom();
}
this.#cleanUpZoom();
}
const svgPanZoom = (await import("svg-pan-zoom")).default;
const zoomInstance = svgPanZoom(svgEl, {
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);
}
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 }
};
}

@ -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;
}
}
} }