diff --git a/packages/editor/src/extensions/paging/__tests__/nested-virtualization.test.ts b/packages/editor/src/extensions/paging/__tests__/nested-virtualization.test.ts
new file mode 100644
index 000000000..6f371c146
--- /dev/null
+++ b/packages/editor/src/extensions/paging/__tests__/nested-virtualization.test.ts
@@ -0,0 +1,512 @@
+/*
+This file is part of the Notesnook project (https://notesnook.com/)
+
+Copyright (C) 2023 Streetwriters (Private) Limited
+
+This program is free software: you can redistribute it and/or modify
+it under the terms of the GNU General Public License as published by
+the Free Software Foundation, either version 3 of the License, or
+(at your option) any later version.
+
+This program is distributed in the hope that it will be useful,
+but WITHOUT ANY WARRANTY; without even the implied warranty of
+MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+GNU General Public License for more details.
+
+You should have received a copy of the GNU General Public License
+along with this program. If not, see .
+*/
+
+import { Editor, Node } from "@tiptap/core";
+import StarterKit from "@tiptap/starter-kit";
+import { describe, expect, test } from "vitest";
+import TableRow from "@tiptap/extension-table-row";
+import { OutlineList } from "../../outline-list/index.js";
+import { OutlineListItem } from "../../outline-list-item/index.js";
+import { TaskItemNode } from "../../task-item/index.js";
+import { TaskListNode } from "../../task-list/index.js";
+import { BlockId } from "../../block-id/block-id.js";
+import TableCell from "../../table-cell/index.js";
+import TableHeader from "../../table-header/index.js";
+import { Table } from "../../table/index.js";
+import { Node as ProsemirrorNode } from "@tiptap/pm/model";
+import { widestChildren } from "../containers.js";
+import { countPages, Page, Paging } from "../index.js";
+
+const PagedDocument = Node.create({
+ name: "doc",
+ topNode: true,
+ content: "(page | block)+"
+});
+
+/** The window a container starts with, before anything has been measured. */
+const INITIAL = 30;
+
+/**
+ * Rows kept outside the window because they hold a column's widest cell. Every
+ * column of the test table is widest in the same row.
+ */
+const WIDEST = 1;
+
+let ids = 0;
+function id() {
+ return `blk${ids++}`;
+}
+
+function para(text: string) {
+ return `
${text}
`;
+}
+
+function listOf(items: number) {
+ let html = ``;
+ for (let i = 0; i < items; i++) html += `Item ${i}
`;
+ return html + "
";
+}
+
+function taskListOf(items: number) {
+ let html = ``;
+ for (let i = 0; i < items; i++) html += `Task ${i}
`;
+ return html + "
";
+}
+
+function outlineListOf(items: number) {
+ let html = ``;
+ for (let i = 0; i < items; i++)
+ html += `Point ${i}
`;
+ return html + "
";
+}
+
+function tableOf(rows: number) {
+ let html = ``;
+ for (let i = 0; i < rows; i++)
+ html += `Cell ${i} | Value ${i} |
`;
+ return html + "
";
+}
+
+function createEditor(content: string, thresholdBlocks = 1) {
+ return new Editor({
+ extensions: [
+ StarterKit.configure({ document: false }),
+ TaskListNode,
+ TaskItemNode,
+ OutlineList,
+ OutlineListItem,
+ Table,
+ TableRow,
+ TableCell,
+ TableHeader,
+ PagedDocument,
+ Page,
+ BlockId,
+ Paging.configure({ enabled: true, pageSize: 50, thresholdBlocks })
+ ],
+ content
+ });
+}
+
+function rect(top: number, height: number) {
+ return {
+ top,
+ bottom: top + height,
+ height,
+ left: 0,
+ right: 800,
+ width: 800,
+ x: 0,
+ y: top,
+ toJSON: () => ({})
+ } as DOMRect;
+}
+
+const ROW_HEIGHT = 32;
+
+/**
+ * happy-dom lays nothing out, so the note gets a synthetic geometry: the rows
+ * of a container stacked at a fixed height, scrolled down by `scrolledBy`.
+ */
+function stubLayout(editor: Editor, scrolledBy: number) {
+ const editorDom = editor.view.dom as HTMLElement;
+ HTMLElement.prototype.getBoundingClientRect = function () {
+ if (this === editorDom) return rect(-scrolledBy, 1e6);
+ const parent = this.parentElement;
+ if (!parent) return rect(0, 0);
+ const index = Array.prototype.indexOf.call(parent.children, this);
+ if (parent.tagName === "TBODY" || parent.tagName === "UL")
+ return rect(index * ROW_HEIGHT - scrolledBy, ROW_HEIGHT);
+ return rect(-scrolledBy, 1e6);
+ };
+}
+
+function frames(count = 2) {
+ return new Promise((resolve) => {
+ let remaining = count;
+ const tick = () =>
+ remaining-- > 0 ? requestAnimationFrame(tick) : resolve();
+ tick();
+ });
+}
+
+async function created() {
+ await new Promise((resolve) => setTimeout(resolve, 0));
+}
+
+function findList(editor: Editor) {
+ let list: ProsemirrorNode | undefined;
+ let start = -1;
+ editor.state.doc.descendants((node, position) => {
+ if (list || node.type.name !== "bulletList") return true;
+ list = node;
+ start = position;
+ return false;
+ });
+ if (!list) throw new Error("no list");
+ return { list, start };
+}
+
+function hidden(editor: Editor) {
+ return editor.view.dom.querySelectorAll("[data-virtual-child]");
+}
+
+function shown(editor: Editor) {
+ return editor.view.dom.querySelectorAll(
+ "li:not([data-virtual-child]):not([data-virtual-spacer])"
+ );
+}
+
+/** The container's children in order, without the spacers standing in for runs. */
+function childrenOf(editor: Editor, selector: string) {
+ const host = editor.view.dom.querySelector(selector);
+ return Array.from(host?.children ?? []).filter(
+ (element) => !element.hasAttribute("data-virtual-spacer")
+ );
+}
+
+describe("nested virtualization", () => {
+ test("a long list renders only the children in its window", async () => {
+ const editor = createEditor(para("intro") + listOf(200));
+ await created();
+
+ expect(shown(editor)).toHaveLength(INITIAL);
+ expect(hidden(editor)).toHaveLength(200 - INITIAL);
+ editor.destroy();
+ });
+
+ test("a short list is rendered whole", async () => {
+ const editor = createEditor(para("intro") + listOf(20));
+ await created();
+
+ expect(shown(editor)).toHaveLength(20);
+ expect(hidden(editor)).toHaveLength(0);
+ editor.destroy();
+ });
+
+ test("a page of ordinary prose is untouched", async () => {
+ let content = "";
+ for (let i = 0; i < 200; i++) content += para(`Paragraph ${i}`);
+ const editor = createEditor(content);
+ await created();
+
+ expect(hidden(editor)).toHaveLength(0);
+ editor.destroy();
+ });
+
+ test("the children left out are still in the document", async () => {
+ const editor = createEditor(para("intro") + listOf(200));
+ await created();
+
+ expect(hidden(editor).length).toBeGreaterThan(0);
+ const html = editor.getHTML();
+ expect(html.match(/ {
+ const editor = createEditor(para("intro") + listOf(200));
+ await created();
+
+ const list = editor.state.doc.child(0).child(1);
+ let position = 3 + para("intro").length;
+ for (let i = 0; i < 150; i++) position += list.child(i).nodeSize;
+ editor.commands.setTextSelection(position + 2);
+
+ expect(shown(editor).length).toBe(INITIAL + 1);
+ editor.destroy();
+ });
+
+ test("every child is rendered while the browser prints", async () => {
+ const editor = createEditor(para("intro") + listOf(200));
+ await created();
+ expect(hidden(editor).length).toBeGreaterThan(0);
+
+ window.dispatchEvent(new Event("beforeprint"));
+
+ expect(hidden(editor)).toHaveLength(0);
+ expect(editor.getText()).toContain("Item 199");
+ editor.destroy();
+ });
+
+ test("a long table renders only the rows in its window", async () => {
+ const editor = createEditor(para("intro") + tableOf(2000));
+ await created();
+
+ // every row still has an element of its own; the ones left out are empty
+ expect(childrenOf(editor, "tbody")).toHaveLength(2000);
+ expect(hidden(editor)).toHaveLength(2000 - INITIAL - WIDEST);
+ // the cells of the rows that are left out are never built
+ // the rows left out are empty and hidden, so their cells are never built;
+ // each spacer carries one cell of its own
+ const spacers = editor.view.dom.querySelectorAll("[data-virtual-spacer]");
+ expect(editor.view.dom.querySelectorAll("td")).toHaveLength(
+ (INITIAL + WIDEST) * 2 + spacers.length
+ );
+ editor.destroy();
+ });
+
+ test("the rows left out are still in the document", async () => {
+ const editor = createEditor(para("intro") + tableOf(2000));
+ await created();
+
+ const html = editor.getHTML();
+ expect(html.match(/ {
+ const editor = createEditor(para("intro") + tableOf(2000));
+ await created();
+
+ for (const element of Array.from(hidden(editor)))
+ expect(element.tagName).toBe("TR");
+ editor.destroy();
+ });
+
+ test("a note too small to be paged is still windowed", async () => {
+ // the reported case: one enormous table is only a block or two, so it
+ // never reaches the threshold that turns a note into pages
+ const editor = createEditor(para("intro") + tableOf(2000), 100);
+ await created();
+
+ expect(countPages(editor.state.doc)).toBe(0);
+ expect(hidden(editor)).toHaveLength(2000 - INITIAL - WIDEST);
+ editor.destroy();
+ });
+
+ test("children come back when their container gets short", async () => {
+ const editor = createEditor(para("intro") + listOf(200));
+ await created();
+ expect(hidden(editor).length).toBeGreaterThan(0);
+
+ const { list, start } = findList(editor);
+ let from = start + 1;
+ for (let i = 0; i < 20; i++) from += list.child(i).nodeSize;
+ editor.view.dispatch(
+ editor.state.tr.delete(from, start + list.nodeSize - 1)
+ );
+
+ expect(hidden(editor)).toHaveLength(0);
+ expect(shown(editor)).toHaveLength(20);
+ editor.destroy();
+ });
+
+ test("a child inserted into the window renders straight away", async () => {
+ const editor = createEditor(para("intro") + listOf(200));
+ await created();
+
+ const { list, start } = findList(editor);
+ let at = start + 1;
+ for (let i = 0; i < 5; i++) at += list.child(i).nodeSize;
+ editor.view.dispatch(editor.state.tr.insert(at, list.child(0)));
+
+ // the container is still windowed: the stand-ins find it through the DOM,
+ // because asking each one for its position would cost a sibling scan
+ expect(hidden(editor)).toHaveLength(201 - INITIAL);
+
+ const items = childrenOf(editor, "ul");
+ expect(items.length).toBeGreaterThan(INITIAL);
+ for (let i = 0; i < INITIAL; i++)
+ expect(items[i].hasAttribute("data-virtual-child")).toBe(false);
+ editor.destroy();
+ });
+
+ test("the window follows the scroll through a container", async () => {
+ // a table draws itself, so its element carries no block id -- the window
+ // still has to be measured through it
+ const editor = createEditor(para("intro") + tableOf(500), 100);
+ await created();
+ const layout = HTMLElement.prototype.getBoundingClientRect;
+
+ stubLayout(editor, 200 * ROW_HEIGHT);
+ await frames();
+
+ const rows = childrenOf(editor, "tbody");
+ const rendered = (index: number) =>
+ !rows[index].hasAttribute("data-virtual-child");
+ // the rows seeded at the top have been given up for the ones on screen
+ expect(rendered(0)).toBe(false);
+ expect(rendered(200)).toBe(true);
+ expect(rendered(220)).toBe(true);
+ expect(rendered(499)).toBe(false);
+
+ HTMLElement.prototype.getBoundingClientRect = layout;
+ editor.destroy();
+ });
+
+ test("the window follows the scroll past an element the editor did not draw", async () => {
+ // another plugin's widget, a drag handle, anything: the editor's children
+ // and the document's no longer line up index for index
+ const editor = createEditor(para("intro") + tableOf(500), 100);
+ await created();
+ const layout = HTMLElement.prototype.getBoundingClientRect;
+ editor.view.dom.insertBefore(
+ document.createElement("div"),
+ editor.view.dom.firstChild
+ );
+
+ stubLayout(editor, 200 * ROW_HEIGHT);
+ await frames();
+
+ const rows = childrenOf(editor, "tbody");
+ expect(rows[0].hasAttribute("data-virtual-child")).toBe(true);
+ expect(rows[220].hasAttribute("data-virtual-child")).toBe(false);
+
+ HTMLElement.prototype.getBoundingClientRect = layout;
+ editor.destroy();
+ });
+
+ test("the row a column is widest in is never left out", async () => {
+ // otherwise the column resizes, and every row re-wraps, as it scrolls past
+ const editor = createEditor(para("intro") + tableOf(500), 100);
+ await created();
+ const layout = HTMLElement.prototype.getBoundingClientRect;
+
+ const table = editor.state.doc.child(1);
+ const widest = widestChildren(table);
+ expect(widest).toHaveLength(1);
+
+ stubLayout(editor, 400 * ROW_HEIGHT);
+ await frames();
+
+ const rows = childrenOf(editor, "tbody");
+ for (const index of widest)
+ expect(rows[index].hasAttribute("data-virtual-child")).toBe(false);
+ // and it really is outside the window
+ expect(rows[widest[0] + 1].hasAttribute("data-virtual-child")).toBe(true);
+
+ HTMLElement.prototype.getBoundingClientRect = layout;
+ editor.destroy();
+ });
+
+ test("a long task list is windowed too", async () => {
+ // task items draw themselves, so they opt in through `virtualizable`
+ // rather than through the views the plugin registers
+ const editor = createEditor(para("intro") + taskListOf(300), 100);
+ await created();
+
+ expect(shown(editor).length).toBeLessThan(60);
+ expect(hidden(editor).length).toBeGreaterThan(200);
+ expect(editor.getHTML()).toContain("Task 299");
+ editor.destroy();
+ });
+
+ test("a long outline list is windowed too", async () => {
+ const editor = createEditor(para("intro") + outlineListOf(300), 100);
+ await created();
+
+ expect(editor.state.doc.child(1).type.name).toBe("outlineList");
+ expect(editor.state.doc.child(1).firstChild?.type.name).toBe(
+ "outlineListItem"
+ );
+ expect(hidden(editor).length).toBeGreaterThan(200);
+ expect(editor.getHTML()).toContain("Point 299");
+ editor.destroy();
+ });
+
+ test("children left out cost the browser no layout", async () => {
+ const editor = createEditor(para("intro") + tableOf(2000), 100);
+ await created();
+
+ for (const element of Array.from(hidden(editor))) {
+ // display:none means the browser builds no layout box at all, which is
+ // the whole point -- an empty box of the right height is still a box
+ expect((element as HTMLElement).style.display).toBe("none");
+ expect(element.children).toHaveLength(0);
+ }
+ editor.destroy();
+ });
+
+ test("the spacers hold the space of every run left out", async () => {
+ const editor = createEditor(para("intro") + tableOf(2000), 100);
+ await created();
+
+ const spacers = editor.view.dom.querySelectorAll("[data-virtual-spacer]");
+ expect(spacers.length).toBeGreaterThan(0);
+
+ const heights = (
+ editor.storage.paging as {
+ heights: { heightFor(node: ProsemirrorNode): number };
+ }
+ ).heights;
+ const table = editor.state.doc.child(1);
+ const rows = childrenOf(editor, "tbody");
+ let missing = 0;
+ rows.forEach((row, index) => {
+ if (row.hasAttribute("data-virtual-child"))
+ missing += heights.heightFor(table.child(index));
+ });
+
+ const held = Array.from(spacers).reduce(
+ (total, element) =>
+ total +
+ parseFloat((element.firstElementChild as HTMLElement).style.height),
+ 0
+ );
+ expect(held).toBeCloseTo(missing, -1);
+ editor.destroy();
+ });
+
+ test("what a rendered row measures is remembered for its stand-in", async () => {
+ const editor = createEditor(para("intro") + tableOf(2000), 100);
+ await created();
+ const layout = HTMLElement.prototype.getBoundingClientRect;
+ const height = Object.getOwnPropertyDescriptor(
+ HTMLElement.prototype,
+ "offsetHeight"
+ );
+
+ // happy-dom measures everything as zero, so the rows are given a height
+ // that is nothing like the estimate
+ Object.defineProperty(HTMLElement.prototype, "offsetHeight", {
+ configurable: true,
+ get(this: HTMLElement) {
+ return this.tagName === "TR" ? 77 : 0;
+ }
+ });
+ stubLayout(editor, 0);
+ await frames();
+
+ const heights = (
+ editor.storage.paging as {
+ heights: { heightFor(node: ProsemirrorNode): number };
+ }
+ ).heights;
+ const table = editor.state.doc.child(1);
+ expect(heights.heightFor(table.child(0))).toBe(77);
+
+ if (height)
+ Object.defineProperty(HTMLElement.prototype, "offsetHeight", height);
+ HTMLElement.prototype.getBoundingClientRect = layout;
+ editor.destroy();
+ });
+
+ test("a stand-in child keeps the tag its container expects", async () => {
+ const editor = createEditor(para("intro") + listOf(200));
+ await created();
+
+ for (const element of Array.from(hidden(editor)))
+ expect(element.tagName).toBe("LI");
+ editor.destroy();
+ });
+});
diff --git a/packages/editor/src/extensions/paging/child-view.ts b/packages/editor/src/extensions/paging/child-view.ts
new file mode 100644
index 000000000..e8eda6cde
--- /dev/null
+++ b/packages/editor/src/extensions/paging/child-view.ts
@@ -0,0 +1,167 @@
+/*
+This file is part of the Notesnook project (https://notesnook.com/)
+
+Copyright (C) 2023 Streetwriters (Private) Limited
+
+This program is free software: you can redistribute it and/or modify
+it under the terms of the GNU General Public License as published by
+the Free Software Foundation, either version 3 of the License, or
+(at your option) any later version.
+
+This program is distributed in the hope that it will be useful,
+but WITHOUT ANY WARRANTY; without even the implied warranty of
+MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+GNU General Public License for more details.
+
+You should have received a copy of the GNU General Public License
+along with this program. If not, see .
+*/
+
+import { NodeViewRenderer, NodeViewRendererProps } from "@tiptap/core";
+import { DOMSerializer, Node as ProsemirrorNode } from "@tiptap/pm/model";
+import {
+ Decoration,
+ EditorView,
+ NodeView,
+ NodeViewConstructor
+} from "@tiptap/pm/view";
+import { profiler } from "../../utils/profiler.js";
+import { isRendered } from "./page-view.js";
+import {
+ hasWindowedContainers,
+ isInsideWindowedContainer,
+ WINDOWED_ATTRIBUTE
+} from "./state.js";
+
+type GetPos = (() => number | undefined) | boolean | undefined;
+
+/**
+ * Whether this child sits in a container that is being windowed.
+ *
+ * Asking a child for its position is only cheap while it is being built --
+ * afterwards ProseMirror works it out by counting siblings, which a container
+ * of ten thousand rows makes ruinous. So once the child has a place in the DOM
+ * the container is found there instead, where the window marks it.
+ */
+function isInWindowedContainer(
+ view: EditorView,
+ getPos: GetPos,
+ dom: Node | null
+): boolean {
+ if (!hasWindowedContainers(view)) return false;
+
+ const parent = dom?.parentElement;
+ if (parent) return !!parent.closest(`[${WINDOWED_ATTRIBUTE}]`);
+
+ const position = typeof getPos === "function" ? getPos() : undefined;
+ return (
+ typeof position === "number" && isInsideWindowedContainer(view, position)
+ );
+}
+
+function tagFor(node: ProsemirrorNode): string {
+ const spec = node.type.spec.toDOM?.(node);
+ return Array.isArray(spec) && typeof spec[0] === "string" ? spec[0] : "div";
+}
+
+/**
+ * An empty, hidden element standing in for a child that is off screen.
+ *
+ * It is `display: none`, so the browser gives it no layout box at all -- the
+ * point of leaving a child out is lost if the browser still has to lay it out,
+ * and a table of twelve thousand rows is twelve thousand layout boxes. The
+ * space the hidden children would have taken is held by one spacer per run
+ * instead. The tag still matches what the child would have been: a row that is
+ * not a `tr` is torn out of its table by the browser.
+ */
+function standInFor(
+ node: ProsemirrorNode,
+ isLeftOut: (decorations: readonly Decoration[]) => boolean
+): NodeView {
+ profiler.count("paging.childStandIns");
+ const dom = document.createElement(tagFor(node));
+ dom.setAttribute("data-virtual-child", "true");
+ dom.style.display = "none";
+
+ return {
+ dom,
+ contentDOM: null,
+ update(updated, decorations) {
+ if (updated.type !== node.type) return false;
+ if (!isLeftOut(decorations)) return false;
+ node = updated;
+ return true;
+ },
+ ignoreMutation() {
+ return true;
+ }
+ };
+}
+
+/** The child drawn the way the schema says, for types with no view of their own. */
+function drawnFromSchema(node: ProsemirrorNode): NodeView {
+ const spec = node.type.spec.toDOM?.(node);
+ if (!spec) return { dom: document.createElement("div") };
+ return DOMSerializer.renderSpec(document, spec);
+}
+
+/**
+ * Stand-in views for container children that no extension renders itself.
+ * Registered only while paging is on, and a no-op for every container small
+ * enough to be rendered whole.
+ */
+export const containerChildView: NodeViewConstructor = (
+ node,
+ view,
+ getPos,
+ decorations
+) => {
+ let dom: Node | null = null;
+ const isLeftOut = (current: readonly Decoration[]) =>
+ !isRendered(current) && isInWindowedContainer(view, getPos, dom);
+
+ if (isLeftOut(decorations)) {
+ const standIn = standInFor(node, isLeftOut);
+ dom = standIn.dom;
+ return standIn;
+ }
+
+ const drawn = drawnFromSchema(node);
+ dom = drawn.dom;
+ return {
+ ...drawn,
+ update: (updated, current) =>
+ !isLeftOut(current) && updated.sameMarkup(node)
+ };
+};
+
+/**
+ * Lets an extension's own node view be left out of a windowed container. Does
+ * nothing at all unless paging is on and the child's container is long enough
+ * to be windowed.
+ */
+export function virtualizable(render: NodeViewRenderer): NodeViewRenderer {
+ return (props: NodeViewRendererProps) => {
+ const view = props.editor.view;
+ let dom: Node | null = null;
+ const isLeftOut = (current: readonly Decoration[]) =>
+ !isRendered(current) && isInWindowedContainer(view, props.getPos, dom);
+
+ if (isLeftOut(props.decorations)) {
+ const standIn = standInFor(props.node, isLeftOut);
+ dom = standIn.dom;
+ return standIn;
+ }
+
+ const drawn = render(props) as NodeView;
+ dom = drawn.dom;
+ const update = drawn.update?.bind(drawn);
+ drawn.update = (updated, current, inner) => {
+ if (isLeftOut(current)) return false;
+ return update
+ ? update(updated, current, inner)
+ : updated.sameMarkup(props.node);
+ };
+ return drawn;
+ };
+}
diff --git a/packages/editor/src/extensions/paging/containers.ts b/packages/editor/src/extensions/paging/containers.ts
new file mode 100644
index 000000000..9fdc4dfa7
--- /dev/null
+++ b/packages/editor/src/extensions/paging/containers.ts
@@ -0,0 +1,140 @@
+/*
+This file is part of the Notesnook project (https://notesnook.com/)
+
+Copyright (C) 2023 Streetwriters (Private) Limited
+
+This program is free software: you can redistribute it and/or modify
+it under the terms of the GNU General Public License as published by
+the Free Software Foundation, either version 3 of the License, or
+(at your option) any later version.
+
+This program is distributed in the hope that it will be useful,
+but WITHOUT ANY WARRANTY; without even the implied warranty of
+MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+GNU General Public License for more details.
+
+You should have received a copy of the GNU General Public License
+along with this program. If not, see .
+*/
+
+import { Node as ProsemirrorNode } from "@tiptap/pm/model";
+
+/**
+ * Containers whose children stack one above the other, so rendering only some
+ * of them leaves the rest in the right place. Anything not listed here is
+ * rendered whole.
+ */
+const TABLE = "table";
+
+const WINDOWABLE_CONTAINERS = new Set([
+ TABLE,
+ "bulletList",
+ "orderedList",
+ "taskList",
+ "checkList",
+ "outlineList"
+]);
+
+/** Child types the stand-in views are registered for. */
+export const TABLE_ROW_NODE = "tableRow";
+export const LIST_ITEM_NODE = "listItem";
+
+/** Fewer children than this and rendering the whole container is cheap. */
+const MIN_CHILDREN = 100;
+
+/** How far into a block to look for containers worth windowing. */
+const MAX_DEPTH = 3;
+
+/** How many children to render before anything has been measured. */
+export const CHILDREN_BEFORE_MEASURING = 30;
+
+export type WindowedContainer = {
+ node: ProsemirrorNode;
+ /** Where the container begins, counted from the start of the block. */
+ offset: number;
+ id: string;
+};
+
+const containersByBlock = new WeakMap();
+const widestByTable = new WeakMap();
+const NO_CHILDREN: number[] = [];
+
+function isWorthWindowing(node: ProsemirrorNode): boolean {
+ return (
+ WINDOWABLE_CONTAINERS.has(node.type.name) && node.childCount >= MIN_CHILDREN
+ );
+}
+
+function findContainers(
+ parent: ProsemirrorNode,
+ contentStart: number,
+ depth: number,
+ found: WindowedContainer[]
+): void {
+ parent.forEach((child, offset) => {
+ if (child.isLeaf || child.isTextblock) return;
+ const start = contentStart + offset;
+ if (isWorthWindowing(child)) {
+ const id = child.attrs.blockId as string | undefined;
+ if (id) found.push({ node: child, offset: start, id });
+ } else if (depth < MAX_DEPTH)
+ findContainers(child, start + 1, depth + 1, found);
+ });
+}
+
+/**
+ * The containers within `block` that are long enough to be worth windowing,
+ * offset from where `block` itself begins. `block` counts as one of them, so
+ * the same call covers both the blocks of a page and a table that is a note's
+ * only block.
+ *
+ * Answers from the cache after the first look: nodes are immutable, so an edit
+ * anywhere else never invalidates it.
+ */
+export function containersWorthWindowing(
+ block: ProsemirrorNode
+): WindowedContainer[] {
+ const cached = containersByBlock.get(block);
+ if (cached) return cached;
+
+ const found: WindowedContainer[] = [];
+ const id = block.attrs.blockId as string | undefined;
+ if (!isWorthWindowing(block)) findContainers(block, 1, 0, found);
+ else if (id) found.push({ node: block, offset: 0, id });
+
+ containersByBlock.set(block, found);
+ return found;
+}
+
+/**
+ * The rows that decide each column's width. A column is only as wide as its
+ * widest cell, so leaving that cell out would make the column change width as
+ * the reader scrolls -- and every row re-wrap with it. These rows are rendered
+ * whatever the window says.
+ *
+ * Which cell is widest is taken from how much each one holds, since that is
+ * what the width follows.
+ */
+export function widestChildren(container: ProsemirrorNode): number[] {
+ if (container.type.name !== TABLE) return NO_CHILDREN;
+
+ const cached = widestByTable.get(container);
+ if (cached) return cached;
+
+ const widest: number[] = [];
+ const rowFor: number[] = [];
+ container.forEach((row, _offset, index) => {
+ let column = 0;
+ row.forEach((cell) => {
+ if (!(widest[column] >= cell.content.size)) {
+ widest[column] = cell.content.size;
+ rowFor[column] = index;
+ }
+ column += Number(cell.attrs.colspan) || 1;
+ });
+ });
+
+ const rows = [...new Set(rowFor)].sort((a, b) => a - b);
+ widestByTable.set(container, rows);
+ return rows;
+}
diff --git a/packages/editor/src/extensions/paging/height-index.ts b/packages/editor/src/extensions/paging/height-index.ts
new file mode 100644
index 000000000..45750b1df
--- /dev/null
+++ b/packages/editor/src/extensions/paging/height-index.ts
@@ -0,0 +1,89 @@
+/*
+This file is part of the Notesnook project (https://notesnook.com/)
+
+Copyright (C) 2023 Streetwriters (Private) Limited
+
+This program is free software: you can redistribute it and/or modify
+it under the terms of the GNU General Public License as published by
+the Free Software Foundation, either version 3 of the License, or
+(at your option) any later version.
+
+This program is distributed in the hope that it will be useful,
+but WITHOUT ANY WARRANTY; without even the implied warranty of
+MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+GNU General Public License for more details.
+
+You should have received a copy of the GNU General Public License
+along with this program. If not, see .
+*/
+
+import { Node as ProsemirrorNode } from "@tiptap/pm/model";
+import { HeightMap } from "./height-map.js";
+
+/**
+ * Where each child of a container sits, as a running total of the heights
+ * before it.
+ *
+ * The children that are off screen are hidden rather than laid out, so the
+ * browser cannot be asked where they are. Adding their heights up answers the
+ * same question without touching the DOM, and answers it for every child
+ * rather than only the ones on screen.
+ */
+export type HeightIndex = {
+ /** `before[i]` is the height of children 0..i-1; the last entry is the total. */
+ before: Float64Array;
+ total: number;
+};
+
+type Cached = HeightIndex & { childCount: number; revision: number };
+
+const indexes = new Map();
+
+/**
+ * Adding up every child is too much to do while scrolling, so the answer is
+ * kept until the container gains or loses a child, or a measurement changes a
+ * height. Editing the text in a child does neither.
+ */
+export function heightIndexFor(
+ id: string,
+ container: ProsemirrorNode,
+ heights: HeightMap
+): HeightIndex {
+ const cached = indexes.get(id);
+ if (
+ cached &&
+ cached.childCount === container.childCount &&
+ cached.revision === heights.revision
+ )
+ return cached;
+
+ const before = new Float64Array(container.childCount + 1);
+ let total = 0;
+ container.forEach((child, _offset, index) => {
+ before[index] = total;
+ total += heights.heightFor(child);
+ });
+ before[container.childCount] = total;
+
+ const index: Cached = {
+ before,
+ total,
+ childCount: container.childCount,
+ revision: heights.revision
+ };
+ indexes.set(id, index);
+ return index;
+}
+
+/** The first child reaching down to `offset` pixels into the container. */
+export function childAt(index: HeightIndex, offset: number): number {
+ const { before } = index;
+ let low = 0;
+ let high = before.length - 2;
+ while (low < high) {
+ const middle = (low + high + 1) >> 1;
+ if (before[middle] <= offset) low = middle;
+ else high = middle - 1;
+ }
+ return low;
+}
diff --git a/packages/editor/src/extensions/paging/height-map.ts b/packages/editor/src/extensions/paging/height-map.ts
index 405bcc720..36059ec4e 100644
--- a/packages/editor/src/extensions/paging/height-map.ts
+++ b/packages/editor/src/extensions/paging/height-map.ts
@@ -52,6 +52,7 @@ const RESIZE_THRESHOLD = 0.15;
const PAGE_TYPE = "page";
const TABLE_TYPE = "table";
+const TABLE_ROW_TYPE = "tableRow";
/** A table row is at least this tall, however little its cells hold. */
const MIN_ROW_HEIGHT = 32;
@@ -81,6 +82,8 @@ type Measured = { height: number; characters: number };
export class HeightMap {
private measured = new Map();
+ private sizes = new WeakMap();
+ private childRevision = 0;
private measurements = new Map();
private global: Measured = { height: 0, characters: 0 };
private stale = false;
@@ -147,6 +150,7 @@ export class HeightMap {
if (stored) return stored;
if (node.type.name === TABLE_TYPE) return this.table(node) || base;
+ if (node.type.name === TABLE_ROW_TYPE) return this.row(node);
if (this.holdsBlocks(node)) {
let total = 0;
@@ -200,20 +204,22 @@ export class HeightMap {
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);
- });
+ node.forEach((row) => (total += this.estimate(row)));
return total;
}
+ /** The cells within a row sit side by side, so the tallest one wins. */
+ private row(node: ProsemirrorNode): number {
+ let tallest = 0;
+ node.forEach((cell) => {
+ const height = this.estimate(cell);
+ if (height > tallest) tallest = height;
+ });
+ return Math.max(MIN_ROW_HEIGHT, tallest);
+ }
+
/** True once measurements have moved a ratio enough to resize placeholders. */
get placeholdersNeedResizing(): boolean {
return this.stale;
@@ -230,13 +236,31 @@ export class HeightMap {
profiler.count("virtualization.heightMap.hit");
return this.measured.get(blockId) as number;
}
+ const size = this.sizes.get(node);
+ if (size !== undefined) {
+ profiler.count("virtualization.heightMap.hit");
+ return size;
+ }
profiler.count("virtualization.heightMap.miss");
return this.estimate(node);
}
record(node: ProsemirrorNode, height: number): void {
+ if (!Number.isFinite(height) || height <= 0) return;
+
const blockId = node.attrs.blockId as string | undefined;
- if (!blockId || !Number.isFinite(height) || height <= 0) return;
+ // A row or a list item has no id of its own, so its height is remembered
+ // against the node. Nodes are immutable, so editing one forgets only that
+ // one, and a stand-in is the size of the thing it replaced rather than a
+ // guess -- which is what stops the note shifting under the reader.
+ if (!blockId) {
+ const measured = Math.round(height);
+ if (this.sizes.get(node) !== measured) {
+ this.sizes.set(node, measured);
+ this.childRevision++;
+ }
+ return;
+ }
this.measured.set(blockId, Math.round(height));
this.estimates.delete(node);
profiler.gauge("virtualization.heightMap.size", this.measured.size);
@@ -265,6 +289,15 @@ export class HeightMap {
);
}
+ /**
+ * Bumped whenever a container child's real height turns out to differ from
+ * what was remembered, so anything totalling those heights knows to add up
+ * again -- and, just as importantly, does not when nothing moved.
+ */
+ get revision(): number {
+ return this.childRevision;
+ }
+
toJSON(): Record {
return Object.fromEntries(this.measured);
}
diff --git a/packages/editor/src/extensions/paging/index.ts b/packages/editor/src/extensions/paging/index.ts
index 02ae64d65..2849f6b45 100644
--- a/packages/editor/src/extensions/paging/index.ts
+++ b/packages/editor/src/extensions/paging/index.ts
@@ -114,4 +114,4 @@ export {
isPage,
toPages
} from "./split.js";
-export { viewportKey } from "./viewport-plugin.js";
+export { viewportKey } from "./state.js";
diff --git a/packages/editor/src/extensions/paging/state.ts b/packages/editor/src/extensions/paging/state.ts
new file mode 100644
index 000000000..887c4bf52
--- /dev/null
+++ b/packages/editor/src/extensions/paging/state.ts
@@ -0,0 +1,95 @@
+/*
+This file is part of the Notesnook project (https://notesnook.com/)
+
+Copyright (C) 2023 Streetwriters (Private) Limited
+
+This program is free software: you can redistribute it and/or modify
+it under the terms of the GNU General Public License as published by
+the Free Software Foundation, either version 3 of the License, or
+(at your option) any later version.
+
+This program is distributed in the hope that it will be useful,
+but WITHOUT ANY WARRANTY; without even the implied warranty of
+MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+GNU General Public License for more details.
+
+You should have received a copy of the GNU General Public License
+along with this program. If not, see .
+*/
+
+import { EditorState, PluginKey } from "@tiptap/pm/state";
+import { DecorationSet, EditorView } from "@tiptap/pm/view";
+
+export type ChildWindow = {
+ /** Where the container itself begins and ends in the document. */
+ containerStart: number;
+ containerEnd: number;
+ /** The half-open range of child indexes that is rendered. */
+ from: number;
+ to: number;
+ /** The same run as document positions, for anything working in those. */
+ renderedStart: number;
+ renderedEnd: number;
+ /** How many children it had, so an inserted one can be noticed. */
+ childCount: number;
+};
+
+export type ViewportState = {
+ visible: Set;
+ windows: Map;
+ /** Set while printing, when every child of every container has to be there. */
+ expanded: boolean;
+ selectionIndex: number;
+ pageCount: number;
+ decorations: DecorationSet;
+};
+
+export const viewportKey = new PluginKey("notesnook-paging");
+
+/** Marks a container whose children are being windowed. */
+export const WINDOWED_ATTRIBUTE = "data-windowed";
+
+export function hasWindowedContainers(view: EditorView): boolean {
+ return !!viewportKey.getState(view.state)?.windows.size;
+}
+
+export type RenderedRange = {
+ containerStart: number;
+ containerEnd: number;
+ start: number;
+ end: number;
+};
+
+/**
+ * Every windowed container in the note, with the run of it that is actually
+ * rendered. Anything that would otherwise walk all of a container's children
+ * can use this to walk only the ones on screen: a child left out has no DOM of
+ * its own, so work spent on it is wasted whatever the caller wanted it for.
+ *
+ * Read this from the state a transaction started in and move the positions
+ * with its mapping. Reading it from the state being built depends on which
+ * plugin's field is applied first, which is not something a caller should have
+ * to know.
+ */
+export function renderedRanges(state: EditorState): RenderedRange[] {
+ const windows = viewportKey.getState(state)?.windows;
+ if (!windows?.size) return [];
+ return [...windows.values()].map((window) => ({
+ containerStart: window.containerStart,
+ containerEnd: window.containerEnd,
+ start: window.renderedStart,
+ end: window.renderedEnd
+ }));
+}
+
+export function isInsideWindowedContainer(
+ view: EditorView,
+ position: number
+): boolean {
+ const windows = viewportKey.getState(view.state)?.windows;
+ if (!windows) return false;
+ for (const window of windows.values())
+ if (position > window.containerStart && position < window.containerEnd)
+ return true;
+ return false;
+}
diff --git a/packages/editor/src/extensions/paging/viewport-plugin.ts b/packages/editor/src/extensions/paging/viewport-plugin.ts
index 1dc32d073..060d6f758 100644
--- a/packages/editor/src/extensions/paging/viewport-plugin.ts
+++ b/packages/editor/src/extensions/paging/viewport-plugin.ts
@@ -18,24 +18,48 @@ along with this program. If not, see .
*/
import { Node as ProsemirrorNode } from "@tiptap/pm/model";
-import { EditorState, Plugin, PluginKey } from "@tiptap/pm/state";
+import { EditorState, Plugin, Selection } from "@tiptap/pm/state";
+import { Mapping } from "@tiptap/pm/transform";
import { Decoration, DecorationSet, EditorView } from "@tiptap/pm/view";
import { profiler } from "../../utils/profiler.js";
+import { containerChildView } from "./child-view.js";
+import { childAt, HeightIndex, heightIndexFor } from "./height-index.js";
+import {
+ CHILDREN_BEFORE_MEASURING,
+ containersWorthWindowing,
+ LIST_ITEM_NODE,
+ TABLE_ROW_NODE,
+ widestChildren,
+ WindowedContainer
+} from "./containers.js";
import { HeightMap } from "./height-map.js";
import { PAGE_NODE } from "./page.js";
+import {
+ ChildWindow,
+ ViewportState,
+ viewportKey,
+ WINDOWED_ATTRIBUTE
+} from "./state.js";
-export const viewportKey = new PluginKey("notesnook-paging");
-
-type ViewportState = {
- visible: Set;
- selectionIndex: number;
- pageCount: number;
- decorations: DecorationSet;
-};
+export { viewportKey } from "./state.js";
type PageRange = { from: number; to: number; index: number };
+type Windows = Map;
+
+/** A place in the note held on to across a redraw, so the scroll can follow. */
+type Pin = { position: number; top: number };
+
+/** Where rendering starts and stops, in screen coordinates. */
+type Edges = {
+ addTop: number;
+ addBottom: number;
+ keepTop: number;
+ keepBottom: number;
+};
+
const EMPTY_VISIBLE: Set = new Set();
+const EMPTY_WINDOWS: Windows = new Map();
const pending = new WeakMap void>();
const calibrations = new WeakMap void>();
@@ -61,6 +85,7 @@ const SHOW_MARGIN = 1;
const KEEP_MARGIN = 1.5;
const RENDER_ATTRS = {};
const RENDER_SPEC = { render: true };
+const WINDOWED_ATTRS = { [WINDOWED_ATTRIBUTE]: "true" };
export function findScrollParent(node: HTMLElement): HTMLElement | null {
let current: HTMLElement | null = node.parentElement;
@@ -114,34 +139,267 @@ function renderDecoration(from: number, to: number): Decoration {
return Decoration.node(from, to, RENDER_ATTRS, RENDER_SPEC);
}
+/** A run of children that is not rendered, and the space it has to hold. */
+type Gap = { position: number; height: number };
+
/**
- * Marks the pages that should be rendered. Only pages are marked: building a
- * decoration set walks the whole document once per decoration, so a decoration
- * nothing reads is not free.
+ * Marks the children of one long container that should be rendered, and
+ * reports the runs that are not.
+ *
+ * Two kinds of child are kept whatever the window says: the ones the selection
+ * begins and ends in, so the caret always has somewhere to sit, and the ones
+ * holding each column's widest cell, so the columns do not resize as the reader
+ * scrolls. Either can fall outside the window, which is why the runs left out
+ * are gaps rather than simply one above and one below.
+ */
+function decorateContainerChildren(
+ container: ProsemirrorNode,
+ window: ChildWindow,
+ selection: Selection,
+ index: HeightIndex,
+ decorations: Decoration[]
+): { start: number; end: number; gaps: Gap[] } {
+ const count = container.childCount;
+ const from = Math.max(0, Math.min(window.from, count));
+ const to = Math.max(from, Math.min(window.to, count));
+ const kept = widestChildren(container);
+ const held = [selection.from, selection.to].filter(
+ (position) =>
+ position > window.containerStart && position < window.containerEnd
+ );
+ const last = kept.length ? kept[kept.length - 1] : -1;
+
+ const gaps: Gap[] = [];
+ let gapFrom = -1;
+ let gapAt = 0;
+ let offset = window.containerStart + 1;
+ let start = offset;
+ let end = offset;
+
+ for (let i = 0; i < count; i++) {
+ if (i >= to && i > last && !held.length) {
+ const at = gapFrom >= 0 ? gapFrom : i;
+ gaps.push({
+ position: gapFrom >= 0 ? gapAt : offset,
+ height: index.total - index.before[at]
+ });
+ gapFrom = -1;
+ break;
+ }
+
+ const size = container.child(i).nodeSize;
+ const holdsSelection = held.some(
+ (position) => position >= offset && position < offset + size
+ );
+ const rendered =
+ (i >= from && i < to) || holdsSelection || kept.includes(i);
+
+ if (rendered) {
+ if (gapFrom >= 0) {
+ gaps.push({
+ position: gapAt,
+ height: index.before[i] - index.before[gapFrom]
+ });
+ gapFrom = -1;
+ }
+ decorations.push(renderDecoration(offset, offset + size));
+ } else if (gapFrom < 0) {
+ gapFrom = i;
+ gapAt = offset;
+ }
+
+ if (i === from) start = offset;
+ if (i < to) end = offset + size;
+ offset += size;
+ }
+
+ if (gapFrom >= 0)
+ gaps.push({ position: gapAt, height: index.total - index.before[gapFrom] });
+
+ profiler.gauge("paging.renderedChildren", to - from + kept.length);
+ return { start, end, gaps };
+}
+
+/**
+ * Marks the pages that should be rendered, and inside each of those, the part
+ * of any long container that should be rendered. Nothing else is marked:
+ * building a decoration set walks the whole document once per decoration, so a
+ * decoration nothing reads is not free.
+ *
+ * Windows for containers on screen for the first time are seeded here rather
+ * than left empty, so a long container is never rendered whole while it waits
+ * to be measured.
*/
function buildDecorations(
doc: ProsemirrorNode,
visible: Set,
- selectionIndex: number
-): DecorationSet {
+ known: Windows,
+ expanded: boolean,
+ selection: Selection,
+ heights: HeightMap
+): { decorations: DecorationSet; windows: Windows } {
const end = profiler.start("paging.decorations");
const decorations: Decoration[] = [];
+ const windows: Windows = new Map();
+ const selectionIndex = selection.$from.index(0);
const lastIndex = doc.childCount - 1;
+ let renderedPages = 0;
let index = -1;
- doc.forEach((node, offset) => {
+ doc.forEach((block, offset) => {
index++;
- if (node.type.name !== PAGE_NODE) return;
- if (!shouldRender(node, index, lastIndex, visible, selectionIndex)) return;
- decorations.push(renderDecoration(offset, offset + node.nodeSize));
+ if (block.type.name === PAGE_NODE) {
+ if (!shouldRender(block, index, lastIndex, visible, selectionIndex))
+ return;
+ decorations.push(renderDecoration(offset, offset + block.nodeSize));
+ renderedPages++;
+ }
+
+ for (const container of containersWorthWindowing(block)) {
+ const containerStart = offset + container.offset;
+ const previous = expanded ? undefined : known.get(container.id);
+ const window: ChildWindow = {
+ containerStart,
+ containerEnd: containerStart + container.node.nodeSize,
+ from: previous?.from ?? 0,
+ to: expanded
+ ? container.node.childCount
+ : previous?.to ?? CHILDREN_BEFORE_MEASURING,
+ childCount: container.node.childCount,
+ renderedStart: containerStart,
+ renderedEnd: containerStart + container.node.nodeSize
+ };
+ windows.set(container.id, window);
+ decorations.push(
+ Decoration.node(containerStart, window.containerEnd, WINDOWED_ATTRS)
+ );
+ const rendered = decorateContainerChildren(
+ container.node,
+ window,
+ selection,
+ heightIndexFor(container.id, container.node, heights),
+ decorations
+ );
+ window.renderedStart = rendered.start;
+ window.renderedEnd = rendered.end;
+ addSpacers(container.node, rendered.gaps, decorations);
+ }
});
const set = DecorationSet.create(doc, decorations);
end();
profiler.count("paging.decorationBuilds");
- profiler.gauge("paging.renderedPages", decorations.length);
+ profiler.gauge("paging.renderedPages", renderedPages);
profiler.gauge("paging.pagesInDoc", doc.childCount);
- return set;
+ profiler.gauge("paging.windowedContainers", windows.size);
+ return { decorations: set, windows };
+}
+
+function mapWindows(windows: Windows, mapping: Mapping): Windows {
+ if (!windows.size) return windows;
+ const mapped: Windows = new Map();
+ for (const [id, window] of windows)
+ mapped.set(id, {
+ ...window,
+ containerStart: mapping.map(window.containerStart),
+ containerEnd: mapping.map(window.containerEnd),
+ renderedStart: mapping.map(window.renderedStart),
+ renderedEnd: mapping.map(window.renderedEnd)
+ });
+ return mapped;
+}
+
+/**
+ * Whether the mapped decorations still describe the note. Mapping carries a
+ * window's decorations along with the text but cannot invent one, so a
+ * container that gained or lost a child, or a caret that landed on a child with
+ * no decoration of its own, needs them built again. Ordinary typing does not,
+ * which matters: building them walks every child of every windowed container,
+ * and there may be twenty thousand of those.
+ */
+function mappedDecorationsHold(
+ doc: ProsemirrorNode,
+ windows: Windows,
+ decorations: DecorationSet,
+ selection: Selection
+): boolean {
+ for (const window of windows.values()) {
+ const container = doc.nodeAt(window.containerStart);
+ if (!container || container.childCount !== window.childCount) return false;
+
+ const caret = selection.from;
+ if (caret <= window.containerStart || caret >= window.containerEnd)
+ continue;
+ const covered = decorations
+ .find(caret, caret)
+ .some(
+ (decoration) =>
+ decoration.from > window.containerStart &&
+ decoration.to < window.containerEnd &&
+ (decoration.spec as { render?: boolean })?.render
+ );
+ if (!covered) return false;
+ }
+ return true;
+}
+
+/**
+ * One empty element standing in for a whole run of hidden children, sized to
+ * the height they would have taken. The hidden children have no layout box at
+ * all, so without this the container would collapse to the part on screen and
+ * the scrollbar would lie about how long the note is.
+ */
+function spacer(tag: string, height: number, columns: number): HTMLElement {
+ const dom = document.createElement(tag);
+ dom.setAttribute("data-virtual-spacer", "true");
+ const box =
+ tag === "tr" ? dom.appendChild(document.createElement("td")) : dom;
+ if (box !== dom) (box as HTMLTableCellElement).colSpan = columns;
+ box.style.height = `${Math.round(height)}px`;
+ box.style.padding = "0";
+ box.style.border = "none";
+ return dom;
+}
+
+function columnCount(node: ProsemirrorNode): number {
+ let total = 0;
+ node.forEach((cell) => (total += Number(cell.attrs.colspan) || 1));
+ return total || 1;
+}
+
+function addSpacers(
+ container: ProsemirrorNode,
+ gaps: Gap[],
+ decorations: Decoration[]
+): void {
+ const first = container.firstChild;
+ if (!first || !gaps.length) return;
+
+ const spec = first.type.spec.toDOM?.(first);
+ const tag =
+ Array.isArray(spec) && typeof spec[0] === "string" ? spec[0] : "div";
+ const columns = tag === "tr" ? columnCount(first) : 1;
+
+ for (const gap of gaps) {
+ if (gap.height <= 0) continue;
+ decorations.push(
+ Decoration.widget(gap.position, () => spacer(tag, gap.height, columns), {
+ side: -1,
+ key: `spacer:${Math.round(gap.height)}`
+ })
+ );
+ }
+ profiler.gauge("paging.spacers", gaps.length);
+}
+
+function sameWindows(a: Windows, b: Windows): boolean {
+ if (a.size !== b.size) return false;
+ for (const [id, window] of a) {
+ const other = b.get(id);
+ if (!other || other.from !== window.from || other.to !== window.to)
+ return false;
+ }
+ return true;
}
/**
@@ -216,56 +474,101 @@ export function viewportPlugin(heights: HeightMap): Plugin {
state: {
init(_config, state) {
const selectionIndex = state.selection.$from.index(0);
+ const built = buildDecorations(
+ state.doc,
+ EMPTY_VISIBLE,
+ EMPTY_WINDOWS,
+ false,
+ state.selection,
+ heights
+ );
return {
visible: EMPTY_VISIBLE,
+ windows: built.windows,
+ expanded: false,
selectionIndex,
pageCount: state.doc.childCount,
- decorations: buildDecorations(
- state.doc,
- EMPTY_VISIBLE,
- selectionIndex
- )
+ decorations: built.decorations
};
},
apply(tr, value, _oldState, newState) {
const meta = tr.getMeta(viewportKey) as
- | { visible: Set }
+ | { visible: Set; windows: Windows; expanded: boolean }
| undefined;
const visible = meta?.visible ?? value.visible;
+ const known = meta?.windows ?? value.windows;
+ const expanded = meta?.expanded ?? value.expanded;
const selectionIndex = newState.selection.$from.index(0);
const pageCount = tr.doc.childCount;
const selectionMoved = selectionIndex !== value.selectionIndex;
const pagesChanged = pageCount !== value.pageCount;
+ // Mapping moves a window's decorations but cannot invent one for a row
+ // that was just pasted in, so a note holding a windowed container is
+ // rebuilt rather than mapped.
+ const windowed = known.size > 0;
- if (!meta && !selectionMoved && !pagesChanged && !tr.docChanged) {
+ if (
+ !meta &&
+ !selectionMoved &&
+ !pagesChanged &&
+ !tr.docChanged &&
+ !(windowed && tr.selectionSet)
+ ) {
profiler.count("paging.decorationReuses");
return value;
}
- if (meta || selectionMoved || pagesChanged) {
- return {
- visible,
- selectionIndex,
- pageCount,
- decorations: buildDecorations(tr.doc, visible, selectionIndex)
- };
+ if (!meta && !selectionMoved && !pagesChanged) {
+ const end = profiler.start("paging.decorationMap");
+ const windows = mapWindows(known, tr.mapping);
+ const mapped = repairSelection(
+ value.decorations.map(tr.mapping, tr.doc),
+ newState
+ );
+ end();
+ if (
+ !windowed ||
+ mappedDecorationsHold(tr.doc, windows, mapped, newState.selection)
+ ) {
+ profiler.count("paging.decorationMaps");
+ return {
+ visible,
+ windows,
+ expanded,
+ selectionIndex,
+ pageCount,
+ decorations: mapped
+ };
+ }
}
- const end = profiler.start("paging.decorationMap");
- const mapped = repairSelection(
- value.decorations.map(tr.mapping, tr.doc),
- newState
+ const built = buildDecorations(
+ tr.doc,
+ visible,
+ known,
+ expanded,
+ newState.selection,
+ heights
);
- end();
- profiler.count("paging.decorationMaps");
-
- return { visible, selectionIndex, pageCount, decorations: mapped };
+ return {
+ visible,
+ windows: built.windows,
+ expanded,
+ selectionIndex,
+ pageCount,
+ decorations: built.decorations
+ };
}
},
props: {
decorations(state) {
return viewportKey.getState(state)?.decorations;
+ },
+ // Types that draw themselves opt in with `virtualizable` instead.
+ nodeViews: {
+ [TABLE_ROW_NODE]: containerChildView,
+ [LIST_ITEM_NODE]: containerChildView
}
},
view(editorView) {
@@ -287,10 +590,11 @@ export function viewportPlugin(heights: HeightMap): Plugin {
};
/**
- * The first page reaching down to `y`. Pages are stacked, so their edges
- * only ever increase and can be searched by halving instead of scanning.
+ * The first child reaching down to `y`. Children are stacked, so their
+ * edges only ever increase and can be searched by halving instead of
+ * scanning.
*/
- const firstPageBelow = (children: HTMLCollection, y: number): number => {
+ const firstReaching = (children: HTMLCollection, y: number): number => {
let low = 0;
let high = children.length - 1;
let result = children.length - 1;
@@ -307,12 +611,100 @@ export function viewportPlugin(heights: HeightMap): Plugin {
return result;
};
+ const windowsNow = (): Iterable =>
+ viewportKey.getState(editorView.state)?.windows.values() ?? [];
+
+ /** The element a container's children are laid out in. */
+ const childHost = (containerStart: number): HTMLElement | null => {
+ const first = editorView.nodeDOM(containerStart + 1);
+ return first instanceof Element ? first.parentElement : null;
+ };
+
+ /**
+ * The stretch of a container's children to render, worked out from where
+ * the container starts on screen and how tall its children are.
+ *
+ * The children that are off screen are hidden, so the DOM cannot say
+ * where they are. Adding up their heights answers the same question,
+ * costs one rectangle rather than one per child, and holds on to children
+ * already rendered until they pass the wider margin, so one on the edge
+ * cannot flicker.
+ */
+ /**
+ * The stretch of a container's children to render, worked out from where
+ * the container starts on screen and how tall its children are.
+ *
+ * The children that are off screen are hidden, so the DOM cannot say
+ * where they are. Adding up their heights answers the same question,
+ * costs one rectangle rather than one per child, and holds on to children
+ * already rendered until they pass the wider margin, so one on the edge
+ * cannot flicker. A container with nothing drawn keeps the range it had.
+ */
+ const visibleChildRange = (
+ container: WindowedContainer,
+ containerStart: number,
+ previous: ChildWindow | undefined,
+ edges: Edges
+ ): { from: number; to: number } => {
+ const kept = previous ?? { from: 0, to: CHILDREN_BEFORE_MEASURING };
+ const host = container.node.childCount
+ ? childHost(containerStart)
+ : null;
+ if (!host) {
+ profiler.count("paging.containerNotDrawn");
+ return kept;
+ }
+ profiler.count("paging.containersMeasured");
+
+ const top = host.getBoundingClientRect().top;
+ const index = heightIndexFor(container.id, container.node, heights);
+ const count = container.node.childCount;
+ const at = (edge: number) =>
+ Math.min(count, childAt(index, edge - top));
+
+ const from = at(edges.addTop);
+ const to = Math.min(count, at(edges.addBottom) + 1);
+ if (!previous) return { from, to };
+ return {
+ from: Math.min(from, Math.max(previous.from, at(edges.keepTop))),
+ to: Math.max(to, Math.min(previous.to, at(edges.keepBottom) + 1))
+ };
+ };
+
+ const measureContainerWindows = (
+ edges: Edges,
+ known: Windows,
+ windows: Windows
+ ): void => {
+ let blockStart = 0;
+ editorView.state.doc.forEach((block) => {
+ const start = blockStart;
+ blockStart += block.nodeSize;
+ for (const container of containersWorthWindowing(block)) {
+ const containerStart = start + container.offset;
+ const containerEnd = containerStart + container.node.nodeSize;
+ const previous = known.get(container.id);
+ windows.set(container.id, {
+ containerStart,
+ containerEnd,
+ childCount: container.node.childCount,
+ renderedStart: previous?.renderedStart ?? containerStart,
+ renderedEnd: previous?.renderedEnd ?? containerEnd,
+ ...visibleChildRange(container, containerStart, previous, edges)
+ });
+ }
+ });
+ };
+
/**
* Pages start rendering one screen before they come into view and stop
* half a screen after they leave, so a page sitting on the edge cannot
- * flicker on and off every frame.
+ * flicker on and off every frame. The same margins pick the children of
+ * any long container the visible pages hold.
*/
- const measure = (): Set | undefined => {
+ const measure = ():
+ | { visible: Set; windows: Windows }
+ | undefined => {
const children = editorView.dom.children;
if (!children.length) return undefined;
if (!editorView.dom.getBoundingClientRect().height) return undefined;
@@ -324,64 +716,137 @@ export function viewportPlugin(heights: HeightMap): Plugin {
const height = container ? container.clientHeight : bounds.height;
if (!height) return undefined;
- const addTop = bounds.top - height * SHOW_MARGIN;
- const addBottom = bounds.top + height * (1 + SHOW_MARGIN);
- const keepTop = bounds.top - height * KEEP_MARGIN;
- const keepBottom = bounds.top + height * (1 + KEEP_MARGIN);
+ const edges: Edges = {
+ addTop: bounds.top - height * SHOW_MARGIN,
+ addBottom: bounds.top + height * (1 + SHOW_MARGIN),
+ keepTop: bounds.top - height * KEEP_MARGIN,
+ keepBottom: bounds.top + height * (1 + KEEP_MARGIN)
+ };
+
+ const state = viewportKey.getState(editorView.state);
+ const shown = state?.visible;
+ const known = state?.windows ?? EMPTY_WINDOWS;
+ const visible = new Set();
+ const windows: Windows = new Map(known);
+ measureContainerWindows(edges, known, windows);
- const shown = viewportKey.getState(editorView.state)?.visible;
- const next = new Set();
for (
- let i = firstPageBelow(children, keepTop);
+ let i = firstReaching(children, edges.keepTop);
i < children.length;
i++
) {
const element = children[i] as HTMLElement;
const rect = element.getBoundingClientRect();
- if (rect.top > keepBottom) break;
+ if (rect.top > edges.keepBottom) break;
const pageId = element.getAttribute("data-block-id");
if (!pageId) continue;
if (
- (rect.bottom >= addTop && rect.top <= addBottom) ||
+ (rect.bottom >= edges.addTop && rect.top <= edges.addBottom) ||
shown?.has(pageId)
)
- next.add(pageId);
+ visible.add(pageId);
}
- return next;
+ return { visible, windows };
};
/**
- * The top page on screen, remembered by position so it can be found again
- * after the pages are redrawn.
+ * The element at the top of the viewport, remembered by its place among
+ * its siblings so it can be found again after the note is redrawn. The
+ * collection is live, so a stand-in that becomes a real row is still the
+ * same entry.
*/
- const pinnedPage = (): { index: number; top: number } | undefined => {
- if (!scrollParent) return undefined;
- const children = editorView.dom.children;
- const fold = scrollParent.getBoundingClientRect().top;
- for (let i = firstPageBelow(children, fold); i < children.length; i++) {
- const rect = (children[i] as HTMLElement).getBoundingClientRect();
- if (rect.bottom > fold) return { index: i, top: rect.top };
+ /**
+ * The first element at or below the fold, remembered by the position of
+ * the node it draws.
+ *
+ * A position is the only stable handle here: a stand-in that becomes a
+ * real row is a different element, and a spacer appearing ahead of a row
+ * moves it along its parent. Neither moves the document.
+ */
+ const pinFrom = (
+ first: Element | null,
+ fold: number
+ ): Pin | undefined => {
+ let element = first;
+ while (element instanceof HTMLElement) {
+ if (!element.hasAttribute("data-virtual-spacer")) {
+ const rect = element.getBoundingClientRect();
+ if (rect.bottom > fold)
+ return {
+ position: editorView.posAtDOM(element, 0) - 1,
+ top: rect.top
+ };
+ }
+ element = element.nextElementSibling;
}
return undefined;
};
/**
- * An empty page is only a guess at its real height, so a page that
- * renders changes size and shoves everything below it. Moving the scroll
- * by the same amount keeps the reader looking at the same place.
+ * What to hold on to across a redraw: the top block on screen, or the row
+ * at the top if the fold falls inside a windowed container. A note that is
+ * one long table has only ever one block, whose top never moves, so
+ * pinning that alone would let every row under the reader slide.
*/
- const restorePin = (pin?: { index: number; top: number }) => {
+ const pinAtFold = (): Pin | undefined => {
+ if (!scrollParent) return undefined;
+ const fold = scrollParent.getBoundingClientRect().top;
+ const blocks = editorView.dom.children;
+ let pin = pinFrom(blocks[firstReaching(blocks, fold)] ?? null, fold);
+
+ for (const window of windowsNow()) {
+ const host = childHost(window.containerStart);
+ if (!host) continue;
+ const rect = host.getBoundingClientRect();
+ if (rect.top > fold || rect.bottom < fold) continue;
+ const first = editorView.nodeDOM(window.renderedStart);
+ pin = pinFrom(first instanceof Element ? first : null, fold) ?? pin;
+ }
+ return pin;
+ };
+
+ /**
+ * A stand-in is only a guess at the height of what it replaces, so a row
+ * or page that renders changes size and shoves everything below it.
+ * Moving the scroll by the same amount keeps the reader looking at the
+ * same place.
+ */
+ const restorePin = (pin?: Pin) => {
if (!pin || !scrollParent) return;
- const element = editorView.dom.children[pin.index] as
- | HTMLElement
- | undefined;
- if (!element) return;
+ const element = editorView.nodeDOM(pin.position);
+ if (!(element instanceof HTMLElement)) return;
+ if (element.hasAttribute("data-virtual-child")) return;
const delta = element.getBoundingClientRect().top - pin.top;
if (!delta) return;
scrollParent.scrollTop += delta;
profiler.record("paging.pinCorrection", Math.abs(delta));
};
+ /**
+ * What a rendered child actually measures, so the run it belongs to is
+ * held by a spacer of the right height once it scrolls away.
+ *
+ * The children in the window are next to each other with nothing between
+ * them, so one lookup and then siblings is enough -- and unlike counting
+ * elements, it is not thrown off by the spacers.
+ */
+ const measureRenderedChildren = () => {
+ const doc = editorView.state.doc;
+ for (const window of windowsNow()) {
+ const container = doc.nodeAt(window.containerStart);
+ if (!container) continue;
+
+ let element = editorView.nodeDOM(window.renderedStart);
+ const to = Math.min(window.to, container.childCount);
+ for (let i = Math.max(0, window.from); i < to; i++) {
+ if (!(element instanceof HTMLElement)) break;
+ if (!element.hasAttribute("data-virtual-child"))
+ heights.record(container.child(i), element.offsetHeight);
+ element = element.nextElementSibling;
+ }
+ }
+ };
+
const flush = () => {
frame = 0;
if (printing) return;
@@ -392,25 +857,30 @@ export function viewportPlugin(heights: HeightMap): Plugin {
profiler.count("paging.measures");
if (!next) return;
- const current = viewportKey.getState(editorView.state)?.visible;
- if (current && sameSet(current, next)) {
+ const current = viewportKey.getState(editorView.state);
+ if (
+ current &&
+ sameSet(current.visible, next.visible) &&
+ sameWindows(current.windows, next.windows)
+ ) {
profiler.count("paging.measuresUnchanged");
return;
}
profiler.count("paging.visibilityFlushes");
- profiler.gauge("paging.visiblePages", next.size);
+ profiler.gauge("paging.visiblePages", next.visible.size);
- const pin = pinnedPage();
+ const pin = pinAtFold();
editorView.dispatch(
editorView.state.tr
- .setMeta(viewportKey, { visible: next })
+ .setMeta(viewportKey, { ...next, expanded: false })
.setMeta("preventUpdate", true)
.setMeta("addToHistory", false)
);
restorePin(pin);
updateMetrics();
measureRenderedPages();
+ measureRenderedChildren();
resizePlaceholders();
};
@@ -421,7 +891,7 @@ export function viewportPlugin(heights: HeightMap): Plugin {
*/
const resizePlaceholders = () => {
if (!heights.placeholdersNeedResizing) return;
- const pin = pinnedPage();
+ const pin = pinAtFold();
const children = editorView.dom.children;
const doc = editorView.state.doc;
const count = Math.min(children.length, doc.childCount);
@@ -497,9 +967,14 @@ export function viewportPlugin(heights: HeightMap): Plugin {
const pageId = page.attrs.blockId as string | undefined;
if (pageId) everything.add(pageId);
});
+ const windows = viewportKey.getState(editorView.state)?.windows;
editorView.dispatch(
editorView.state.tr
- .setMeta(viewportKey, { visible: everything })
+ .setMeta(viewportKey, {
+ visible: everything,
+ windows: windows ?? EMPTY_WINDOWS,
+ expanded: true
+ })
.setMeta("preventUpdate", true)
.setMeta("addToHistory", false)
);