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 0f724a61f..17cacd89c 100644
--- a/packages/editor/src/extensions/virtualization/__tests__/height-map.test.ts
+++ b/packages/editor/src/extensions/virtualization/__tests__/height-map.test.ts
@@ -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 = `
`;
+ for (let i = 0; i < count; i++)
+ html += `a | b |
`;
+ return html + "
";
+ };
+ 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"
+ )}
`
+ );
+ 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();
});
diff --git a/packages/editor/src/extensions/virtualization/height-map.ts b/packages/editor/src/extensions/virtualization/height-map.ts
index e4b69c040..5bf57436e 100644
--- a/packages/editor/src/extensions/virtualization/height-map.ts
+++ b/packages/editor/src/extensions/virtualization/height-map.ts
@@ -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();
private global: Samples = { height: 0, content: 0 };
private stale = false;
+ private estimates = new WeakMap();
+ 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
diff --git a/packages/editor/src/extensions/virtualization/viewport-plugin.ts b/packages/editor/src/extensions/virtualization/viewport-plugin.ts
index 5f05c6e6b..d170339ea 100644
--- a/packages/editor/src/extensions/virtualization/viewport-plugin.ts
+++ b/packages/editor/src/extensions/virtualization/viewport-plugin.ts
@@ -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);