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 += ``; + return html + "

a

b

"; + }; + 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);