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,