diff --git a/CHANGELOG.md b/CHANGELOG.md index b20c5f8a..fd75c74f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -32,6 +32,12 @@ refactors, dependency updates, CI changes, and code cleanup do not belong here. --- +## [1.2.7] - 2026-08-14 + +### Changed + +- Improved scrolling performance in Markdown files with many Mermaid diagrams by rendering diagrams only near the viewport, unloading offscreen SVG DOM while preserving layout, and restoring cached results without rerunning Mermaid. + ## [1.2.6] - 2026-08-14 ### Added diff --git a/Cargo.lock b/Cargo.lock index b6ec15e2..0bc26d4c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1845,7 +1845,7 @@ dependencies = [ [[package]] name = "neverwrite-native-backend" -version = "1.2.6" +version = "1.2.7" dependencies = [ "agent-client-protocol", "agent-client-protocol-legacy", diff --git a/apps/desktop/native-backend/Cargo.toml b/apps/desktop/native-backend/Cargo.toml index f2292628..68e7d283 100644 --- a/apps/desktop/native-backend/Cargo.toml +++ b/apps/desktop/native-backend/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "neverwrite-native-backend" -version = "1.2.6" +version = "1.2.7" edition = "2021" [dependencies] diff --git a/apps/desktop/package-lock.json b/apps/desktop/package-lock.json index 3dbb4bf9..3e15ece7 100644 --- a/apps/desktop/package-lock.json +++ b/apps/desktop/package-lock.json @@ -1,12 +1,12 @@ { "name": "bifrostwrite-desktop", - "version": "1.2.6", + "version": "1.2.7", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "bifrostwrite-desktop", - "version": "1.2.6", + "version": "1.2.7", "hasInstallScript": true, "dependencies": { "@codemirror/commands": "^6.10.3", diff --git a/apps/desktop/package.json b/apps/desktop/package.json index 2248d2c4..38d44612 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -1,7 +1,7 @@ { "name": "bifrostwrite-desktop", "private": true, - "version": "1.2.6", + "version": "1.2.7", "homepage": "https://github.com/bifrost-proxy/BifrostWrite", "type": "module", "engines": { diff --git a/apps/desktop/src-tauri/Cargo.lock b/apps/desktop/src-tauri/Cargo.lock index 7284847b..77e2cf1c 100644 --- a/apps/desktop/src-tauri/Cargo.lock +++ b/apps/desktop/src-tauri/Cargo.lock @@ -250,7 +250,7 @@ checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" [[package]] name = "bifrostwrite-desktop" -version = "1.2.6" +version = "1.2.7" dependencies = [ "arboard", "base64 0.22.1", diff --git a/apps/desktop/src-tauri/Cargo.toml b/apps/desktop/src-tauri/Cargo.toml index 84ee1915..20f59f6e 100644 --- a/apps/desktop/src-tauri/Cargo.toml +++ b/apps/desktop/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "bifrostwrite-desktop" -version = "1.2.6" +version = "1.2.7" edition = "2021" description = "Tauri desktop shell for BifrostWrite" license = "Apache-2.0" diff --git a/apps/desktop/src-tauri/tauri.conf.json b/apps/desktop/src-tauri/tauri.conf.json index 93125095..53d8d734 100644 --- a/apps/desktop/src-tauri/tauri.conf.json +++ b/apps/desktop/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://schema.tauri.app/config/2", "productName": "BifrostWrite", - "version": "1.2.6", + "version": "1.2.7", "identifier": "com.bifrostwrite.desktop", "build": { "beforeDevCommand": "npm run renderer:dev", diff --git a/apps/desktop/src/features/editor/extensions/livePreviewBlocks.test.ts b/apps/desktop/src/features/editor/extensions/livePreviewBlocks.test.ts index 30ce9857..be2cc67a 100644 --- a/apps/desktop/src/features/editor/extensions/livePreviewBlocks.test.ts +++ b/apps/desktop/src/features/editor/extensions/livePreviewBlocks.test.ts @@ -5,7 +5,7 @@ import { markdown, markdownLanguage } from "@codemirror/lang-markdown"; import { forceParsing } from "@codemirror/language"; import { EditorSelection, EditorState } from "@codemirror/state"; import { EditorView } from "@codemirror/view"; -import { beforeEach, describe, expect, it, vi } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { getFencedCodeBlockKind, resolvePreviewAssetPath, @@ -53,6 +53,10 @@ beforeEach(() => { mockedRenderMermaidDiagram.mockReset(); }); +afterEach(() => { + vi.unstubAllGlobals(); +}); + describe("resolvePreviewAssetPath", () => { it("resolves note-relative assets against the current note path", () => { expect( @@ -233,6 +237,108 @@ describe("code block live preview", () => { parent.remove(); }); + it("defers offscreen Mermaid rendering and restores cached SVG", async () => { + const observers: FakeIntersectionObserver[] = []; + + class FakeIntersectionObserver { + private callback: IntersectionObserverCallback; + disconnected = false; + observed: Element | null = null; + + constructor(callback: IntersectionObserverCallback) { + this.callback = callback; + observers.push(this); + } + + observe(target: Element) { + this.observed = target; + } + + disconnect() { + this.disconnected = true; + } + + trigger(target: Element, isIntersecting: boolean) { + this.callback( + [ + { + target, + isIntersecting, + } as IntersectionObserverEntry, + ], + this as unknown as IntersectionObserver, + ); + } + } + + vi.stubGlobal("IntersectionObserver", FakeIntersectionObserver); + vi.stubGlobal( + "requestIdleCallback", + (callback: IdleRequestCallback) => { + callback({ + didTimeout: false, + timeRemaining: () => 50, + }); + return 1; + }, + ); + vi.stubGlobal("cancelIdleCallback", vi.fn()); + mockedRenderMermaidDiagram.mockResolvedValueOnce({ + status: "ok", + svg: 'Lazy', + }); + + const parent = document.createElement("div"); + document.body.appendChild(parent); + const doc = ["```mermaid", "flowchart TD", " A --> B", "```"].join( + "\n", + ); + const view = new EditorView({ + state: createLivePreviewState(doc), + parent, + }); + + await flushPromises(); + + const preview = view.dom.querySelector( + ".cm-mermaid-preview", + ); + const body = preview?.querySelector( + ".cm-mermaid-preview-body", + ); + expect(preview).not.toBeNull(); + expect(body).not.toBeNull(); + const observer = observers.find( + (item) => !item.disconnected && item.observed === preview, + ); + expect(observer).toBeDefined(); + expect(mockedRenderMermaidDiagram).not.toHaveBeenCalled(); + + observer!.trigger(preview!, true); + await flushPromises(); + + expect(body?.querySelector("svg text")?.textContent).toBe("Lazy"); + expect(mockedRenderMermaidDiagram).toHaveBeenCalledTimes(1); + + vi.spyOn(body!, "getBoundingClientRect").mockReturnValue({ + ...body!.getBoundingClientRect(), + height: 240, + }); + observer!.trigger(preview!, false); + + expect(body?.querySelector("svg")).toBeNull(); + expect(body?.style.minHeight).toBe("240px"); + + observer!.trigger(preview!, true); + + expect(body?.querySelector("svg text")?.textContent).toBe("Lazy"); + expect(mockedRenderMermaidDiagram).toHaveBeenCalledTimes(1); + + view.destroy(); + expect(observer!.disconnected).toBe(true); + parent.remove(); + }); + it("renders multiple Mermaid diagrams independently", async () => { mockedRenderMermaidDiagram .mockResolvedValueOnce({ diff --git a/apps/desktop/src/features/editor/extensions/livePreviewBlocks.ts b/apps/desktop/src/features/editor/extensions/livePreviewBlocks.ts index 75167739..10a2810f 100644 --- a/apps/desktop/src/features/editor/extensions/livePreviewBlocks.ts +++ b/apps/desktop/src/features/editor/extensions/livePreviewBlocks.ts @@ -53,7 +53,10 @@ import { getNotePreviewContentState, renderEmbedPreview, } from "./notePreviewSource"; -import { renderMermaidDiagram } from "../mermaid/mermaidRenderer"; +import { + renderMermaidDiagram, + type MermaidRenderResult, +} from "../mermaid/mermaidRenderer"; import { createMermaidFullscreenButton } from "../mermaid/mermaidFullscreen"; import { parseMermaidSvg } from "../mermaid/mermaidSvg"; import { formatCodeFenceLanguageLabel } from "../codeFencePresentation"; @@ -982,9 +985,17 @@ function setCodeBlockCopyButtonState( button.replaceChildren(createCodeBlockCopyIcon(copied)); } +const mermaidWidgetOwners = new WeakMap(); + class MermaidDiagramWidget extends WidgetType { private source: string; private diagramId: string; + private observer: IntersectionObserver | null = null; + private idleHandle: number | null = null; + private renderResult: MermaidRenderResult | null = null; + private renderPromise: Promise | null = null; + private isNearViewport = false; + private destroyed = false; constructor(source: string, diagramId: string) { super(); @@ -1009,44 +1020,143 @@ class MermaidDiagramWidget extends WidgetType { body.className = "cm-mermaid-preview-body"; body.textContent = translate("Rendering Mermaid diagram..."); outer.appendChild(body); + mermaidWidgetOwners.set(outer, this); - const expectedId = this.diagramId; - const expectedSource = this.source; + if (typeof IntersectionObserver === "undefined") { + this.isNearViewport = true; + this.startRender(outer, body); + } else { + queueMicrotask(() => this.observeVisibility(outer, body)); + } - void renderMermaidDiagram(this.source, this.diagramId).then( - (result) => { - if ( - !outer.isConnected || - outer.dataset.mermaidId !== expectedId || - outer.dataset.mermaidSource !== expectedSource - ) { - return; - } + return outer; + } - body.replaceChildren(); - if (result.status === "ok") { - body.className = - "cm-mermaid-preview-body cm-mermaid-preview-body-rendered"; - const svg = parseMermaidSvg(result.svg); - if (svg) { - body.appendChild(svg); - body.appendChild( - createMermaidFullscreenButton(result.svg), - ); - } else { - renderMermaidError( - body, - "Unable to read Mermaid SVG output.", - ); - } - return; - } + private observeVisibility(outer: HTMLElement, body: HTMLElement) { + if (this.destroyed || !outer.isConnected) return; - renderMermaidError(body, result.message); + const root = outer.closest(".cm-scroller"); + this.observer = new IntersectionObserver( + (entries) => { + const entry = entries.find((item) => item.target === outer); + if (!entry) return; + + this.isNearViewport = entry.isIntersecting; + if (entry.isIntersecting) { + this.activate(outer, body); + } else { + this.deactivate(body); + } + }, + { + root, + rootMargin: "400px 0px", }, ); + this.observer.observe(outer); + } - return outer; + private activate(outer: HTMLElement, body: HTMLElement) { + if (this.renderResult) { + this.mountResult(body, this.renderResult); + return; + } + if (this.renderPromise || this.idleHandle !== null) return; + + this.idleHandle = requestMermaidIdleWork(() => { + this.idleHandle = null; + if ( + this.destroyed || + !this.isNearViewport || + !outer.isConnected + ) { + return; + } + this.startRender(outer, body); + }); + } + + private deactivate(body: HTMLElement) { + if (this.idleHandle !== null) { + cancelMermaidIdleWork(this.idleHandle); + this.idleHandle = null; + } + if (this.renderResult) { + this.showDeferredPlaceholder(body); + } + } + + private startRender(outer: HTMLElement, body: HTMLElement) { + if (this.renderPromise || this.renderResult || this.destroyed) return; + + const expectedId = this.diagramId; + const expectedSource = this.source; + this.renderPromise = renderMermaidDiagram(this.source, this.diagramId); + void this.renderPromise.then((result) => { + this.renderPromise = null; + if ( + this.destroyed || + !outer.isConnected || + outer.dataset.mermaidId !== expectedId || + outer.dataset.mermaidSource !== expectedSource + ) { + return; + } + + this.renderResult = result; + if (this.isNearViewport) { + this.mountResult(body, result); + } else { + this.showDeferredPlaceholder(body); + } + }); + } + + private mountResult(body: HTMLElement, result: MermaidRenderResult) { + body.replaceChildren(); + body.style.removeProperty("min-height"); + + if (result.status === "ok") { + body.className = + "cm-mermaid-preview-body cm-mermaid-preview-body-rendered"; + const svg = parseMermaidSvg(result.svg); + if (svg) { + body.appendChild(svg); + body.appendChild(createMermaidFullscreenButton(result.svg)); + return; + } + renderMermaidError(body, "Unable to read Mermaid SVG output."); + return; + } + + renderMermaidError(body, result.message); + } + + private showDeferredPlaceholder(body: HTMLElement) { + const renderedHeight = Math.ceil(body.getBoundingClientRect().height); + body.className = "cm-mermaid-preview-body"; + body.replaceChildren(); + body.textContent = translate("Rendering Mermaid diagram..."); + if (renderedHeight > 0) { + body.style.minHeight = `${renderedHeight}px`; + } + } + + private dispose() { + this.destroyed = true; + this.observer?.disconnect(); + this.observer = null; + if (this.idleHandle !== null) { + cancelMermaidIdleWork(this.idleHandle); + this.idleHandle = null; + } + } + + destroy(dom: HTMLElement) { + // CodeMirror may reuse an equal widget's DOM with a newer widget + // instance. Always dispose the instance that originally owns it. + mermaidWidgetOwners.get(dom)?.dispose(); + mermaidWidgetOwners.delete(dom); } ignoreEvent() { @@ -1054,6 +1164,21 @@ class MermaidDiagramWidget extends WidgetType { } } +function requestMermaidIdleWork(callback: () => void) { + if ("requestIdleCallback" in globalThis) { + return globalThis.requestIdleCallback(callback, { timeout: 200 }); + } + return window.setTimeout(callback, 16); +} + +function cancelMermaidIdleWork(handle: number) { + if ("cancelIdleCallback" in globalThis) { + globalThis.cancelIdleCallback(handle); + return; + } + window.clearTimeout(handle); +} + const codeBlockFenceHidden = Decoration.line({ class: "cm-code-block-fence-hidden", }); diff --git a/apps/desktop/src/features/editor/extensions/livePreviewTheme.ts b/apps/desktop/src/features/editor/extensions/livePreviewTheme.ts index a67dd25f..b32b43be 100644 --- a/apps/desktop/src/features/editor/extensions/livePreviewTheme.ts +++ b/apps/desktop/src/features/editor/extensions/livePreviewTheme.ts @@ -1057,6 +1057,8 @@ export const livePreviewTheme = EditorView.baseTheme({ margin: "12px 0", maxWidth: "100%", overflowX: "auto", + contentVisibility: "auto", + containIntrinsicSize: "auto 160px", borderTop: "1px solid var(--border)", borderBottom: "1px solid var(--border)", background: