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"