Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion apps/desktop/package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down Expand Up @@ -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",
Expand Down
2 changes: 1 addition & 1 deletion apps/quicklook/package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
2 changes: 1 addition & 1 deletion apps/share-viewer/package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
2 changes: 1 addition & 1 deletion apps/web/package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
20 changes: 10 additions & 10 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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": {
Expand Down
2 changes: 1 addition & 1 deletion packages/app-core/package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
88 changes: 88 additions & 0 deletions packages/app-core/src/lib/mermaid-render.test.ts
Original file line number Diff line number Diff line change
@@ -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: `<svg id="${id}"></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)
})
})
63 changes: 62 additions & 1 deletion packages/app-core/src/lib/mermaid-render.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: <wrappingWidth>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<T>(render: () => Promise<T>): Promise<T> {
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 */
/* -------------------------------------------------------------------------- */
Expand Down Expand Up @@ -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) };
Expand Down
2 changes: 1 addition & 1 deletion packages/bridge-contract/package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
2 changes: 1 addition & 1 deletion packages/shared-domain/package.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "@zennotes/shared-domain",
"private": true,
"version": "2.63.0",
"version": "2.64.0",
"type": "module",
"exports": {
"./*": "./src/*.ts"
Expand Down
2 changes: 1 addition & 1 deletion packages/shared-ui/package.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "@zennotes/shared-ui",
"private": true,
"version": "2.63.0",
"version": "2.64.0",
"type": "module",
"exports": {
".": "./src/index.ts"
Expand Down
Loading