diff --git a/.changeset/pink-nights-shake.md b/.changeset/pink-nights-shake.md new file mode 100644 index 0000000..78c5078 --- /dev/null +++ b/.changeset/pink-nights-shake.md @@ -0,0 +1,9 @@ +--- +'@visimer/dom': patch +--- + +Re-render the canvas once the document's webfonts have loaded, so labels are no longer clipped on a first visit. + +Mermaid measures label text against the fonts the document can use at the moment it renders, then bakes those measurements into fixed-width `foreignObject` boxes. On a cold load — a first-time visitor with an empty cache, where `font-display: swap` deliberately paints fallback text first — the webfont arrives after that measurement, and the real text is wider than the box that was sized for the fallback. Every label ends up clipped a few pixels short: "Tests green?" loses its "?", "Ship it" renders as "Ship i". Nothing in mermaid or in the browser re-measures, so the diagram stays wrong for the whole session; reloading fixes it only because the font is then cached, which is why it is invisible in normal development. + +`MermaidCanvasView` now watches `document.fonts` after each render. If the document's fonts are still loading it waits for them to settle and then re-renders, which re-measures every label against the fonts the browser is actually painting with. It arms only while fonts are pending, so a page whose fonts are already available renders exactly once, as before. Environments with no `FontFaceSet` are unaffected. diff --git a/TESTING.md b/TESTING.md index 2e7a995..037fd68 100644 --- a/TESTING.md +++ b/TESTING.md @@ -13,6 +13,7 @@ test exercises the seam. | `bindTextPane` editor contract | The adapter contract any code editor integration implements | `packages/core/test/textpane.test.ts` drives the binding through an in-memory pane that implements exactly the shipped adapter interface: both sync directions, caret selection, reveal, drift resync, dispose | B | | CodeMirror binding | `@visimer/codemirror` against a real CodeMirror 6 `EditorView` | `packages/codemirror/test/binding.test.ts` (jsdom): engine ops → view, view edits → engine, decorations in the DOM, caret → entity selection, engine-authoritative undo, teardown | A | | Monaco binding | `@visimer/monaco` against the structural editor interface it binds | `packages/monaco/test/binding.test.ts`: fake implementing exactly the bound surface (both sync directions, decorations, caret reasons, undo keys, dispose), plus a compile-time conformance check that real `monaco-editor` types satisfy the interface | B | +| Canvas render loop (what makes `@visimer/dom` re-render) | The triggers and the guards on them: source changes, config changes, and the webfont-settled re-measure that keeps labels from being clipped on a cold load | `packages/dom/test/fonts.test.ts` drives a real `MermaidCanvasView` over a fake mermaid and a stubbed `FontFaceSet`: re-renders once when fonts land late, never when they were already there, never after `destroy()`, never where the document exposes no font set | B | | SVG correlation (dom package ↔ Mermaid's rendered DOM) | Third-party dependency seam: correlators key off Mermaid's internal SVG structure, which can shift between Mermaid releases | None automated. Verified manually in the playground across all 23 diagram types | uncovered | | Canvas interaction layer (popovers, drag, in-place editing) | `@visimer/dom` gestures compiled to engine ops | None automated. Verified manually in the playground | uncovered | | React bindings | `@visimer/react` hooks/components over core events | None automated. Thin subscription layer; exercised manually via the playground | uncovered | @@ -29,6 +30,11 @@ What the current suite cannot catch: provide it because Mermaid layout requires real text measurement. - **Pointer-gesture regressions** (drag-to-connect thresholds, double-click vs drag arbitration, popover anchoring). Same real-browser rung. +- **Anything that depends on real text metrics.** The font-settled re-measure + is pinned at the contract level (does the view re-render, and only when it + should), not at the pixel level: jsdom cannot tell us whether a label + actually fits its box. Catching a *wrongly sized* label, rather than a + missing re-render, needs the same real-browser rung. - **React render-loop regressions** (stale subscriptions, effect ordering). Would need @testing-library/react coverage. diff --git a/packages/dom/src/view.ts b/packages/dom/src/view.ts index 3b096a4..50ec623 100644 --- a/packages/dom/src/view.ts +++ b/packages/dom/src/view.ts @@ -405,6 +405,9 @@ export class MermaidCanvasView { private zoomControls: HTMLElement | null = null /** per-instance staleness counter; a shared one would drop renders across instances */ private renderSeq = 0 + /** a font-settled re-measure is already armed; don't stack a second one */ + private awaitingFonts = false + private destroyed = false constructor(options: ViewOptions) { this.editor = options.editor @@ -825,6 +828,7 @@ export class MermaidCanvasView { } this.editor.setDiagnostics([]) this.emit('render', { ok: true }) + this.remeasureWhenFontsSettle() if (this.activeInPlaceSession()) { this.resumeInPlaceSession(liveEdit) } else if (this.pendingEditEntity) { @@ -847,6 +851,43 @@ export class MermaidCanvasView { } } + /** + * Mermaid measures label text against the fonts the document can use at the + * moment it renders, then bakes those measurements into fixed-width + * `foreignObject` boxes. A webfont that lands afterwards — the normal case + * on a first visit, where `font-display: swap` deliberately paints fallback + * text first — is wider than the box measured for the fallback, so every + * label ends up clipped a few pixels short for the rest of the session. + * Nothing in mermaid or in the browser re-measures on its own. + * + * So once the document's fonts have settled, render again. Asking *after* + * the render matters: mermaid's own measuring pass is usually what first + * requests the webfont, so the pending load is only visible by then. + * + * This cannot loop. It arms only while fonts are still loading, and by the + * time `ready` resolves the status is `loaded`, so the corrective render + * arms nothing. A font that starts loading later (a host swapping theme + * fonts at runtime) flips the status back and correctly arms a fresh wait. + */ + private remeasureWhenFontsSettle() { + // no FontFaceSet in older browsers and in jsdom; nothing to wait on + const fonts: FontFaceSet | undefined = typeof document === 'undefined' ? undefined : document.fonts + if (!fonts || this.awaitingFonts || fonts.status === 'loaded') return + this.awaitingFonts = true + const remeasure = () => { + this.awaitingFonts = false + if (this.destroyed) return + // the code has not changed, so render() would short-circuit on + // lastRenderedCode — clear it to force the re-measure through + this.lastRenderedCode = '' + void this.render() + } + // `ready` is spec'd never to reject, but a partial polyfill could. Either + // way the measurements are as final as they are going to get, so both + // settlements take the same path. + void Promise.resolve(fonts.ready).then(remeasure, remeasure) + } + private bindSvg() { const svg = this.svg if (!svg) return @@ -2642,6 +2683,7 @@ export class MermaidCanvasView { } destroy() { + this.destroyed = true if (this.renderTimer) clearTimeout(this.renderTimer) if (this.lifelineClearTimer) clearTimeout(this.lifelineClearTimer) if (this.inPlaceSession?.liveTimer) clearTimeout(this.inPlaceSession.liveTimer) diff --git a/packages/dom/test/fonts.test.ts b/packages/dom/test/fonts.test.ts new file mode 100644 index 0000000..bf7053c --- /dev/null +++ b/packages/dom/test/fonts.test.ts @@ -0,0 +1,180 @@ +// @vitest-environment jsdom +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { MermaidWysiwygEditor } from '@visimer/core' +import { MermaidCanvasView, type MermaidLike } from '../src' + +// jsdom has no CSS.escape (browsers do) +if (typeof (globalThis as { CSS?: unknown }).CSS === 'undefined') { + ;(globalThis as { CSS?: { escape(s: string): string } }).CSS = { + escape: (s: string) => s.replace(/[^a-zA-Z0-9_-]/g, (c) => `\\${c}`), + } +} + +const CODE = 'flowchart TD\n A[Tests green?] --> B[Ship it]\n' + +/** + * Mermaid measures label text against the fonts the document can use at the + * moment it renders, then bakes those measurements into fixed-width + * `foreignObject` boxes. A webfont that arrives afterwards leaves every label + * clipped, and nothing re-measures on its own. These tests pin the view's + * response to that: re-render once the document's fonts have settled, and + * only when there was something to wait for. + */ + +function makeFakeMermaid() { + const renders: string[] = [] + const fake: MermaidLike & { renders: string[] } = { + renders, + initialize() {}, + async render(_id: string, code: string) { + renders.push(code) + const nodes = [...code.matchAll(/(\w+)\[([^\]]*)\]/g)] + const svg = [ + '', + '', + ...nodes.map( + ([, id, label], i) => + `` + + `

${label}

` + + `
`, + ), + '
', + '', + '', + '
', + ].join('') + return { svg } + }, + async parse() { + return {} + }, + } + return fake +} + +/** + * Stand-in for the slice of `FontFaceSet` the view reads. jsdom does not + * implement one, and the real thing cannot be driven from a test. + */ +function installFontFaceSet(status: 'loading' | 'loaded') { + let resolve!: () => void + const ready = new Promise((r) => { + resolve = r + }) + const fonts = { + status, + ready, + /** the webfont finished loading (or failed) and metrics are now final */ + settle() { + fonts.status = 'loaded' + resolve() + }, + } + Object.defineProperty(document, 'fonts', { value: fonts, configurable: true, writable: true }) + return fonts +} + +function removeFontFaceSet() { + Object.defineProperty(document, 'fonts', { value: undefined, configurable: true, writable: true }) +} + +/** drain microtasks and the macrotask queue so renders in flight land */ +async function flush() { + for (let i = 0; i < 5; i++) await new Promise((r) => setTimeout(r, 0)) +} + +describe('re-measures when the document fonts land after the first render', () => { + let editor: MermaidWysiwygEditor + let container: HTMLElement + let view: MermaidCanvasView | null + let mermaid: ReturnType + + beforeEach(() => { + editor = new MermaidWysiwygEditor({ code: CODE }) + container = document.createElement('div') + document.body.appendChild(container) + mermaid = makeFakeMermaid() + view = null + }) + + afterEach(() => { + view?.destroy() + container.remove() + // drop the stub so the next test starts from whatever the environment has + delete (document as unknown as { fonts?: unknown }).fonts + }) + + it('re-renders once the fonts finish loading', async () => { + const fonts = installFontFaceSet('loading') + view = new MermaidCanvasView({ editor, container, mermaid, debounceMs: 0 }) + await flush() + expect(mermaid.renders).toEqual([CODE]) + + fonts.settle() + await flush() + + // same code, rendered again: the boxes from the first pass were measured + // with the fallback font and have to be thrown away + expect(mermaid.renders).toEqual([CODE, CODE]) + }) + + it('does not keep re-rendering after the fonts have settled', async () => { + const fonts = installFontFaceSet('loading') + view = new MermaidCanvasView({ editor, container, mermaid, debounceMs: 0 }) + await flush() + fonts.settle() + await flush() + await flush() + + expect(mermaid.renders.length).toBe(2) + }) + + it('arms only one re-measure when several renders land before the fonts do', async () => { + const fonts = installFontFaceSet('loading') + view = new MermaidCanvasView({ editor, container, mermaid, debounceMs: 0 }) + await flush() + + // a second render while the fonts are still loading: re-theming, say + view.setMermaidConfig({}) + await flush() + expect(mermaid.renders.length).toBe(2) + + fonts.settle() + await flush() + await flush() + + // one corrective render, not one per render that was waiting + expect(mermaid.renders.length).toBe(3) + }) + + it('does not re-render when the fonts were already available', async () => { + installFontFaceSet('loaded') + view = new MermaidCanvasView({ editor, container, mermaid, debounceMs: 0 }) + await flush() + await flush() + + expect(mermaid.renders).toEqual([CODE]) + }) + + it('renders normally where the document exposes no font set', async () => { + removeFontFaceSet() + view = new MermaidCanvasView({ editor, container, mermaid, debounceMs: 0 }) + await flush() + await flush() + + expect(mermaid.renders).toEqual([CODE]) + }) + + it('does not re-render a destroyed view when the fonts land late', async () => { + const fonts = installFontFaceSet('loading') + view = new MermaidCanvasView({ editor, container, mermaid, debounceMs: 0 }) + await flush() + view.destroy() + view = null + + fonts.settle() + await flush() + + expect(mermaid.renders).toEqual([CODE]) + }) +})