mirror of
https://github.com/streetwriters/notesnook.git
synced 2026-08-29 10:09:26 +02:00
editor: page the document while parsing instead of after the first render
This commit is contained in:
@@ -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();
|
||||
|
||||
@@ -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";
|
||||
|
||||
68
packages/editor/src/extensions/paging/parser.ts
Normal file
68
packages/editor/src/extensions/paging/parser.ts
Normal 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;
|
||||
}
|
||||
64
packages/editor/src/extensions/paging/positions.ts
Normal file
64
packages/editor/src/extensions/paging/positions.ts
Normal 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);
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user