editor: estimate text from the layout it will wrap into

This commit is contained in:
Ammar Ahmed
2026-08-26 13:05:15 +05:00
parent df82a0466f
commit b8ea866177
3 changed files with 116 additions and 10 deletions

View File

@@ -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) + `<h1 data-block-id="${id()}">${text}</h1>`
);
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();

View File

@@ -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<number, number> = {
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<ProsemirrorNode, number>();
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<Metrics>): 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. */

View File

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