editor: page the document while parsing instead of after the first render

This commit is contained in:
Ammar Ahmed
2026-08-25 20:35:07 +05:00
parent f4ffa44a69
commit f41c231e69
5 changed files with 221 additions and 3 deletions

View File

@@ -21,7 +21,14 @@ import { describe, expect, test } from "vitest";
import { Editor, getHTMLFromFragment } from "@tiptap/core";
import StarterKit from "@tiptap/starter-kit";
import { Node } from "@tiptap/core";
import { Page, Paging, countPages, flattenBlocks } from "../index.js";
import {
Page,
Paging,
countPages,
flattenBlocks,
fromFlatPosition,
toFlatPosition
} from "../index.js";
import { BlockId } from "../../block-id/block-id.js";
import { getTableOfContents } from "../../../utils/toc.js";
@@ -63,6 +70,64 @@ async function created() {
await new Promise((resolve) => setTimeout(resolve, 0));
}
describe("paging positions", () => {
test("flat positions round-trip through a paged document", async () => {
const editor = createEditor(savedNoteHTML(BLOCKS));
await created();
expect(countPages(editor.state.doc)).toBe(3);
// Positions on a page boundary are ambiguous by nature -- the gap between
// two pages is a single position once the pages are gone. Carets only ever
// sit inside a textblock, and those must survive exactly.
const doc = editor.state.doc;
let checked = 0;
for (let pos = 1; pos < doc.content.size; pos++) {
if (!doc.resolve(pos).parent.isTextblock) continue;
const flat = toFlatPosition(doc, pos);
expect(fromFlatPosition(doc, flat)).toBe(pos);
checked++;
}
expect(checked).toBeGreaterThan(1000);
editor.destroy();
});
test("a position saved unpaged resolves to the same block when paged", async () => {
const flatEditor = createEditor(savedNoteHTML(BLOCKS), false);
await created();
expect(countPages(flatEditor.state.doc)).toBe(0);
// caret inside block 120 of the unpaged document
let target = 0;
for (let i = 0; i < 120; i++)
target += flatEditor.state.doc.child(i).nodeSize;
target += 3;
const saved = toFlatPosition(flatEditor.state.doc, target);
expect(saved).toBe(target);
const text = flatEditor.state.doc.child(120).textContent;
flatEditor.destroy();
const pagedEditor = createEditor(savedNoteHTML(BLOCKS));
await created();
const restored = fromFlatPosition(pagedEditor.state.doc, saved);
expect(pagedEditor.state.doc.resolve(restored).parent.textContent).toBe(
text
);
pagedEditor.destroy();
});
test("conversions are the identity without pages", async () => {
const editor = createEditor(savedNoteHTML(20), false);
await created();
const doc = editor.state.doc;
for (let pos = 0; pos <= doc.content.size; pos += 5) {
expect(toFlatPosition(doc, pos)).toBe(pos);
expect(fromFlatPosition(doc, pos)).toBe(pos);
}
editor.destroy();
});
});
describe("paging", () => {
test("groups blocks into pages once the note is opened", async () => {
const editor = createEditor(savedNoteHTML(BLOCKS));
@@ -75,6 +140,16 @@ describe("paging", () => {
editor.destroy();
});
test("pages exist before the first render, not after it", () => {
const editor = createEditor(savedNoteHTML(BLOCKS));
// no `await created()`: the parser pages the document, so the very first
// view already renders pages instead of 250 blocks.
expect(countPages(editor.state.doc)).toBe(3);
expect(editor.view.dom.children).toHaveLength(3);
editor.destroy();
});
test("keeps every block, in order", async () => {
const editor = createEditor(savedNoteHTML(BLOCKS));
await created();

View File

@@ -20,6 +20,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
import { Extension } from "@tiptap/core";
import { profiler } from "../../utils/profiler.js";
import { installFlatteningSerializer } from "./serializer.js";
import { installPagingParser } from "./parser.js";
import { DEFAULT_PAGE_SIZE, countPages, toPages } from "./split.js";
export type PagingOptions = {
@@ -39,12 +40,19 @@ export const Paging = Extension.create<PagingOptions>({
};
},
// Runs before the first view exists. Installing the serializer here means no
// code path can serialize a paged document before pages are stripped.
// Runs before the first view exists, so the document arrives already paged
// and is never rendered flat.
onBeforeCreate() {
installFlatteningSerializer(this.editor.schema);
if (!this.options.enabled) return;
installPagingParser(this.editor.schema, {
pageSize: this.options.pageSize,
thresholdBlocks: this.options.thresholdBlocks
});
},
// Content that arrives as JSON bypasses the parser, so this stays as a
// fallback for documents the parser never saw.
onCreate() {
if (!this.options.enabled) return;
const { editor } = this;
@@ -77,3 +85,5 @@ export {
toPages
} from "./split.js";
export { installFlatteningSerializer } from "./serializer.js";
export { installPagingParser, uninstallPagingParser } from "./parser.js";
export { fromFlatPosition, toFlatPosition } from "./positions.js";

View File

@@ -0,0 +1,68 @@
/*
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 { DOMParser, Node as ProsemirrorNode, Schema } from "@tiptap/pm/model";
import { toPages } from "./split.js";
export type PagingParserOptions = {
pageSize: number;
thresholdBlocks: number;
};
/**
* Pages the document as it is parsed, so the very first view already renders
* pages. Splitting after the editor exists would mean rendering the whole
* document flat once and then again as pages.
*
* Only whole documents are paged. `parseSlice` — used for pasting, dropping and
* `insertContent` — is left alone so inserted content joins the page it lands
* in rather than becoming a page of its own.
*/
class PagingDOMParser extends DOMParser {
options: PagingParserOptions = { pageSize: 0, thresholdBlocks: 0 };
parse(dom: Node, options?: Parameters<DOMParser["parse"]>[1]) {
const doc = super.parse(dom, options) as ProsemirrorNode;
if (doc.childCount <= this.options.thresholdBlocks) return doc;
return doc.type.create(
doc.attrs,
toPages(doc, this.schema, this.options.pageSize),
doc.marks
);
}
}
export function installPagingParser(
schema: Schema,
options: PagingParserOptions
): void {
const cached = schema.cached as { domParser?: DOMParser };
if (!(cached.domParser instanceof PagingDOMParser)) {
// `fromSchema` seeds the default parser; reuse its rules so the paging
// parser behaves identically apart from the wrapping.
const base = DOMParser.fromSchema(schema);
cached.domParser = new PagingDOMParser(schema, base.rules);
}
(cached.domParser as PagingDOMParser).options = options;
}
export function uninstallPagingParser(schema: Schema): void {
const cached = schema.cached as { domParser?: DOMParser };
if (cached.domParser instanceof PagingDOMParser) delete cached.domParser;
}

View File

@@ -0,0 +1,64 @@
/*
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 { isPage } from "./split.js";
/**
* Positions are persisted (restored selections, saved scroll targets) and must
* survive a note being paged differently — or not at all — next time it is
* opened. Both conversions are the identity for an unpaged document.
*
* Positions inside a textblock convert exactly in both directions. A position
* on a page boundary does not: the gap between two pages collapses to a single
* position once the wrappers are gone, and converting back lands inside the
* following page.
*/
export function toFlatPosition(doc: ProsemirrorNode, pos: number): number {
let flat = 0;
let at = 0;
for (let i = 0; i < doc.childCount; i++) {
const child = doc.child(i);
const size = child.nodeSize;
if (pos < at + size) {
if (!isPage(child)) return flat + (pos - at);
const inner = pos - at - 1;
return flat + Math.max(0, Math.min(inner, child.content.size));
}
flat += isPage(child) ? child.content.size : size;
at += size;
}
return flat;
}
export function fromFlatPosition(doc: ProsemirrorNode, flat: number): number {
if (flat <= 0) return 0;
let seen = 0;
let at = 0;
for (let i = 0; i < doc.childCount; i++) {
const child = doc.child(i);
const size = child.nodeSize;
const flatSize = isPage(child) ? child.content.size : size;
if (flat < seen + flatSize)
return isPage(child) ? at + 1 + (flat - seen) : at + (flat - seen);
seen += flatSize;
at += size;
}
return Math.min(doc.content.size, at);
}

View File

@@ -494,6 +494,7 @@ export * from "./utils/word-counter.js";
export * from "./utils/font.js";
export * from "./utils/toc.js";
export * from "./utils/profiler.js";
export { fromFlatPosition, toFlatPosition } from "./extensions/paging/index.js";
export * from "./utils/downloader.js";
export {
useTiptap,