mirror of
https://github.com/streetwriters/notesnook.git
synced 2026-09-01 19:49:54 +02:00
editor: render only the container children that are on screen
This commit is contained in:
@@ -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();
|
||||
});
|
||||
});
|
||||
167
packages/editor/src/extensions/paging/child-view.ts
Normal file
167
packages/editor/src/extensions/paging/child-view.ts
Normal 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;
|
||||
};
|
||||
}
|
||||
140
packages/editor/src/extensions/paging/containers.ts
Normal file
140
packages/editor/src/extensions/paging/containers.ts
Normal 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;
|
||||
}
|
||||
89
packages/editor/src/extensions/paging/height-index.ts
Normal file
89
packages/editor/src/extensions/paging/height-index.ts
Normal 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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -114,4 +114,4 @@ export {
|
||||
isPage,
|
||||
toPages
|
||||
} from "./split.js";
|
||||
export { viewportKey } from "./viewport-plugin.js";
|
||||
export { viewportKey } from "./state.js";
|
||||
|
||||
95
packages/editor/src/extensions/paging/state.ts
Normal file
95
packages/editor/src/extensions/paging/state.ts
Normal 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;
|
||||
}
|
||||
@@ -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)
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user