editor: size placeholders from each block type's own structure

This commit is contained in:
Ammar Ahmed
2026-08-26 13:03:32 +05:00
parent c3a62c486f
commit df82a0466f
3 changed files with 143 additions and 23 deletions

View File

@@ -25,6 +25,10 @@ import { HeightMap } from "../height-map.js";
import { Page, Paging } from "../../paging/index.js";
import { BlockId } from "../../block-id/block-id.js";
import { ImageNode } from "../../image/index.js";
import { Table } from "../../table/index.js";
import TableCell from "../../table-cell/index.js";
import TableHeader from "../../table-header/index.js";
import TableRow from "@tiptap/extension-table-row";
const PagedDocument = Node.create({
name: "doc",
@@ -37,6 +41,10 @@ function createEditor(content: string, pageSize = 100) {
extensions: [
StarterKit.configure({ document: false }),
ImageNode,
Table,
TableRow,
TableCell,
TableHeader,
PagedDocument,
Page,
BlockId,
@@ -110,23 +118,70 @@ describe("height map", () => {
editor.destroy();
});
test("each node type calibrates from its own measurements", () => {
test("text types calibrate from their own measurements", () => {
const editor = createEditor(
para("word ".repeat(50)) + para("word ".repeat(50))
);
const map = new HeightMap();
const [measured, other] = blocks(editor);
map.record(measured, 400);
expect(map.heightFor(measured)).toBe(400);
// an identical paragraph that was never measured now follows that ratio
expect(map.estimate(other)).toBeGreaterThan(300);
expect(map.estimate(other)).toBeLessThanOrEqual(420);
editor.destroy();
});
test("a list is measured by its items, not by prose density", () => {
const editor = createEditor(para("word ".repeat(50)) + listOf(20));
const map = new HeightMap();
const [paragraph, list] = blocks(editor);
// the list renders far denser per unit of content than the prose does
map.record(paragraph, 400);
map.record(list, 100);
// calibrating prose to something extreme must not move the list, which is
// estimated from the items it holds
const before = map.estimate(list);
map.record(paragraph, 4000);
// measured nodes report what they measured
expect(map.heightFor(paragraph)).toBe(400);
expect(map.heightFor(list)).toBe(100);
expect(map.estimate(list)).toBe(before);
editor.destroy();
});
// an unmeasured list of the same shape now follows the list's ratio, not
// the paragraph's
const other = list.type.create(null, list.content);
expect(map.estimate(other)).toBeLessThan(map.estimate(paragraph));
test("a table is estimated row by row", () => {
const rows = (count: number) => {
let html = `<table data-block-id="${id()}"><tbody>`;
for (let i = 0; i < count; i++)
html += `<tr><td><p>a</p></td><td><p>b</p></td></tr>`;
return html + "</tbody></table>";
};
const editor = createEditor(rows(2) + rows(20));
const map = new HeightMap();
const tables = blocks(editor).filter((n) => n.type.name === "table");
expect(tables).toHaveLength(2);
const small = map.estimate(tables[0]);
const big = map.estimate(tables[1]);
// ten times the rows, and the cells of a row sit side by side
expect(big).toBeGreaterThan(small * 3);
expect(big).toBeLessThan(small * 15);
editor.destroy();
});
test("an image too wide for the editor is scaled down", () => {
const editor = createEditor(
`${para(
"one"
)}<img src="x.png" width="1000" height="500" data-block-id="${id()}" />`
);
const map = new HeightMap();
const image = blocks(editor).find(
(node) => node.type.name === "image"
) as ProsemirrorNode;
expect(map.estimate(image)).toBe(500);
map.setWidth(500);
expect(map.estimate(image)).toBe(250);
editor.destroy();
});

View File

@@ -52,6 +52,10 @@ const DEFAULT_PIXELS_PER_UNIT = 0.4;
const RECALIBRATION_THRESHOLD = 0.15;
const PAGE_TYPE = "page";
const TABLE_TYPE = "table";
/** A table row is at least this tall, however little its cells hold. */
const MIN_ROW_HEIGHT = 32;
type Samples = { height: number; content: number };
@@ -60,6 +64,18 @@ export class HeightMap {
private samples = new Map<string, Samples>();
private global: Samples = { height: 0, content: 0 };
private stale = false;
private estimates = new WeakMap<ProsemirrorNode, number>();
private width = 0;
/**
* The width content is laid out in. An image wider than the editor is scaled
* down to fit, and its height with it.
*/
setWidth(width: number): void {
if (!Number.isFinite(width) || width <= 0 || width === this.width) return;
this.width = width;
this.estimates = new WeakMap();
}
/**
* Pixels per unit of content for a node type. Prose wraps to many lines while
@@ -76,28 +92,74 @@ export class HeightMap {
return DEFAULT_PIXELS_PER_UNIT;
}
/**
* Estimates a node from whatever structure it actually has: an image from
* its stored dimensions, a table from its rows, anything holding blocks from
* the blocks themselves, and text from how much of it there is.
*/
estimate(node: ProsemirrorNode): number {
// A page is only as tall as what it holds: estimate each block by its own
// type rather than assuming the page is uniform.
if (node.type.name === PAGE_TYPE) {
const cached = this.estimates.get(node);
if (cached !== undefined) return cached;
const height = this.computeEstimate(node);
this.estimates.set(node, height);
return height;
}
private computeEstimate(node: ProsemirrorNode): number {
const base = DEFAULT_ESTIMATES[node.type.name] ?? FALLBACK_ESTIMATE;
const stored = this.storedHeight(node);
if (stored) return stored;
// Structure beats the type's fallback: a two-row table is not as tall as
// the average table, it is as tall as two rows.
if (node.type.name === TABLE_TYPE) return this.table(node) || base;
// Lists, callouts, quotes, pages: a container is as tall as its contents,
// and its children carry their own structure down as far as it goes.
if (this.holdsBlocks(node)) {
let total = 0;
node.forEach((child) => (total += this.heightFor(child)));
return total || FALLBACK_ESTIMATE;
return total || base;
}
// Images and embeds carry their own dimensions, so there is nothing to
// guess: the stored height is what they will occupy.
const stored = Number(node.attrs.height);
if (Number.isFinite(stored) && stored > 0) return Math.round(stored);
const base = DEFAULT_ESTIMATES[node.type.name] ?? FALLBACK_ESTIMATE;
// `content.size` is O(1) and proportional to how much a node holds, unlike
// `textContent`, which would copy every character of every page.
// `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)));
}
/** Images and embeds know their own size; scale it if it must fit. */
private storedHeight(node: ProsemirrorNode): number | undefined {
const height = Number(node.attrs.height);
if (!Number.isFinite(height) || height <= 0) return undefined;
const width = Number(node.attrs.width);
if (this.width > 0 && Number.isFinite(width) && width > this.width)
return Math.round(height * (this.width / width));
return Math.round(height);
}
private holdsBlocks(node: ProsemirrorNode): boolean {
return node.childCount > 0 && !!node.firstChild?.isBlock;
}
/** Rows stack, but the cells within a row sit side by side. */
private table(node: ProsemirrorNode): number {
let total = 0;
node.forEach((row) => {
let tallest = 0;
row.forEach((cell) => {
const height = this.estimate(cell);
if (height > tallest) tallest = height;
});
total += Math.max(MIN_ROW_HEIGHT, tallest);
});
return total;
}
/** True once measurements have moved a ratio enough to resize placeholders. */
get needsRecalibration(): boolean {
return this.stale;
@@ -105,6 +167,7 @@ export class HeightMap {
markRecalibrated(): void {
this.stale = false;
this.estimates = new WeakMap();
}
heightFor(node: ProsemirrorNode): number {
@@ -121,6 +184,7 @@ export class HeightMap {
const blockId = node.attrs.blockId as string | undefined;
if (!blockId || !Number.isFinite(height) || height <= 0) return;
this.measured.set(blockId, Math.round(height));
this.estimates.delete(node);
profiler.gauge("virtualization.heightMap.size", this.measured.size);
// Pages are containers; calibrating from them would average away the

View File

@@ -451,6 +451,7 @@ export function virtualizationPlugin(
*/
const recordRenderedHeights = () => {
if (!heightMap) return;
heightMap.setWidth(editorView.dom.clientWidth);
const children = editorView.dom.children;
const doc = editorView.state.doc;
const count = Math.min(children.length, doc.childCount);