From ab1a88edecd2e0bfd025e98567ee7742643fe831 Mon Sep 17 00:00:00 2001 From: Ammar Ahmed Date: Sat, 29 Aug 2026 11:55:26 +0500 Subject: [PATCH] editor: render only the container children that are on screen --- .../__tests__/nested-virtualization.test.ts | 512 ++++++++++++++ .../src/extensions/paging/child-view.ts | 167 +++++ .../src/extensions/paging/containers.ts | 140 ++++ .../src/extensions/paging/height-index.ts | 89 +++ .../src/extensions/paging/height-map.ts | 53 +- .../editor/src/extensions/paging/index.ts | 2 +- .../editor/src/extensions/paging/state.ts | 95 +++ .../src/extensions/paging/viewport-plugin.ts | 639 +++++++++++++++--- 8 files changed, 1604 insertions(+), 93 deletions(-) create mode 100644 packages/editor/src/extensions/paging/__tests__/nested-virtualization.test.ts create mode 100644 packages/editor/src/extensions/paging/child-view.ts create mode 100644 packages/editor/src/extensions/paging/containers.ts create mode 100644 packages/editor/src/extensions/paging/height-index.ts create mode 100644 packages/editor/src/extensions/paging/state.ts 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 = `"; +} + +function taskListOf(items: number) { + let html = `"; +} + +function outlineListOf(items: number) { + let html = `"; +} + +function tableOf(rows: number) { + let html = ``; + for (let i = 0; i < rows; i++) + html += ``; + return html + "

Cell ${i}

Value ${i}

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