diff --git a/packages/editor/src/extensions/virtualization/__tests__/height-map.test.ts b/packages/editor/src/extensions/virtualization/__tests__/height-map.test.ts index 17cacd89c..4de4e5c3c 100644 --- a/packages/editor/src/extensions/virtualization/__tests__/height-map.test.ts +++ b/packages/editor/src/extensions/virtualization/__tests__/height-map.test.ts @@ -179,8 +179,10 @@ describe("height map", () => { (node) => node.type.name === "image" ) as ProsemirrorNode; - expect(map.estimate(image)).toBe(500); - map.setWidth(500); + // the editor defaults to an 850px measure, so a 1000px image already + // scales; a narrower one scales further + expect(map.estimate(image)).toBe(425); + map.setMetrics({ width: 500 }); expect(map.estimate(image)).toBe(250); editor.destroy(); }); @@ -199,6 +201,40 @@ describe("height map", () => { editor.destroy(); }); + test("text is estimated from the lines it wraps to", () => { + const editor = createEditor(para("word ".repeat(200))); + const map = new HeightMap(); + const [paragraph] = blocks(editor); + + const wide = map.estimate(paragraph); + map.setMetrics({ width: 300 }); + const narrow = map.estimate(paragraph); + + // the same text in a narrower measure wraps to more lines + expect(narrow).toBeGreaterThan(wide); + editor.destroy(); + }); + + test("a heading is estimated at its own size", () => { + const text = "a fairly long heading that will wrap ".repeat(4); + const editor = createEditor( + para(text) + `

${text}

` + ); + const map = new HeightMap(); + const [paragraph, heading] = blocks(editor); + + expect(heading.type.name).toBe("heading"); + expect(map.estimate(heading)).toBeGreaterThan(map.estimate(paragraph)); + editor.destroy(); + }); + + test("new layout metrics mark the map for recalibration", () => { + const map = new HeightMap(); + expect(map.needsRecalibration).toBe(false); + map.setMetrics({ width: 400 }); + expect(map.needsRecalibration).toBe(true); + }); + test("a measured height wins over any estimate", () => { const editor = createEditor(para("one") + para("two")); const map = new HeightMap(); diff --git a/packages/editor/src/extensions/virtualization/height-map.ts b/packages/editor/src/extensions/virtualization/height-map.ts index 5bf57436e..d42abf9e3 100644 --- a/packages/editor/src/extensions/virtualization/height-map.ts +++ b/packages/editor/src/extensions/virtualization/height-map.ts @@ -57,6 +57,28 @@ const TABLE_TYPE = "table"; /** A table row is at least this tall, however little its cells hold. */ const MIN_ROW_HEIGHT = 32; +/** + * Layout the estimates assume until the editor can be measured. The width + * matches the editor's own max-width so a first estimate is close even though + * the element has not been laid out yet. + */ +const DEFAULT_METRICS: Metrics = { width: 850, fontSize: 16, lineHeight: 24 }; + +/** Average glyph width as a fraction of font size, for proportional text. */ +const AVERAGE_CHAR_WIDTH = 0.5; + +/** How much larger each heading level renders than body text. */ +const HEADING_SCALE: Record = { + 1: 2, + 2: 1.5, + 3: 1.25, + 4: 1.1, + 5: 1, + 6: 1 +}; + +export type Metrics = { width: number; fontSize: number; lineHeight: number }; + type Samples = { height: number; content: number }; export class HeightMap { @@ -65,16 +87,29 @@ export class HeightMap { private global: Samples = { height: 0, content: 0 }; private stale = false; private estimates = new WeakMap(); - private width = 0; + private metrics: Metrics = { ...DEFAULT_METRICS }; /** - * The width content is laid out in. An image wider than the editor is scaled - * down to fit, and its height with it. + * The layout text is wrapped in. Nothing has been measured on a first load, + * so estimates are built from the editor's own font and width instead of a + * fixed guess: an 800-character paragraph is however many lines it wraps to. */ - setWidth(width: number): void { - if (!Number.isFinite(width) || width <= 0 || width === this.width) return; - this.width = width; + setMetrics(metrics: Partial): void { + let changed = false; + for (const key of ["width", "fontSize", "lineHeight"] as const) { + const value = metrics[key]; + if (!Number.isFinite(value) || !value || value <= 0) continue; + if (Math.abs((value as number) - this.metrics[key]) < 1) continue; + this.metrics[key] = value as number; + changed = true; + } + if (!changed) return; this.estimates = new WeakMap(); + this.stale = true; + } + + private get width(): number { + return this.metrics.width; } /** @@ -124,11 +159,37 @@ export class HeightMap { return total || base; } + return this.text(node, base); + } + + /** + * Text is estimated from the lines it wraps to. A measured ratio for the type + * wins once there is one, since it accounts for margins and font quirks the + * line model cannot see. + */ + private text(node: ProsemirrorNode, base: number): number { // `content.size` is O(1) and proportional to how much text a node holds, // unlike `textContent`, which would copy every character of every page. const content = node.content.size; if (!content) return base; - return Math.max(base, Math.round(content * this.ratioFor(node.type.name))); + + const sample = this.samples.get(node.type.name); + if (sample && sample.content > 0) + return Math.max( + base, + Math.round(content * (sample.height / sample.content)) + ); + + const scale = + node.type.name === "heading" + ? HEADING_SCALE[Number(node.attrs.level)] ?? 1 + : 1; + const charsPerLine = Math.max( + 20, + this.metrics.width / (this.metrics.fontSize * scale * AVERAGE_CHAR_WIDTH) + ); + const lines = Math.max(1, Math.ceil(content / charsPerLine)); + return Math.max(base, Math.round(lines * this.metrics.lineHeight * scale)); } /** Images and embeds know their own size; scale it if it must fit. */ diff --git a/packages/editor/src/extensions/virtualization/viewport-plugin.ts b/packages/editor/src/extensions/virtualization/viewport-plugin.ts index d170339ea..b4d10c4ac 100644 --- a/packages/editor/src/extensions/virtualization/viewport-plugin.ts +++ b/packages/editor/src/extensions/virtualization/viewport-plugin.ts @@ -451,7 +451,16 @@ export function virtualizationPlugin( */ const recordRenderedHeights = () => { if (!heightMap) return; - heightMap.setWidth(editorView.dom.clientWidth); + // Read the layout the note is actually rendered in, so estimates for + // everything still off screen match what the reader will see. + const style = getComputedStyle(editorView.dom); + const fontSize = parseFloat(style.fontSize); + const lineHeight = parseFloat(style.lineHeight); + heightMap.setMetrics({ + width: editorView.dom.clientWidth, + fontSize, + lineHeight: Number.isFinite(lineHeight) ? lineHeight : fontSize * 1.5 + }); const children = editorView.dom.children; const doc = editorView.state.doc; const count = Math.min(children.length, doc.childCount);