feat(share): pan and zoom a shared Mermaid note's diagram

A shared Mermaid note's diagram goes into a viewer once Mermaid has drawn it: zoom out, the
percentage and zoom in in its bottom corner, between 50% and 1000% of its width on the page;
clicking the percentage or double-clicking returns to the whole diagram. Dragging pans only once
zoomed in, so a touch on the diagram still scrolls the page at the fitted size, and the wheel
zooms only while the viewer has focus (`useZoomPanWheel`'s `isFocusRequired`). Code blocks in
text notes are left as they were.

The viewer, `mermaid_zoom.tsx`, is the first share-theme code to reuse the app's: it drives
react-zoom-pan-pinch through the client's `useZoomPanPinch` and `useZoomPanWheel` from
`zoom_pan.ts`, imported as `@triliumnext/client/src/...`, with the app's zoom bounds. Since the
client's Vite build bundles the share theme, the built viewer is 1.5 KB of its own and imports the
app's `jsxRuntime`, `hooks` and `zoom_pan` chunks. `ZoomControls` is not reused: it imports the
client's `hooks.ts` and with it most of the app; `useZoomPanKeyboard` likewise reaches `utils.ts`,
so the viewer has no keyboard panning. `mermaid.ts` loads the viewer on demand, so only a page
with a Mermaid note loads Preact. preact and react-zoom-pan-pinch are not declared by the share
theme: the hoisted install resolves them to the client's copy.

The labels come from core as `data-*` attributes on `.mermaid-note`, so custom templates get them
too (four `share_theme.*` keys in `server.json`).

Build: `LAZY_MODULES` groups the viewer with mermaid, so `share_theme.json` lists it and the
Preact chunks it loads under `mermaid` (137 files, 7 of them the viewer's, 74 KB) and a page's own
files stay at 34. `scripts.css` is now built with every dynamic import external, so a module
loaded on demand brings its stylesheet from the app's build instead of also landing in
`scripts.css`, which is byte-for-byte unchanged.

`hasMermaidDiagrams()` now counts a Mermaid note. It only looked for diagrams in text and
Markdown notes, so the export of a subtree whose diagrams were all Mermaid notes carried neither
mermaid nor the viewer and showed the saved images.

The share theme's TypeScript project references the client's, compiles JSX for Preact, and its
specs and coverage include `.tsx`; the viewer's spec fakes react-zoom-pan-pinch as the client's
`SvgSplitEditor.spec.tsx` does, since happy-dom cannot lay it out.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
This commit is contained in:
Elian Doran
2026-10-09 23:32:17 +03:00
co-authored by Claude Opus 5.5
parent fb8518b549
commit 09e4bd965e
18 changed files with 502 additions and 68 deletions
+45 -33
View File
@@ -105,7 +105,6 @@ function replaceOnce(code: string, pattern: RegExp, replacer: (match: string, ..
return code.replace(pattern, replacer);
}
/** The share theme's sources, which the app build bundles into the files shared pages load. */
const SHARE_THEME_SRC = join(import.meta.dirname, "../../packages/share-theme/src");
@@ -128,11 +127,15 @@ export const SHARE_THEME_MANIFEST_FILE = `${SHARE_THEME_DIR}/share_theme.json`;
const SHARE_THEME_TARGET = "chrome96";
/**
* The libraries the share theme imports on demand that a share-theme export copies only for the
* pages that use them, keyed by their name in {@link ShareThemeManifest.lazy}.
* The modules the share theme imports on demand that a share-theme export copies only for the
* pages that use them, grouped by their name in {@link ShareThemeManifest.lazy}: mermaid, and the
* viewer a Mermaid note's diagram goes into.
*/
const LAZY_LIBRARIES: Record<string, { specifier: string; importer: string }> = {
mermaid: { specifier: "mermaid", importer: "content/mermaid.ts" }
const LAZY_MODULES: Record<string, { specifier: string; importer: string }[]> = {
mermaid: [
{ specifier: "mermaid", importer: "content/mermaid.ts" },
{ specifier: "./mermaid_zoom.js", importer: "content/mermaid.ts" }
]
};
const CODE_THEMES_ID = "virtual:code-themes.css";
@@ -229,13 +232,17 @@ async function emitShareTheme(
config: ResolvedConfig | undefined,
options: ShareThemeOptions
) {
const lazyEntries: Record<string, string> = {};
for (const [ name, { specifier, importer } ] of Object.entries(LAZY_LIBRARIES)) {
const resolved = await context.resolve(specifier, join(SHARE_THEME_SRC, importer));
if (!resolved) {
throw new Error(`Unable to resolve '${specifier}' from the share theme's ${importer}.`);
const lazyEntries: Record<string, string[]> = {};
for (const [ name, modules ] of Object.entries(LAZY_MODULES)) {
lazyEntries[name] = [];
for (const { specifier, importer } of modules) {
const resolved = await context.resolve(specifier, join(SHARE_THEME_SRC, importer));
if (!resolved) {
const source = `'${specifier}' from the share theme's ${importer}`;
throw new Error(`Unable to resolve ${source}.`);
}
lazyEntries[name].push(resolved.id);
}
lazyEntries[name] = resolved.id;
}
const [ tree, styles ] = await Promise.all([ buildTreeScript(config), buildStyles(config) ]);
@@ -265,6 +272,7 @@ async function emitShareTheme(
emit(`${stubDir}/scripts.css`, `@import url("${toSource(STYLES_FILE)}");\n`);
}
}
/**
* Serves `virtual:code-themes.css`: the VS Code highlighting themes code notes also use, one per
* share theme mode. `:where()` keeps the block rules as weak as a stock highlight.js theme's, so
@@ -297,21 +305,22 @@ async function buildTreeScript(config: ResolvedConfig | undefined) {
}
/**
* Builds the styles of everything `index.ts` imports into one stylesheet, in the order it imports
* them. In the app's build, the stylesheets the theme shares with the app move into the shared
* chunks, which reorders the cascade. Returns the stylesheet and the files it references.
* Builds the styles of everything `index.ts` imports statically into one stylesheet, in the order
* it imports them. In the app's build, the stylesheets the theme shares with the app move into the
* shared chunks, which reorders the cascade. A module imported on demand brings its own styles
* from the app's build. Returns the stylesheet and the files it references.
*/
async function buildStyles(config: ResolvedConfig | undefined) {
const outputs = await buildShareThemeFile(config, "index.ts", {
base: "./",
plugins: [ codeThemesPlugin() ],
plugins: [
codeThemesPlugin(),
{ name: "share-theme-static-styles", resolveDynamicImport: () => false }
],
css: { transformer: config?.css.transformer },
build: {
cssCodeSplit: false,
assetsDir: "",
rollupOptions: {
external: Object.values(LAZY_LIBRARIES).map(({ specifier }) => specifier)
}
assetsDir: ""
}
});
const assets = outputs.flatMap((output) => (output.type === "asset" ? [ output ] : []));
@@ -373,23 +382,24 @@ type BundleOutput =
* references, and every chunk `scripts.js` loads, statically or on demand, with what each loads in
* turn. The styles of the chunks `scripts.js` imports statically are in `scripts.css`, so their
* stylesheets are listed only when a chunk loaded on demand imports them, which preloads them. What
* only the libraries in `lazyEntries` load is listed under their name, each given as the module ID
* the build resolves it to.
* only the modules in `lazyEntries` load is listed under the name of their group, each module given
* as the ID the build resolves it to.
*/
export function buildShareThemeManifest(
bundle: Record<string, BundleOutput>,
lazyEntries: Record<string, string>,
lazyEntries: Record<string, string[]>,
styleAssets: string[]
): ShareThemeManifest {
const lazyChunks = new Map(Object.values(bundle).flatMap((output) => {
const name = output.type === "chunk" && output.facadeModuleId
? Object.keys(lazyEntries).find((key) => lazyEntries[key] === output.facadeModuleId)
: undefined;
return name ? [ [ output.fileName, name ] as const ] : [];
}));
for (const name of Object.keys(lazyEntries)) {
if (![ ...lazyChunks.values() ].includes(name)) {
throw new Error(`The bundle has no chunk for the share theme's '${name}'.`);
const lazyChunks = new Map<string, string>();
for (const [ name, ids ] of Object.entries(lazyEntries)) {
for (const id of ids) {
const chunk = Object.values(bundle).find((output) =>
output.type === "chunk" && output.facadeModuleId === id);
if (!chunk) {
throw new Error(`The bundle has no chunk for '${id}' of the share theme's`
+ ` '${name}'.`);
}
lazyChunks.set(chunk.fileName, name);
}
}
@@ -407,8 +417,10 @@ export function buildShareThemeManifest(
]);
const lazy: Record<string, string[]> = {};
for (const [ fileName, name ] of lazyChunks) {
const lazyFiles = collectFiles(bundle, [ fileName ], { dynamic: true });
for (const name of Object.keys(lazyEntries)) {
const chunks = [ ...lazyChunks ].flatMap(([ fileName, group ]) =>
(group === name ? [ fileName ] : []));
const lazyFiles = collectFiles(bundle, chunks, { dynamic: true });
lazy[name] = toManifestPaths([ ...lazyFiles ].filter((file) => !files.has(file)));
}
+14 -7
View File
@@ -123,6 +123,7 @@ describe("stripUniverEmojiData", () => {
describe("buildShareThemeManifest", () => {
const MERMAID_ID = "/node_modules/mermaid/dist/mermaid.core.mjs";
const VIEWER_ID = "/packages/share-theme/src/content/mermaid_zoom.tsx";
const chunk = (fileName: string, extra: { facade?: string; imports?: string[];
dynamicImports?: string[]; css?: string[]; assets?: string[] } = {}) => ({
type: "chunk" as const,
@@ -139,7 +140,7 @@ describe("buildShareThemeManifest", () => {
const bundle = Object.fromEntries([
chunk("src/scripts.js", {
imports: [ "src/shared-a.js", "src/shared-k.js" ],
dynamicImports: [ "src/fuse-b.js", "src/mermaid.core-c.js" ],
dynamicImports: [ "src/fuse-b.js", "src/mermaid.core-c.js", "src/mermaid_zoom-l.js" ],
css: [ "src/scripts-j.css" ]
}),
chunk("src/shared-a.js", { css: [ "src/shared-a.css" ] }),
@@ -154,6 +155,8 @@ describe("buildShareThemeManifest", () => {
imports: [ "src/shared-k.js", "src/dagre-f.js" ],
dynamicImports: [ "src/flowchart-g.js" ]
}),
chunk("src/mermaid_zoom-l.js", { facade: VIEWER_ID, imports: [ "src/preact-m.js" ] }),
chunk("src/preact-m.js"),
chunk("src/dagre-f.js"),
chunk("src/flowchart-g.js"),
chunk("src/index-h.js", { imports: [ "src/shared-a.js" ] }),
@@ -161,32 +164,36 @@ describe("buildShareThemeManifest", () => {
.map((name) => asset(`src/${name}`))
].map((output) => [ output.fileName, output ]));
it("lists what every page loads apart from what only mermaid loads", () => {
it("lists what every page loads apart from what only the modules of mermaid load", () => {
// The styles of the chunks `scripts.js` imports are in `scripts.css`, but a chunk loaded on
// demand preloads the stylesheets of the chunks it imports.
const styleAssets = [ "src/KaTeX-i.woff2" ];
const manifest = buildShareThemeManifest(bundle, { mermaid: MERMAID_ID }, styleAssets);
const lazyEntries = { mermaid: [ MERMAID_ID, VIEWER_ID ] };
const manifest = buildShareThemeManifest(bundle, lazyEntries, styleAssets);
expect(manifest).toEqual({
files: [
"KaTeX-i.woff2", "font-e.woff2", "fuse-b.js", "fuse-d.css", "scripts.css",
"scripts.js", "shared-a.css", "shared-a.js", "shared-k.js", "tree.js"
],
lazy: {
mermaid: [ "dagre-f.js", "flowchart-g.js", "mermaid.core-c.js", "shared-k.css" ]
mermaid: [
"dagre-f.js", "flowchart-g.js", "mermaid.core-c.js", "mermaid_zoom-l.js",
"preact-m.js", "shared-k.css"
]
}
});
});
it("rejects a bundle without a library's chunk or with files outside one directory", () => {
expect(() => buildShareThemeManifest(bundle, { mermaid: "/elsewhere/mermaid.mjs" }, []))
.toThrow("no chunk for the share theme's 'mermaid'");
expect(() => buildShareThemeManifest(bundle, { mermaid: [ "/elsewhere/mermaid.mjs" ] }, []))
.toThrow("no chunk for '/elsewhere/mermaid.mjs' of the share theme's 'mermaid'");
const nested = {
...bundle,
"src/fuse-b.js": chunk("src/fuse-b.js", { imports: [ "src/nested/k.js" ] }),
"src/nested/k.js": chunk("src/nested/k.js")
};
expect(() => buildShareThemeManifest(nested, { mermaid: MERMAID_ID }, []))
expect(() => buildShareThemeManifest(nested, { mermaid: [ MERMAID_ID ] }, []))
.toThrow("must all be in 'src/': src/nested/k.js");
});
});
@@ -138,13 +138,16 @@ class="image">
the visitor switches it. Until it is drawn, and in browsers with JavaScript
disabled, the image saved when the note was last edited is shown instead.</li>
<li>The source of the diagram can be expanded below it.</li>
<li>The diagram can be zoomed with the <span class="tn-icon bx bx-minus-circle"></span> and <span
class="tn-icon bx bx-plus-circle"></span> buttons in its bottom corner,
between 50% and 1000% of its width on the page. Clicking the percentage
between them, or double-clicking the diagram, shows it whole again. Once
zoomed in, the diagram can be panned by dragging it, and on touch screens
it can be zoomed by pinching. After clicking the diagram, the mouse wheel
zooms it instead of scrolling the page.</li>
</ul>
</td>
<td>
<ul>
<li>No further interaction supported.</li>
</ul>
</td>
<td>&nbsp;</td>
</tr>
<tr>
<th><a class="reference-link" href="#root/_help_grjYqerjn243">Canvas</a>
@@ -468,7 +468,11 @@
"heading-link": "Link to This Section",
"previous": "Previous",
"next": "Next",
"page-navigation": "Previous and next pages"
"page-navigation": "Previous and next pages",
"diagram": "Diagram",
"zoom-in": "Zoom in",
"zoom-out": "Zoom out",
"zoom-reset": "Reset zoom"
},
"hidden_subtree_templates": {
"text-snippet": "Text Snippet",
+2 -2
View File
@@ -68,8 +68,8 @@ Trilium allows you to share selected notes as **publicly accessible** read-only
</tr>
<tr>
<th><a class="reference-link" href="../Note%20Types/Mermaid%20Diagrams.md">Mermaid Diagrams</a></th>
<td><ul><li>The diagram is drawn in the page's light or dark mode and redrawn when the visitor switches it. Until it is drawn, and in browsers with JavaScript disabled, the image saved when the note was last edited is shown instead.</li><li>The source of the diagram can be expanded below it.</li></ul></td>
<td><ul><li>No further interaction supported.</li></ul></td>
<td><ul><li>The diagram is drawn in the page's light or dark mode and redrawn when the visitor switches it. Until it is drawn, and in browsers with JavaScript disabled, the image saved when the note was last edited is shown instead.</li><li>The source of the diagram can be expanded below it.</li><li>The diagram can be zoomed with the <span class="tn-icon bx bx-minus-circle"></span> and <span class="tn-icon bx bx-plus-circle"></span> buttons in its bottom corner, between 50% and 1000% of its width on the page. Clicking the percentage between them, or double-clicking the diagram, shows it whole again. Once zoomed in, the diagram can be panned by dragging it, and on touch screens it can be zoomed by pinching. After clicking the diagram, the mouse wheel zooms it instead of scrolling the page.</li></ul></td>
<td>&nbsp;</td>
</tr>
<tr>
<th><a class="reference-link" href="../Note%20Types/Canvas.md">Canvas</a></th>
+1
View File
@@ -23,6 +23,7 @@
},
"devDependencies": {
"@triliumnext/ckeditor5": "workspace:*",
"@triliumnext/client": "workspace:*",
"@types/ejs": "3.1.5",
"ejs": "7.0.1",
"happy-dom": "20.14.5",
@@ -18,8 +18,12 @@ vi.mock("mermaid", () => ({
}
}));
const mountZoomPan = vi.hoisted(() => vi.fn());
vi.mock("./mermaid_zoom.js", () => ({ default: mountZoomPan }));
describe("setupMermaid", () => {
afterEach(() => {
mountZoomPan.mockReset();
document.documentElement.removeAttribute("style");
document.documentElement.removeAttribute("class");
document.body.innerHTML = "";
@@ -52,9 +56,10 @@ describe("setupMermaid", () => {
expect(document.querySelector("#content > .mermaid")).toBe(diagram);
expect(diagram?.querySelectorAll("svg")).toHaveLength(1);
expect(diagram?.querySelector("svg")?.id).not.toBe(firstId);
expect(mountZoomPan).not.toHaveBeenCalled();
});
it("draws a Mermaid note in place of its saved image and keeps the source block", async () => {
it("draws a Mermaid note in place of its image, in a viewer, keeping the source", async () => {
const fakeMermaid = stubMermaid();
document.body.innerHTML = MERMAID_NOTE;
@@ -65,6 +70,27 @@ describe("setupMermaid", () => {
expect(container?.querySelectorAll(":scope > .mermaid > svg")).toHaveLength(1);
expect(container?.querySelector("details pre.mermaid-note-source")).not.toBeNull();
expect(fakeMermaid.render).toHaveBeenLastCalledWith(expect.any(String), "graph TD; A-->B");
const diagram = container?.querySelector(":scope > .mermaid");
expect(mountZoomPan).toHaveBeenCalledExactlyOnceWith(diagram, {
label: "Diagram", zoomIn: "Zoom in", zoomOut: "Zoom out", zoomReset: "Reset zoom"
});
});
it("keeps a Mermaid note's diagram as drawn when its viewer cannot be loaded", async () => {
stubMermaid();
const consoleError = vi.spyOn(console, "error").mockImplementation(() => {});
mountZoomPan.mockImplementationOnce(() => {
throw new Error("Failed to fetch the viewer");
});
document.body.innerHTML = MERMAID_NOTE;
await setupMermaid();
expect(document.querySelectorAll("#content > .mermaid-note > .mermaid > svg"))
.toHaveLength(1);
const error = expect.objectContaining({ message: "Failed to fetch the viewer" });
expect(consoleError).toHaveBeenCalledWith(error);
consoleError.mockRestore();
});
it("keeps what a diagram replaces until Mermaid draws it, and draws every diagram again on a theme change", async () => {
@@ -127,7 +153,8 @@ describe("setupMermaid without diagrams", () => {
});
});
const MERMAID_NOTE =`<div id="content"><div class="mermaid-note">`
const MERMAID_NOTE = `<div id="content"><div class="mermaid-note" data-label="Diagram"`
+ ` data-zoom-in="Zoom in" data-zoom-out="Zoom out" data-zoom-reset="Reset zoom">`
+ `<img class="mermaid-note-image" src="api/images/abc/diagram">`
+ `<hr><details><summary>Chart source</summary>`
+ `<pre class="mermaid-note-source">graph TD; A--&gt;B</pre></details>`
+35 -7
View File
@@ -1,9 +1,12 @@
import { getMermaidConfig, type MermaidTheme, parseMermaidTheme } from "@triliumnext/commons/src/lib/mermaid_config.js";
import type { Mermaid } from "mermaid";
import type { ZoomPanLabels } from "./mermaid_zoom.js";
/**
* Draws the Mermaid diagrams on the page: code blocks in a text note, and Mermaid notes. A code
* block or a note's saved image stays in place until Mermaid has drawn its diagram.
* block or a note's saved image stays in place until Mermaid has drawn its diagram, and a Mermaid
* note's diagram then goes into a viewer that pans and zooms it.
*/
export default async function setupMermaid() {
const diagrams = findDiagrams();
@@ -36,11 +39,13 @@ interface Diagram {
/** The element the drawings go into. */
element: HTMLElement;
source: string;
/** The texts of the viewer a Mermaid note's diagram goes into. A code block has none. */
labels?: ZoomPanLabels;
}
/** The diagrams on the page, each with the element its drawing replaces. */
function findDiagrams() {
const placeholders: { placeholder: Element; source: string }[] = [];
const placeholders: Omit<Diagram, "element">[] = [];
for (const block of document.querySelectorAll("#content pre")) {
const code = block.querySelector(":scope > code.language-mermaid");
@@ -49,21 +54,28 @@ function findDiagrams() {
}
}
for (const note of document.querySelectorAll("#content .mermaid-note")) {
for (const note of document.querySelectorAll<HTMLElement>("#content .mermaid-note")) {
const image = note.querySelector(":scope > .mermaid-note-image");
const source = note.querySelector(".mermaid-note-source");
if (image && source) {
placeholders.push({ placeholder: image, source: source.textContent });
const labels = readLabels(note);
placeholders.push({ placeholder: image, source: source.textContent, labels });
}
}
return placeholders.map(({ placeholder, source }): Diagram => {
return placeholders.map((placeholder): Diagram => {
const element = document.createElement("div");
element.classList.add("mermaid");
return { placeholder, element, source };
return { ...placeholder, element };
});
}
/** The texts `content_renderer.ts` gives a Mermaid note's viewer in its `data-*` attributes. */
function readLabels(note: HTMLElement): ZoomPanLabels {
const { label = "", zoomIn = "", zoomOut = "", zoomReset = "" } = note.dataset;
return { label, zoomIn, zoomOut, zoomReset };
}
let renderCount = 0;
/**
@@ -72,7 +84,7 @@ let renderCount = 0;
*/
async function renderDiagrams(mermaid: Mermaid, diagrams: Diagram[], theme: MermaidTheme) {
mermaid.initialize({ ...getMermaidConfig(theme), startOnLoad: false });
for (const { placeholder, element, source } of diagrams) {
for (const { placeholder, element, source, labels } of diagrams) {
try {
const { svg } = await mermaid.render(`share-mermaid-${renderCount++}`, source);
element.innerHTML = svg;
@@ -83,10 +95,26 @@ async function renderDiagrams(mermaid: Mermaid, diagrams: Diagram[], theme: Merm
if (placeholder.isConnected) {
placeholder.replaceWith(element);
if (labels) {
await addZoomPan(element, labels);
}
}
}
}
/**
* Puts a drawn diagram in a viewer that pans and zooms it. Loaded on demand, so that only a page
* with a Mermaid note loads it. A diagram whose viewer fails to load stays as it is.
*/
async function addZoomPan(element: HTMLElement, labels: ZoomPanLabels) {
try {
const { default: mountZoomPan } = await import("./mermaid_zoom.js");
mountZoomPan(element, labels);
} catch (error) {
console.error(error);
}
}
function readMermaidTheme() {
return parseMermaidTheme(getComputedStyle(document.documentElement).getPropertyValue("--mermaid-theme"));
}
@@ -0,0 +1,74 @@
.mermaid-zoom {
position: relative;
}
.mermaid-zoom-viewport {
border-radius: 6px;
outline: 2px solid transparent;
outline-offset: 2px;
}
.mermaid-zoom-viewport:focus-visible {
outline-color: var(--link-focus-outline-color);
}
.mermaid-zoom .mermaid-zoom-wrapper,
.mermaid-zoom .mermaid-zoom-content {
width: 100%;
}
.mermaid-zoom .mermaid-zoom-content {
justify-content: center;
}
.mermaid-zoom .mermaid-zoom-content > div {
width: 100%;
}
.mermaid-zoom-controls {
position: absolute;
bottom: 8px;
inset-inline-end: 8px;
display: flex;
gap: 2px;
padding: 2px;
border-radius: 8px;
background: var(--background-highlight);
}
.mermaid-zoom-controls button {
display: flex;
align-items: center;
justify-content: center;
min-width: 30px;
height: 26px;
padding: 0 4px;
border: 0;
border-radius: 6px;
background: transparent;
color: var(--text-menu);
font-size: 18px;
cursor: pointer;
transition: background-color 150ms ease, color 150ms ease;
}
.mermaid-zoom-controls button:hover:not(:disabled) {
background: var(--background-primary);
color: var(--text-primary);
}
.mermaid-zoom-controls button:focus-visible {
outline: 2px solid var(--link-focus-outline-color);
outline-offset: 1px;
}
.mermaid-zoom-controls button:disabled {
opacity: 0.4;
cursor: default;
}
.mermaid-zoom-controls .mermaid-zoom-reset {
min-width: 48px;
font-size: 12px;
font-variant-numeric: tabular-nums;
}
@@ -0,0 +1,137 @@
// @vitest-environment happy-dom
import { type ComponentChildren, render } from "preact";
import { act } from "preact/test-utils";
import { afterEach, describe, expect, it, vi } from "vitest";
import mountZoomPan from "./mermaid_zoom.js";
// react-zoom-pan-pinch measures its boxes, which happy-dom cannot do. As in the client's
// `SvgSplitEditor.spec.tsx`, this fake keeps what the controls drive: `zoomIn`/`zoomOut` add their
// step to the scale within the bounds, `resetTransform` returns to the fitted view, and each change
// calls `onTransform`. It records the props the viewer passes to the library.
const { transformWrapperSpy } = vi.hoisted(() => ({ transformWrapperSpy: vi.fn() }));
vi.mock("react-zoom-pan-pinch", async () => {
const { forwardRef, useImperativeHandle, useRef } = await import("preact/compat");
interface FakeProps {
children?: ComponentChildren;
minScale: number;
maxScale: number;
onTransform?: (ref: unknown, state: { scale: number }) => void;
}
interface FakeComponentProps {
children?: ComponentChildren;
wrapperClass?: string;
contentClass?: string;
}
return {
TransformWrapper: forwardRef((props: FakeProps, ref) => {
transformWrapperSpy(props);
const state = useRef({ scale: 1 });
const apply = (target: number) => {
const rounded = Number(target.toFixed(3));
state.current.scale = Math.min(props.maxScale, Math.max(props.minScale, rounded));
props.onTransform?.(null, { scale: state.current.scale });
};
useImperativeHandle(ref, () => ({
instance: { state: state.current },
zoomIn: (step: number) => apply(state.current.scale + step),
zoomOut: (step: number) => apply(state.current.scale - step),
resetTransform: () => apply(1)
}));
return props.children;
}),
TransformComponent: (props: FakeComponentProps) => (
<div className={props.wrapperClass}>
<div className={props.contentClass}>{props.children}</div>
</div>
)
};
});
describe("mountZoomPan", () => {
afterEach(() => {
const container = document.querySelector<HTMLElement>(".mermaid-zoom");
if (container) {
act(() => render(null, container));
}
document.body.innerHTML = "";
transformWrapperSpy.mockClear();
});
it("moves the diagram into a labelled viewer in its place", () => {
const diagram = mountDiagram();
expect(document.querySelector("#content > .mermaid-zoom")).not.toBeNull();
const viewport = document.querySelector(".mermaid-zoom > .mermaid-zoom-viewport");
expect(viewport?.getAttribute("aria-label")).toBe("Diagram");
expect(viewport?.getAttribute("tabindex")).toBe("0");
expect(viewport?.querySelector(".mermaid-zoom-content")?.contains(diagram)).toBe(true);
expect(buttons().map((button) => button.title))
.toEqual([ "Zoom out", "Reset zoom", "Zoom in" ]);
expect(buttons().map((button) => button.getAttribute("aria-label")))
.toEqual([ "Zoom out", null, "Zoom in" ]);
expect(readout()).toBe("100%");
expect(transformWrapperSpy).toHaveBeenLastCalledWith(expect.objectContaining({
minScale: 0.5,
maxScale: 10,
wheel: { disabled: true },
doubleClick: { mode: "reset" }
}));
});
it("zooms in steps, pans only when zoomed in, and keeps the diagram it shows", () => {
const diagram = mountDiagram();
const [ zoomOut, reset, zoomIn ] = buttons();
expect(lastProps().panning).toEqual({ disabled: true });
act(() => zoomIn.click());
expect(readout()).toBe("120%");
expect(lastProps().panning).toEqual({ disabled: false });
expect(document.querySelector(".mermaid-zoom-content")?.contains(diagram)).toBe(true);
act(() => reset.click());
expect(readout()).toBe("100%");
for (const expected of [ "83%", "69%", "58%", "50%" ]) {
act(() => zoomOut.click());
expect(readout()).toBe(expected);
}
expect(zoomOut.disabled).toBe(true);
expect(zoomIn.disabled).toBe(false);
});
});
const LABELS = {
label: "Diagram",
zoomIn: "Zoom in",
zoomOut: "Zoom out",
zoomReset: "Reset zoom"
};
/** Mounts a viewer over a drawn diagram in `#content` and returns the diagram. */
function mountDiagram() {
document.body.innerHTML = `<div id="content"><div class="mermaid"><svg></svg></div></div>`;
const diagram = document.querySelector<HTMLElement>(".mermaid");
if (!diagram) {
throw new Error("The diagram is missing.");
}
act(() => mountZoomPan(diagram, LABELS));
return diagram;
}
function buttons() {
return [ ...document.querySelectorAll<HTMLButtonElement>(".mermaid-zoom-controls button") ];
}
function readout() {
return document.querySelector(".mermaid-zoom-reset")?.textContent;
}
function lastProps() {
return transformWrapperSpy.mock.lastCall?.[0] as { panning?: { disabled: boolean } };
}
@@ -0,0 +1,102 @@
import "./mermaid_zoom.css";
import {
useZoomPanPinch, useZoomPanWheel
} from "@triliumnext/client/src/widgets/react/zoom_pan.js";
import { render } from "preact";
import { useLayoutEffect, useRef, useState } from "preact/hooks";
import { TransformComponent, TransformWrapper } from "react-zoom-pan-pinch";
/** The texts of a viewer, which the page carries on the Mermaid note it shows. */
export interface ZoomPanLabels {
label: string;
zoomIn: string;
zoomOut: string;
zoomReset: string;
}
/**
* Moves `diagram` into a viewer that pans and zooms it, with the zoom steps and wheel handling of
* the app's diagram preview. The wheel zooms only while the viewer has focus, and dragging pans
* only once the diagram is zoomed in, so that both scroll the page otherwise.
*/
export default function mountZoomPan(diagram: HTMLElement, labels: ZoomPanLabels) {
const container = document.createElement("div");
container.className = "mermaid-zoom";
diagram.replaceWith(container);
render(<ZoomPanViewer diagram={diagram} labels={labels} />, container);
}
function ZoomPanViewer({ diagram, labels }: { diagram: HTMLElement; labels: ZoomPanLabels }) {
const zoom = useZoomPanPinch({ minScale: MIN_ZOOM, maxScale: MAX_ZOOM });
const [ viewport, setViewport ] = useState<HTMLDivElement | null>(null);
const contentRef = useRef<HTMLDivElement>(null);
useZoomPanWheel(zoom.ref, viewport, true);
// The diagram stays the element `mermaid.ts` draws into, so a theme change redraws it in place.
useLayoutEffect(() => {
contentRef.current?.append(diagram);
}, [ diagram ]);
return (
<>
<div
ref={setViewport}
className="mermaid-zoom-viewport"
tabIndex={0}
role="group"
aria-label={labels.label}
>
<TransformWrapper
ref={zoom.ref}
minScale={MIN_ZOOM}
maxScale={MAX_ZOOM}
centerZoomedOut
wheel={zoom.wheel}
panning={{ disabled: zoom.scale <= 1 }}
doubleClick={{ mode: "reset" }}
onTransform={zoom.onTransform}
>
<TransformComponent
wrapperClass="mermaid-zoom-wrapper"
contentClass="mermaid-zoom-content"
>
<div ref={contentRef} />
</TransformComponent>
</TransformWrapper>
</div>
<div className="mermaid-zoom-controls">
<button
type="button"
title={labels.zoomOut}
aria-label={labels.zoomOut}
disabled={!zoom.canZoomOut}
onClick={zoom.zoomOut}
>
<span className="tn-icon bx bx-minus-circle" aria-hidden="true" />
</button>
<button
type="button"
className="mermaid-zoom-reset"
title={labels.zoomReset}
onClick={zoom.reset}
>
{`${Math.round(zoom.scale * 100)}%`}
</button>
<button
type="button"
title={labels.zoomIn}
aria-label={labels.zoomIn}
disabled={!zoom.canZoomIn}
onClick={zoom.zoomIn}
>
<span className="tn-icon bx bx-plus-circle" aria-hidden="true" />
</button>
</div>
</>
);
}
/** The zoom bounds of the app's diagram preview, as a multiple of the diagram's fitted width. */
const MIN_ZOOM = 0.5;
const MAX_ZOOM = 10;
+10 -3
View File
@@ -11,7 +11,14 @@
"target": "ES2022",
"rootDir": "src",
"outDir": "out-tsc",
"module": "Node16"
"module": "Node16",
"jsx": "react-jsx",
"jsxImportSource": "preact"
},
"include": ["src/**/*"]
}
"include": ["src/**/*"],
"references": [
{
"path": "../../apps/client/tsconfig.app.json"
}
]
}
+6 -3
View File
@@ -11,8 +11,11 @@ export default defineConfig({
load: (id) => (id === "\0virtual:code-themes.css" ? "" : null)
}
],
oxc: {
jsx: { runtime: "automatic", importSource: "preact" }
},
test: {
include: [ "src/**/*.spec.ts" ],
include: [ "src/**/*.spec.{ts,tsx}" ],
environmentOptions: {
happyDOM: {
settings: { disableCSSFileLoading: true, handleDisabledFileLoadingAsSuccess: true }
@@ -31,8 +34,8 @@ export default defineConfig({
},
reportsDirectory: "./test-output/vitest/coverage",
provider: "v8",
include: [ "src/**/*.ts" ],
exclude: [ "src/**/*.spec.ts", "src/**/*.d.ts" ],
include: [ "src/**/*.{ts,tsx}" ],
exclude: [ "src/**/*.spec.{ts,tsx}", "src/**/*.d.ts" ],
// Codecov matches an lcov `SF:` path against the files of the whole repository, where a
// package-relative `src/index.ts` is ambiguous.
reporter: [ "text", "html", [ "lcov", { projectRoot: resolve(__dirname, "../..") } ] ]
@@ -330,6 +330,12 @@ describe("hasMermaidDiagrams", () => {
))).toBe(false);
});
it("finds a readable Mermaid note", () => {
expect(hasMermaidDiagrams(subtreeOf({ type: "mermaid", content: "graph TD;" }))).toBe(true);
const unreadable = subtreeOf({ type: "mermaid", available: false, content: "graph TD;" });
expect(hasMermaidDiagrams(unreadable)).toBe(false);
});
it("finds a fenced mermaid block only in a Markdown note", () => {
const markdown = { type: "code", mime: "text/x-markdown" };
@@ -192,9 +192,10 @@ export function getShareThemeExportFiles(manifest: ShareThemeManifest, note: BNo
}
/**
* Whether `note` or a note below it has a mermaid code block the shared page renders: a text note's
* `language-mermaid` block or a Markdown note's fenced one. Only then does the export carry the
* files of mermaid, several megabytes the pages load on demand.
* Whether `note` or a note below it has a diagram the shared page draws with mermaid: a Mermaid
* note, a text note's `language-mermaid` block or a Markdown note's fenced one. Only then does the
* export carry the files of mermaid and of the viewer a Mermaid note's diagram goes into, several
* megabytes the pages load on demand.
*/
export function hasMermaidDiagrams(note: BNote) {
return note.getSubtree().notes.some((subtreeNote) => {
@@ -202,6 +203,10 @@ export function hasMermaidDiagrams(note: BNote) {
return false;
}
if (subtreeNote.type === "mermaid") {
return true;
}
if (subtreeNote.type === "text") {
return String(subtreeNote.getContent()).includes("language-mermaid");
}
@@ -1022,6 +1022,9 @@ describe("content_renderer", () => {
expect(container?.querySelector("details pre.mermaid-note-source")?.textContent)
.toBe("graph TD; A-->B[<script>]");
expect(root.querySelector("script")).toBeNull();
expect([ "label", "zoom-in", "zoom-out", "zoom-reset" ]
.map((name) => container?.getAttribute(`data-${name}`)))
.toEqual([ "Diagram", "Zoom in", "Zoom out", "Reset zoom" ]);
});
});
@@ -851,15 +851,19 @@ export function renderCode(result: Result, mime?: string) {
/**
* Renders a Mermaid note as the image the app saved, which the share theme's script replaces with
* a diagram drawn from `.mermaid-note-source` in the page's light or dark theme.
* a diagram drawn from `.mermaid-note-source` in the page's light or dark theme, inside a viewer
* that pans and zooms it. The `data-*` attributes label that viewer.
*/
function renderMermaid(result: Result, note: SNote | BNote) {
if (typeof result.content !== "string") {
return;
}
const labels = Object.entries(MERMAID_VIEWER_LABELS)
.map(([ name, key ]) => ` data-${name}="${escapeHtml(t(key))}"`)
.join("");
result.content = `
<div class="mermaid-note">
<div class="mermaid-note"${labels}>
<img class="mermaid-note-image" src="api/images/${note.noteId}/${note.encodedTitle}?${note.utcDateModified}">
<hr>
<details>
@@ -869,6 +873,14 @@ function renderMermaid(result: Result, note: SNote | BNote) {
</div>`;
}
/** The `data-*` attributes of a Mermaid note that label its viewer, and their translations. */
const MERMAID_VIEWER_LABELS = {
"label": "share_theme.diagram",
"zoom-in": "share_theme.zoom-in",
"zoom-out": "share_theme.zoom-out",
"zoom-reset": "share_theme.zoom-reset"
};
function renderImage(result: Result, note: SNote | BNote) {
result.content = `<img src="api/images/${note.noteId}/${note.encodedTitle}?${note.utcDateModified}">`;
}
+3
View File
@@ -1370,6 +1370,9 @@ importers:
'@triliumnext/ckeditor5':
specifier: workspace:*
version: link:../ckeditor5
'@triliumnext/client':
specifier: workspace:*
version: link:../../apps/client
'@types/ejs':
specifier: 3.1.5
version: 3.1.5