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
9 changes: 9 additions & 0 deletions .changeset/pink-nights-shake.md
Original file line number Diff line number Diff line change
@@ -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.
6 changes: 6 additions & 0 deletions TESTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand All @@ -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.

Expand Down
42 changes: 42 additions & 0 deletions packages/dom/src/view.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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) {
Expand All @@ -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
Expand Down Expand Up @@ -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)
Expand Down
180 changes: 180 additions & 0 deletions packages/dom/test/fonts.test.ts
Original file line number Diff line number Diff line change
@@ -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 = [
'<svg xmlns="http://www.w3.org/2000/svg">',
'<g class="nodes">',
...nodes.map(
([, id, label], i) =>
`<g class="node" id="flowchart-${id}-${i}"><g class="label"><foreignObject width="80" height="24">` +
`<div xmlns="http://www.w3.org/1999/xhtml"><span class="nodeLabel"><p>${label}</p></span></div>` +
`</foreignObject></g></g>`,
),
'</g>',
'<g class="edgePaths"></g>',
'<g class="edgeLabels"></g>',
'</svg>',
].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<void>((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<typeof makeFakeMermaid>

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])
})
})