editor: render only the container children that are on screen

This commit is contained in:
Ammar Ahmed
2026-08-29 11:55:26 +05:00
parent 4daeb33fa2
commit ab1a88edec
8 changed files with 1604 additions and 93 deletions

View File

@@ -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 <http://www.gnu.org/licenses/>.
*/
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 `<p data-block-id="${id()}">${text}</p>`;
}
function listOf(items: number) {
let html = `<ul data-block-id="${id()}">`;
for (let i = 0; i < items; i++) html += `<li><p>Item ${i}</p></li>`;
return html + "</ul>";
}
function taskListOf(items: number) {
let html = `<ul class="checklist" data-block-id="${id()}">`;
for (let i = 0; i < items; i++) html += `<li><p>Task ${i}</p></li>`;
return html + "</ul>";
}
function outlineListOf(items: number) {
let html = `<ul data-type="outlineList" data-block-id="${id()}">`;
for (let i = 0; i < items; i++)
html += `<li data-type="outlineListItem"><p>Point ${i}</p></li>`;
return html + "</ul>";
}
function tableOf(rows: number) {
let html = `<table data-block-id="${id()}"><tbody>`;
for (let i = 0; i < rows; i++)
html += `<tr><td><p>Cell ${i}</p></td><td><p>Value ${i}</p></td></tr>`;
return html + "</tbody></table>";
}
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<void>((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(/<li/g)).toHaveLength(200);
expect(html).toContain("Item 199");
expect(html).not.toContain("data-virtual-child");
editor.destroy();
});
test("the child holding the caret is rendered wherever it is", async () => {
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(/<tr/g)).toHaveLength(2000);
expect(html).toContain("Value 1999");
editor.destroy();
});
test("a stand-in row keeps the tag its container expects", async () => {
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();
});
});

View File

@@ -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 <http://www.gnu.org/licenses/>.
*/
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;
};
}

View File

@@ -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 <http://www.gnu.org/licenses/>.
*/
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<ProsemirrorNode, WindowedContainer[]>();
const widestByTable = new WeakMap<ProsemirrorNode, number[]>();
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;
}

View File

@@ -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 <http://www.gnu.org/licenses/>.
*/
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<string, Cached>();
/**
* 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;
}

View File

@@ -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<string, number>();
private sizes = new WeakMap<ProsemirrorNode, number>();
private childRevision = 0;
private measurements = new Map<string, Measured>();
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<string, number> {
return Object.fromEntries(this.measured);
}

View File

@@ -114,4 +114,4 @@ export {
isPage,
toPages
} from "./split.js";
export { viewportKey } from "./viewport-plugin.js";
export { viewportKey } from "./state.js";

View File

@@ -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 <http://www.gnu.org/licenses/>.
*/
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<string>;
windows: Map<string, ChildWindow>;
/** 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<ViewportState>("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;
}

View File

@@ -18,24 +18,48 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
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<ViewportState>("notesnook-paging");
type ViewportState = {
visible: Set<string>;
selectionIndex: number;
pageCount: number;
decorations: DecorationSet;
};
export { viewportKey } from "./state.js";
type PageRange = { from: number; to: number; index: number };
type Windows = Map<string, ChildWindow>;
/** 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<string> = new Set();
const EMPTY_WINDOWS: Windows = new Map();
const pending = new WeakMap<EditorView, () => void>();
const calibrations = new WeakMap<EditorView, () => 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<string>,
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<ViewportState> {
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<string> }
| { visible: Set<string>; 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<ViewportState> {
};
/**
* 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<ViewportState> {
return result;
};
const windowsNow = (): Iterable<ChildWindow> =>
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<string> | undefined => {
const measure = ():
| { visible: Set<string>; 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<ViewportState> {
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<string>();
const windows: Windows = new Map(known);
measureContainerWindows(edges, known, windows);
const shown = viewportKey.getState(editorView.state)?.visible;
const next = new Set<string>();
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<ViewportState> {
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<ViewportState> {
*/
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<ViewportState> {
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)
);