mirror of
https://github.com/streetwriters/notesnook.git
synced 2026-09-03 04:31:46 +02:00
editor: size placeholders from each block type's own structure
This commit is contained in:
@@ -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();
|
||||
});
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user