diff --git a/apps/desktop/package.json b/apps/desktop/package.json
index c5dbcb08..1e1e3d71 100644
--- a/apps/desktop/package.json
+++ b/apps/desktop/package.json
@@ -1,7 +1,7 @@
{
"name": "@zennotes/desktop",
"productName": "ZenNotes",
- "version": "2.63.0",
+ "version": "2.64.0",
"description": "ZenNotes desktop shell",
"private": true,
"main": "./out/main/index.js",
@@ -211,6 +211,7 @@
"icon": "build/icon.icns",
"category": "public.app-category.productivity",
"hardenedRuntime": true,
+ "notarize": false,
"gatekeeperAssess": false,
"entitlements": "build/entitlements.mac.plist",
"entitlementsInherit": "build/entitlements.mac.inherit.plist",
diff --git a/apps/quicklook/package.json b/apps/quicklook/package.json
index 308a8902..187d49c3 100644
--- a/apps/quicklook/package.json
+++ b/apps/quicklook/package.json
@@ -1,7 +1,7 @@
{
"name": "@zennotes/quicklook",
"private": true,
- "version": "2.63.0",
+ "version": "2.64.0",
"type": "module",
"description": "The macOS Quick Look preview for Markdown files: the page it renders and the Swift extension that serves it",
"homepage": "https://zennotes.org",
diff --git a/apps/share-viewer/package.json b/apps/share-viewer/package.json
index 93e46c3f..3f7f2655 100644
--- a/apps/share-viewer/package.json
+++ b/apps/share-viewer/package.json
@@ -1,7 +1,7 @@
{
"name": "@zennotes/share-viewer",
"private": true,
- "version": "2.63.0",
+ "version": "2.64.0",
"type": "module",
"description": "Read-only renderer for publicly shared ZenNotes, embedded by the zennotes.org website",
"homepage": "https://zennotes.org",
diff --git a/apps/web/package.json b/apps/web/package.json
index 2969b4ef..0209d115 100644
--- a/apps/web/package.json
+++ b/apps/web/package.json
@@ -1,7 +1,7 @@
{
"name": "@zennotes/web",
"private": true,
- "version": "2.63.0",
+ "version": "2.64.0",
"type": "module",
"description": "ZenNotes web client for self-hosted and hosted deployments",
"homepage": "https://zennotes.org",
diff --git a/package-lock.json b/package-lock.json
index 0d7f6f57..daaadcf1 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "zennotes-monorepo",
- "version": "2.63.0",
+ "version": "2.64.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "zennotes-monorepo",
- "version": "2.63.0",
+ "version": "2.64.0",
"hasInstallScript": true,
"workspaces": [
"apps/*",
@@ -23,7 +23,7 @@
},
"apps/desktop": {
"name": "@zennotes/desktop",
- "version": "2.63.0",
+ "version": "2.64.0",
"license": "MIT",
"dependencies": {
"@codemirror/autocomplete": "^6.18.3",
@@ -914,7 +914,7 @@
},
"apps/quicklook": {
"name": "@zennotes/quicklook",
- "version": "2.63.0",
+ "version": "2.64.0",
"dependencies": {
"@codemirror/autocomplete": "^6.18.3",
"@codemirror/commands": "^6.7.1",
@@ -987,7 +987,7 @@
},
"apps/share-viewer": {
"name": "@zennotes/share-viewer",
- "version": "2.63.0",
+ "version": "2.64.0",
"dependencies": {
"@codemirror/autocomplete": "^6.18.3",
"@codemirror/commands": "^6.7.1",
@@ -1058,7 +1058,7 @@
},
"apps/web": {
"name": "@zennotes/web",
- "version": "2.63.0",
+ "version": "2.64.0",
"dependencies": {
"@codemirror/autocomplete": "^6.18.3",
"@codemirror/commands": "^6.7.1",
@@ -16360,7 +16360,7 @@
},
"packages/app-core": {
"name": "@zennotes/app-core",
- "version": "2.63.0",
+ "version": "2.64.0",
"dependencies": {
"@codemirror/autocomplete": "^6.18.3",
"@codemirror/commands": "^6.7.1",
@@ -16447,14 +16447,14 @@
},
"packages/bridge-contract": {
"name": "@zennotes/bridge-contract",
- "version": "2.63.0",
+ "version": "2.64.0",
"devDependencies": {
"typescript": "^5.7.2"
}
},
"packages/shared-domain": {
"name": "@zennotes/shared-domain",
- "version": "2.63.0",
+ "version": "2.64.0",
"dependencies": {
"@zennotes/bridge-contract": "*",
"lz-string": "^1.5.0"
@@ -16466,7 +16466,7 @@
},
"packages/shared-ui": {
"name": "@zennotes/shared-ui",
- "version": "2.63.0"
+ "version": "2.64.0"
}
}
}
diff --git a/package.json b/package.json
index 5a9615b8..0d271b0b 100644
--- a/package.json
+++ b/package.json
@@ -1,7 +1,7 @@
{
"name": "zennotes-monorepo",
"private": true,
- "version": "2.63.0",
+ "version": "2.64.0",
"description": "ZenNotes monorepo for desktop, web, and self-hosted server builds",
"packageManager": "npm@10.9.2",
"engines": {
diff --git a/packages/app-core/package.json b/packages/app-core/package.json
index 56758897..57273aff 100644
--- a/packages/app-core/package.json
+++ b/packages/app-core/package.json
@@ -1,7 +1,7 @@
{
"name": "@zennotes/app-core",
"private": true,
- "version": "2.63.0",
+ "version": "2.64.0",
"type": "module",
"exports": {
"./main": "./src/main.tsx",
diff --git a/packages/app-core/src/lib/mermaid-render.test.ts b/packages/app-core/src/lib/mermaid-render.test.ts
new file mode 100644
index 00000000..1ec60c56
--- /dev/null
+++ b/packages/app-core/src/lib/mermaid-render.test.ts
@@ -0,0 +1,88 @@
+// @vitest-environment jsdom
+
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+import { renderMermaidSvg } from './mermaid-render'
+
+// What the stand-in mermaid measured: a capped label inside a diagram, and the
+// same capped box outside one.
+const measured = vi.hoisted(() => ({ label: [] as number[], outside: [] as number[] }))
+
+// Stands in for mermaid's addHtmlSpan: lay a label out under an inline
+// max-width inside a foreignObject and read its width the way mermaid does,
+// after an await like its fastdom read.
+vi.mock('mermaid', () => ({
+ default: {
+ initialize: vi.fn(),
+ render: vi.fn(async (id: string) => {
+ const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg')
+ const foreignObject = document.createElementNS('http://www.w3.org/2000/svg', 'foreignObject')
+ const label = document.createElement('div')
+ label.style.maxWidth = '200px'
+ const outside = document.createElement('div')
+ outside.style.maxWidth = '200px'
+ foreignObject.append(label)
+ svg.append(foreignObject)
+ document.body.append(svg, outside)
+ await Promise.resolve()
+ measured.label.push(label.getBoundingClientRect().width)
+ measured.outside.push(outside.getBoundingClientRect().width)
+ svg.remove()
+ outside.remove()
+ return { svg: `` }
+ })
+ }
+}))
+
+const unpatched = Element.prototype.getBoundingClientRect
+let reportedWidth = 0
+function layoutReports(width: number): void {
+ reportedWidth = width
+}
+const measuring = function (): DOMRect {
+ return new DOMRect(0, 0, reportedWidth, 21)
+}
+
+describe('renderMermaidSvg label measurement (#911)', () => {
+ beforeEach(() => {
+ measured.label.length = 0
+ measured.outside.length = 0
+ Element.prototype.getBoundingClientRect = measuring
+ })
+
+ afterEach(() => {
+ Element.prototype.getBoundingClientRect = unpatched
+ })
+
+ // Both widths are what Chromium reported for a label held at its 200px cap.
+ it.each([
+ ['120% app zoom on a 2x display', 200.00001525878906],
+ ['a 1.333 display scale', 199.9953155517578]
+ ])('reads a label held at its cap as exactly the cap at %s', async (scale, width) => {
+ layoutReports(width)
+ await renderMermaidSvg(`flowchart TB\n a["at ${scale}"]`, 'light')
+ expect(measured.label).toEqual([200])
+ })
+
+ it('leaves a label well under its cap alone', async () => {
+ layoutReports(133.33333333333334)
+ await renderMermaidSvg('flowchart TB\n a["short"]', 'light')
+ expect(measured.label).toEqual([133.33333333333334])
+ })
+
+ it('touches nothing outside a diagram label, and restores the browser measurement after', async () => {
+ layoutReports(199.9953155517578)
+ await renderMermaidSvg('flowchart TB\n a["outside"]', 'light')
+ expect(measured.outside).toEqual([199.9953155517578])
+ expect(Element.prototype.getBoundingClientRect).toBe(measuring)
+ })
+
+ it('keeps reporting the cap until the last of two overlapping renders finishes', async () => {
+ layoutReports(199.9953155517578)
+ await Promise.all([
+ renderMermaidSvg('flowchart TB\n a["first"]', 'light'),
+ renderMermaidSvg('flowchart TB\n a["second"]', 'dark')
+ ])
+ expect(measured.label).toEqual([200, 200])
+ expect(Element.prototype.getBoundingClientRect).toBe(measuring)
+ })
+})
diff --git a/packages/app-core/src/lib/mermaid-render.ts b/packages/app-core/src/lib/mermaid-render.ts
index ce36941f..b2d725a8 100644
--- a/packages/app-core/src/lib/mermaid-render.ts
+++ b/packages/app-core/src/lib/mermaid-render.ts
@@ -225,6 +225,67 @@ export function buildMermaidTheme(mode: "light" | "dark"): MermaidThemeConfig {
};
}
+/* -------------------------------------------------------------------------- */
+/* Label measurement at fractional scales */
+/* -------------------------------------------------------------------------- */
+
+/**
+ * Mermaid decides whether an HTML label wraps by laying it out on one line
+ * under an inline `max-width: px` and asking whether it came
+ * back exactly that wide (`bbox.width === width` in its addHtmlSpan,
+ * unchanged through 12.1). At a fractional device scale the browser lays the
+ * cap out in device pixels and hands back a width just off it: 200.0000152px
+ * at 120% app zoom on a 2x display, 199.9953px at a 1.333 display scale. The
+ * comparison fails, the label never wraps, its node is sized to the cap, and
+ * every long label is cut off at 200px (#911).
+ *
+ * While mermaid lays a diagram out, a label measured within half a device
+ * pixel of its own cap is reported at the cap. Laying the cap out in device
+ * pixels moves it by far less than that. A label that really is that much
+ * narrower than its cap still fits on one line once mermaid switches it to
+ * wrapping, so nothing visible changes for it.
+ */
+function reportedLabelWidth(element: Element, width: number): number {
+ if (!(element instanceof HTMLElement) || !element.style.maxWidth.endsWith("px")) return width;
+ if (!insideDiagramLabel(element)) return width;
+ const cap = Number.parseFloat(element.style.maxWidth);
+ return Math.abs(width - cap) <= 0.5 / (window.devicePixelRatio || 1) ? cap : width;
+}
+
+/** By local name rather than `closest("foreignObject")`: a selector's case
+ * rules for SVG names differ between engines. */
+function insideDiagramLabel(element: Element): boolean {
+ for (let node = element.parentElement; node; node = node.parentElement) {
+ if (node.localName === "foreignObject") return true;
+ }
+ return false;
+}
+
+/** Renders overlap (the live preview and Preview both draw), so the patch is
+ * installed by the first and removed by the last. */
+let capAwareRenders = 0;
+let browserGetBoundingClientRect: Element["getBoundingClientRect"] | null = null;
+
+async function withCapAwareLabelMeasurement(render: () => Promise): Promise {
+ if (capAwareRenders++ === 0) {
+ const original = Element.prototype.getBoundingClientRect;
+ browserGetBoundingClientRect = original;
+ Element.prototype.getBoundingClientRect = function (this: Element): DOMRect {
+ const rect = original.call(this);
+ const width = reportedLabelWidth(this, rect.width);
+ return width === rect.width ? rect : new DOMRect(rect.x, rect.y, width, rect.height);
+ };
+ }
+ try {
+ return await render();
+ } finally {
+ if (--capAwareRenders === 0 && browserGetBoundingClientRect) {
+ Element.prototype.getBoundingClientRect = browserGetBoundingClientRect;
+ browserGetBoundingClientRect = null;
+ }
+ }
+}
+
/* -------------------------------------------------------------------------- */
/* Rendered SVG, cached */
/* -------------------------------------------------------------------------- */
@@ -282,7 +343,7 @@ export function renderMermaidSvg(
// The id must be unique per render: mermaid puts it in the DOM and a
// repeat collides with the diagram already on screen.
const id = `${idPrefix}-${renderSeq++}`;
- const { svg } = await mermaid.render(id, source);
+ const { svg } = await withCapAwareLabelMeasurement(() => mermaid.render(id, source));
return { ok: true, svg };
} catch (error) {
return { ok: false, error: error instanceof Error ? error.message : String(error) };
diff --git a/packages/bridge-contract/package.json b/packages/bridge-contract/package.json
index b8bf6c3f..72374d39 100644
--- a/packages/bridge-contract/package.json
+++ b/packages/bridge-contract/package.json
@@ -1,7 +1,7 @@
{
"name": "@zennotes/bridge-contract",
"private": true,
- "version": "2.63.0",
+ "version": "2.64.0",
"type": "module",
"exports": {
"./bridge": "./src/bridge.ts",
diff --git a/packages/shared-domain/package.json b/packages/shared-domain/package.json
index e4878c20..2d8a0c5f 100644
--- a/packages/shared-domain/package.json
+++ b/packages/shared-domain/package.json
@@ -1,7 +1,7 @@
{
"name": "@zennotes/shared-domain",
"private": true,
- "version": "2.63.0",
+ "version": "2.64.0",
"type": "module",
"exports": {
"./*": "./src/*.ts"
diff --git a/packages/shared-ui/package.json b/packages/shared-ui/package.json
index d57c4b98..01250207 100644
--- a/packages/shared-ui/package.json
+++ b/packages/shared-ui/package.json
@@ -1,7 +1,7 @@
{
"name": "@zennotes/shared-ui",
"private": true,
- "version": "2.63.0",
+ "version": "2.64.0",
"type": "module",
"exports": {
".": "./src/index.ts"