diff --git a/packages/editor/src/extensions/paging/__tests__/nested-virtualization.test.ts b/packages/editor/src/extensions/paging/__tests__/nested-virtualization.test.ts
index c0322fcf2..c165c2f89 100644
--- a/packages/editor/src/extensions/paging/__tests__/nested-virtualization.test.ts
+++ b/packages/editor/src/extensions/paging/__tests__/nested-virtualization.test.ts
@@ -30,7 +30,7 @@ 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 { containersWorthWindowing, widestChildren } from "../containers.js";
import { countPages, Page, Paging } from "../index.js";
const PagedDocument = Node.create({
@@ -76,6 +76,19 @@ function outlineListOf(items: number) {
return html + "";
}
+/** A list buried `levels` deep, with the long one at the bottom. */
+function nestedOutlineOf(levels: number, items: number) {
+ let inner = "";
+ for (let i = 0; i < items; i++)
+ inner += `
Point ${i}
`;
+ let html = ``;
+ for (let level = 0; level < levels; level++)
+ html = ``;
+ return html;
+}
+
function tableOf(rows: number) {
let html = ``;
for (let i = 0; i < rows; i++)
@@ -531,6 +544,43 @@ describe("nested virtualization", () => {
two.destroy();
});
+ test("a long list buried under several levels is still windowed", async () => {
+ // outline and task lists nest as deep as the writer likes, and the long
+ // one can be at the bottom of that
+ const editor = createEditor(para("intro") + nestedOutlineOf(5, 300), 100);
+ await created();
+
+ expect(hidden(editor).length).toBeGreaterThan(200);
+ expect(editor.getHTML()).toContain("Point 299");
+ editor.destroy();
+ });
+
+ test("a nested container keeps its name when the text around it changes", async () => {
+ // the window is remembered against this name, so a name that moved with the
+ // text would throw the reader back to the top of the list on every keystroke
+ const editor = createEditor(para("intro") + nestedOutlineOf(5, 300), 100);
+ await created();
+
+ const block = () => editor.state.doc.child(1);
+ const before = containersWorthWindowing(block()).map((c) => c.id);
+ expect(before).toHaveLength(1);
+
+ // inside the same block and above the nested list, so anything naming it
+ // by where it sits would be renaming it on every keystroke
+ let at = -1;
+ const blockStart = editor.state.doc.child(0).nodeSize;
+ editor.state.doc.descendants((node, position) => {
+ if (at < 0 && position > blockStart && node.type.name === "paragraph")
+ at = position + 1;
+ return at < 0;
+ });
+ editor.commands.setTextSelection(at);
+ editor.commands.insertContent("typing above the list");
+
+ expect(containersWorthWindowing(block()).map((c) => c.id)).toEqual(before);
+ editor.destroy();
+ });
+
test("a stand-in child keeps the tag its container expects", async () => {
const editor = createEditor(para("intro") + listOf(200));
await created();
diff --git a/packages/editor/src/extensions/paging/containers.ts b/packages/editor/src/extensions/paging/containers.ts
index ec1514a2e..7d3258c6d 100644
--- a/packages/editor/src/extensions/paging/containers.ts
+++ b/packages/editor/src/extensions/paging/containers.ts
@@ -25,8 +25,10 @@ import { Node as ProsemirrorNode } from "@tiptap/pm/model";
* rendered whole.
*/
+const TABLE = "table";
+
const WINDOWABLE_CONTAINERS = new Set([
- "table",
+ TABLE,
"bulletList",
"orderedList",
"taskList",
@@ -39,10 +41,19 @@ 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;
+const MIN_CHILDREN = 50;
-/** How far into a block to look for containers worth windowing. */
-const MAX_DEPTH = 3;
+/**
+ * How far into a block to look for containers worth windowing.
+ *
+ * What keeps this walk cheap is not the limit but where it stops: at leaves, at
+ * text, and at any container long enough to window, whose children it never
+ * looks inside. The limit is only a guard against runaway recursion, so it is
+ * set well past anything a writer would type -- outline and task lists nest as
+ * deep as they like, and each level of nesting costs two steps here, one for
+ * the list and one for the item holding the next one.
+ */
+const MAX_DEPTH = 20;
/** How many children to render before anything has been measured. */
export const CHILDREN_BEFORE_MEASURING = 30;
@@ -51,9 +62,12 @@ export type WindowedContainer = {
node: ProsemirrorNode;
/** Where the container begins, counted from the start of the block. */
offset: number;
+ /** Stable across edits, so a window survives the text changing under it. */
id: string;
};
+type Found = Omit;
+
const containersByBlock = new WeakMap();
const widestByTable = new WeakMap();
const NO_CHILDREN: number[] = [];
@@ -68,15 +82,13 @@ function findContainers(
parent: ProsemirrorNode,
contentStart: number,
depth: number,
- found: WindowedContainer[]
+ found: Found[]
): 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)
+ if (isWorthWindowing(child)) found.push({ node: child, offset: start });
+ else if (depth < MAX_DEPTH)
findContainers(child, start + 1, depth + 1, found);
});
}
@@ -96,13 +108,23 @@ export function containersWorthWindowing(
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 });
+ // Only the blocks of a note are given ids of their own, so a container nested
+ // inside one has none to be named by. Counting them off within their block
+ // names them just as well, and just as steadily: editing the text around a
+ // container does not change how many come before it, so its window survives.
+ const blockId = block.attrs.blockId as string | undefined;
+ const found: Found[] = [];
+ if (blockId) {
+ if (isWorthWindowing(block)) found.push({ node: block, offset: 0 });
+ else findContainers(block, 1, 0, found);
+ }
- containersByBlock.set(block, found);
- return found;
+ const containers = found.map((container, index) => ({
+ ...container,
+ id: `${blockId}#${index}`
+ }));
+ containersByBlock.set(block, containers);
+ return containers;
}
/**
@@ -115,7 +137,7 @@ export function containersWorthWindowing(
* what the width follows.
*/
export function widestChildren(container: ProsemirrorNode): number[] {
- if (container.type.name !== "table") return NO_CHILDREN;
+ if (container.type.name !== TABLE) return NO_CHILDREN;
const cached = widestByTable.get(container);
if (cached) return cached;