editor: window long containers however deeply they are nested

This commit is contained in:
Ammar Ahmed
2026-08-29 12:37:26 +05:00
parent 123709b0a5
commit 240220277e
2 changed files with 89 additions and 17 deletions

View File

@@ -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 + "</ul>";
}
/** 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 += `<li data-type="outlineListItem"><p>Point ${i}</p></li>`;
let html = `<ul data-type="outlineList">${inner}</ul>`;
for (let level = 0; level < levels; level++)
html = `<ul data-type="outlineList"${
level === levels - 1 ? ` data-block-id="${id()}"` : ""
}><li data-type="outlineListItem"><p>Level ${level}</p>${html}</li></ul>`;
return html;
}
function tableOf(rows: number) {
let html = `<table data-block-id="${id()}"><tbody>`;
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();

View File

@@ -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<WindowedContainer, "id">;
const containersByBlock = new WeakMap<ProsemirrorNode, WindowedContainer[]>();
const widestByTable = new WeakMap<ProsemirrorNode, number[]>();
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;