diff --git a/apps/client/src/services/content_renderer_text.ts b/apps/client/src/services/content_renderer_text.ts index 9317d0b6e0..8508cd4756 100644 --- a/apps/client/src/services/content_renderer_text.ts +++ b/apps/client/src/services/content_renderer_text.ts @@ -101,13 +101,55 @@ export async function rewriteMermaidDiagramsInContainer(container: HTMLDivElemen } } +/** + * Per-container cache of rendered mermaid SVG keyed by diagram source text. + * Populated after each successful render; reused on subsequent renders to + * avoid flicker when the preview HTML is regenerated (e.g. live markdown + * editing). Entries for diagrams no longer present in the container are + * evicted on each run so the cache can't grow unbounded. + */ +const mermaidSvgCache = new WeakMap>(); + export async function applyInlineMermaid(container: HTMLDivElement) { - // Initialize mermaid + const nodes = Array.from(container.querySelectorAll("div.mermaid-diagram")); + if (!nodes.length) return; + + let cache = mermaidSvgCache.get(container); + if (!cache) { + cache = new Map(); + mermaidSvgCache.set(container, cache); + } + + // Paint cached SVGs upfront so unchanged diagrams don't flicker, and collect + // only the new/changed diagrams for an actual mermaid render pass. + const pendingSources = new Map(); + const seen = new Set(); + for (const node of nodes) { + const source = (node.textContent ?? "").trim(); + seen.add(source); + const cached = cache.get(source); + if (cached) { + node.innerHTML = cached; + node.setAttribute("data-processed", "true"); + } else { + pendingSources.set(node, source); + } + } + + // Evict entries whose source is no longer present. + for (const key of [ ...cache.keys() ]) { + if (!seen.has(key)) cache.delete(key); + } + + if (!pendingSources.size) return; + const mermaid = (await import("mermaid")).default; mermaid.initialize(getMermaidConfig()); - const nodes = Array.from(container.querySelectorAll("div.mermaid-diagram")); try { - await mermaid.run({ nodes }); + await mermaid.run({ nodes: [ ...pendingSources.keys() ] }); + for (const [ node, source ] of pendingSources) { + cache.set(source, node.innerHTML); + } } catch (e) { console.log(e); } diff --git a/apps/client/src/widgets/type_widgets/text/ReadOnlyText.tsx b/apps/client/src/widgets/type_widgets/text/ReadOnlyText.tsx index e10a90cb80..49249e1753 100644 --- a/apps/client/src/widgets/type_widgets/text/ReadOnlyText.tsx +++ b/apps/client/src/widgets/type_widgets/text/ReadOnlyText.tsx @@ -6,7 +6,7 @@ import "@triliumnext/ckeditor5"; import clsx from "clsx"; import { RefObject } from "preact"; -import { useEffect, useMemo } from "preact/hooks"; +import { useEffect, useLayoutEffect, useMemo } from "preact/hooks"; import appContext from "../../../components/app_context"; import FNote from "../../../entities/fnote"; @@ -76,8 +76,11 @@ export function ReadOnlyTextContent({ html, ntxId, dir, className, contentRef: e document.body.style.setProperty("--code-block-tab-width", codeBlockTabWidth || "4"); }, [codeBlockTabWidth]); - // Apply necessary transforms. - useEffect(() => { + // Apply necessary transforms. Runs in a layout effect so the synchronous + // DOM mutations (mermaid rewrite + cached-SVG repaint, math, etc.) happen + // before the browser paints — prevents a flash of raw `
` content
+    // during live preview re-renders.
+    useLayoutEffect(() => {
         const container = contentRef.current;
         if (!container) return;