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);