diff --git a/packages/editor/src/extensions/paging/__tests__/paging.test.ts b/packages/editor/src/extensions/paging/__tests__/paging.test.ts
index 6027b7cbf..de5e6da13 100644
--- a/packages/editor/src/extensions/paging/__tests__/paging.test.ts
+++ b/packages/editor/src/extensions/paging/__tests__/paging.test.ts
@@ -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();
diff --git a/packages/editor/src/extensions/paging/index.ts b/packages/editor/src/extensions/paging/index.ts
index b6b312efa..c55846ca5 100644
--- a/packages/editor/src/extensions/paging/index.ts
+++ b/packages/editor/src/extensions/paging/index.ts
@@ -20,6 +20,7 @@ along with this program. If not, see .
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({
};
},
- // 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";
diff --git a/packages/editor/src/extensions/paging/parser.ts b/packages/editor/src/extensions/paging/parser.ts
new file mode 100644
index 000000000..66a8bec8a
--- /dev/null
+++ b/packages/editor/src/extensions/paging/parser.ts
@@ -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 .
+*/
+
+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[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;
+}
diff --git a/packages/editor/src/extensions/paging/positions.ts b/packages/editor/src/extensions/paging/positions.ts
new file mode 100644
index 000000000..2a1fd181a
--- /dev/null
+++ b/packages/editor/src/extensions/paging/positions.ts
@@ -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 .
+*/
+
+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);
+}
diff --git a/packages/editor/src/index.ts b/packages/editor/src/index.ts
index e4c520787..caf5a2438 100644
--- a/packages/editor/src/index.ts
+++ b/packages/editor/src/index.ts
@@ -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,