diff --git a/packages/editor/src/extensions/paging/__tests__/paged-virtualization.test.ts b/packages/editor/src/extensions/paging/__tests__/paged-virtualization.test.ts new file mode 100644 index 000000000..cb5e763f9 --- /dev/null +++ b/packages/editor/src/extensions/paging/__tests__/paged-virtualization.test.ts @@ -0,0 +1,128 @@ +/* +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 { describe, expect, test } from "vitest"; +import { Editor, Node } from "@tiptap/core"; +import StarterKit from "@tiptap/starter-kit"; +import { DecorationSet } from "@tiptap/pm/view"; +import { Page, Paging, countPages } from "../index.js"; +import { BlockId } from "../../block-id/block-id.js"; +import { + Virtualization, + virtualizationKey +} from "../../virtualization/index.js"; + +const PagedDocument = Node.create({ + name: "doc", + topNode: true, + content: "(page | block)+" +}); + +const BLOCKS = 1000; +const PAGE_SIZE = 100; + +function savedNoteHTML(n: number) { + let content = ""; + for (let i = 0; i < n; i++) + content += `

Paragraph number ${i}.

`; + return content; +} + +function createEditor() { + return new Editor({ + extensions: [ + StarterKit.configure({ document: false }), + PagedDocument, + Page, + BlockId, + Paging.configure({ + enabled: true, + pageSize: PAGE_SIZE, + thresholdBlocks: 10 + }), + Virtualization.configure({ enabled: true, unit: "pages" }) + ], + content: savedNoteHTML(BLOCKS) + }); +} + +async function created() { + await new Promise((resolve) => setTimeout(resolve, 0)); +} + +function decorations(editor: Editor): DecorationSet { + return ( + virtualizationKey.getState(editor.state)?.decorations ?? DecorationSet.empty + ); +} + +describe("paged virtualization", () => { + test("renders pages, not blocks, as the top-level elements", async () => { + const editor = createEditor(); + await created(); + + expect(countPages(editor.state.doc)).toBe(10); + expect(editor.view.dom.children).toHaveLength(10); + editor.destroy(); + }); + + test("keeps off-screen pages as placeholders", async () => { + const editor = createEditor(); + await created(); + + // pages 0 and 9 are edges and 0-1 hold the caret, so the rest are empty + const placeholders = editor.view.dom.querySelectorAll( + "[data-virtual-placeholder]" + ); + expect(placeholders).toHaveLength(7); + editor.destroy(); + }); + + test("decorates pages rather than every block", async () => { + const editor = createEditor(); + await created(); + + const spans = decorations(editor).find(0, editor.state.doc.content.size); + expect(spans).toHaveLength(3); + editor.destroy(); + }); + + test("a placeholder page keeps its blocks in the document", async () => { + const editor = createEditor(); + await created(); + + expect( + editor.view.dom.querySelectorAll("[data-virtual-placeholder]").length + ).toBeGreaterThan(0); + const html = editor.getHTML(); + expect(html.match(/

{ + const editor = createEditor(); + await created(); + + editor.commands.setTextSelection(editor.state.doc.content.size - 2); + const lastPage = editor.view.dom.children[9] as HTMLElement; + expect(lastPage.hasAttribute("data-virtual-placeholder")).toBe(false); + editor.destroy(); + }); +}); diff --git a/packages/editor/src/extensions/paging/__tests__/paging.test.ts b/packages/editor/src/extensions/paging/__tests__/paging.test.ts index 5a0f92cf2..6027b7cbf 100644 --- a/packages/editor/src/extensions/paging/__tests__/paging.test.ts +++ b/packages/editor/src/extensions/paging/__tests__/paging.test.ts @@ -23,6 +23,7 @@ import StarterKit from "@tiptap/starter-kit"; import { Node } from "@tiptap/core"; import { Page, Paging, countPages, flattenBlocks } from "../index.js"; import { BlockId } from "../../block-id/block-id.js"; +import { getTableOfContents } from "../../../utils/toc.js"; const PagedDocument = Node.create({ name: "doc", @@ -139,6 +140,43 @@ describe("paging", () => { editor.destroy(); }); + test("headings inside pages still reach the table of contents", async () => { + let content = ""; + for (let i = 0; i < BLOCKS; i++) + content += `

Heading ${i}

`; + const editor = createEditor(content); + await created(); + + expect(countPages(editor.state.doc)).toBe(3); + const toc = getTableOfContents( + editor.state.doc, + editor.view.dom as HTMLElement + ); + expect(toc).toHaveLength(BLOCKS); + expect(toc[0].title).toBe("Heading 0"); + editor.destroy(); + }); + + test("blocks inside pages keep getting block ids", async () => { + let content = ""; + for (let i = 0; i < BLOCKS; i++) content += `

Paragraph ${i}.

`; + const editor = createEditor(content); + await created(); + + editor.commands.setTextSelection(3); + editor.commands.insertContent("x"); + await created(); + + const page = editor.state.doc.child(0); + let withIds = 0; + page.forEach((node) => { + if (node.attrs.blockId) withIds++; + }); + expect(withIds).toBe(page.childCount); + expect(editor.state.doc.child(0).attrs.blockId).toBeTruthy(); + editor.destroy(); + }); + test("editing a paged document still serializes flat", async () => { const editor = createEditor(savedNoteHTML(BLOCKS)); await created(); diff --git a/packages/editor/src/extensions/paging/split.ts b/packages/editor/src/extensions/paging/split.ts index 9ec4d5a26..779bcb230 100644 --- a/packages/editor/src/extensions/paging/split.ts +++ b/packages/editor/src/extensions/paging/split.ts @@ -18,6 +18,7 @@ along with this program. If not, see . */ import { Fragment, Node as ProsemirrorNode, Schema } from "@tiptap/pm/model"; +import { nanoid } from "nanoid"; import { PAGE_NODE } from "./page.js"; /** Blocks are grouped into pages of this size when a note is opened. */ @@ -43,9 +44,18 @@ export function toPages( const blocks = flattenBlocks(doc); if (!blocks.length) return doc.content; + // Pages are identified at creation rather than by the block id plugin: the + // viewport plugin needs an id the moment a page exists, and one assigned a + // transaction later would leave every page permanently materialized. + const identify = "blockId" in (pageType.spec.attrs ?? {}); const pages: ProsemirrorNode[] = []; for (let i = 0; i < blocks.length; i += pageSize) - pages.push(pageType.create(null, blocks.slice(i, i + pageSize))); + pages.push( + pageType.create( + identify ? { blockId: nanoid(8) } : null, + blocks.slice(i, i + pageSize) + ) + ); return Fragment.fromArray(pages); } diff --git a/packages/editor/src/extensions/virtualization/height-map.ts b/packages/editor/src/extensions/virtualization/height-map.ts index bb6f3d17b..9eb7e2784 100644 --- a/packages/editor/src/extensions/virtualization/height-map.ts +++ b/packages/editor/src/extensions/virtualization/height-map.ts @@ -44,6 +44,11 @@ export class HeightMap { private measured = new Map(); estimate(node: ProsemirrorNode): number { + if (node.type.name === "page") { + let total = 0; + node.forEach((child) => (total += this.heightFor(child))); + return total || FALLBACK_ESTIMATE; + } return DEFAULT_ESTIMATES[node.type.name] ?? FALLBACK_ESTIMATE; } diff --git a/packages/editor/src/extensions/virtualization/index.ts b/packages/editor/src/extensions/virtualization/index.ts index eec9f391b..15bee60ce 100644 --- a/packages/editor/src/extensions/virtualization/index.ts +++ b/packages/editor/src/extensions/virtualization/index.ts @@ -19,7 +19,7 @@ along with this program. If not, see . import { Editor, Extension } from "@tiptap/core"; import { HeightMap } from "./height-map.js"; -import { withVirtualization } from "./node-views.js"; +import { VirtualizationUnit, withVirtualization } from "./node-views.js"; import { virtualizationPlugin } from "./viewport-plugin.js"; /** Paging only engages for notes larger than this many top-level blocks. */ @@ -28,11 +28,13 @@ const DEFAULT_THRESHOLD_BLOCKS = 300; export type VirtualizationOptions = { enabled: boolean; thresholdBlocks: number; + unit: VirtualizationUnit; }; export type VirtualizationStorage = { enabled: boolean; thresholdBlocks: number; + unit: VirtualizationUnit; heightMap: HeightMap; }; @@ -49,13 +51,18 @@ export const Virtualization = Extension.create({ name: "virtualization", addOptions() { - return { enabled: false, thresholdBlocks: DEFAULT_THRESHOLD_BLOCKS }; + return { + enabled: false, + thresholdBlocks: DEFAULT_THRESHOLD_BLOCKS, + unit: "blocks" + }; }, addStorage(): VirtualizationStorage { return { enabled: this.options.enabled, thresholdBlocks: this.options.thresholdBlocks, + unit: this.options.unit, heightMap: new HeightMap() }; }, @@ -70,7 +77,7 @@ export const Virtualization = Extension.create({ addProseMirrorPlugins() { if (!this.options.enabled) return []; - return [virtualizationPlugin()]; + return [virtualizationPlugin(this.options.unit)]; } }); @@ -94,10 +101,7 @@ export function installVirtualization(editor: Editor): void { | undefined; if (!storage?.enabled) return; - const manager = editor.extensionManager as unknown as Record< - string, - unknown - >; + const manager = editor.extensionManager as unknown as Record; if (Object.prototype.hasOwnProperty.call(manager, "nodeViews")) return; const proto = Object.getPrototypeOf(editor.extensionManager); @@ -111,7 +115,8 @@ export function installVirtualization(editor: Editor): void { return withVirtualization( originalGetter.call(this), storage.heightMap, - storage.thresholdBlocks + storage.thresholdBlocks, + storage.unit ); } }); diff --git a/packages/editor/src/extensions/virtualization/node-views.ts b/packages/editor/src/extensions/virtualization/node-views.ts index bf5c3edba..326732c00 100644 --- a/packages/editor/src/extensions/virtualization/node-views.ts +++ b/packages/editor/src/extensions/virtualization/node-views.ts @@ -28,6 +28,15 @@ import { import { profiler } from "../../utils/profiler.js"; import { HeightMap } from "./height-map.js"; +export type VirtualizationUnit = "blocks" | "pages"; + +/** A page is virtualized exactly like a block; it is just a much taller one. */ +export const PAGE_TYPES = ["page"]; + +export function unitTypes(unit: VirtualizationUnit): string[] { + return unit === "pages" ? PAGE_TYPES : TOP_LEVEL_BLOCK_TYPES; +} + export const TOP_LEVEL_BLOCK_TYPES = [ "paragraph", "heading", @@ -212,17 +221,21 @@ function wrapCustom( export function withVirtualization( nodeViews: Record, heightMap: HeightMap, - thresholdBlocks: number + thresholdBlocks: number, + unit: VirtualizationUnit = "blocks" ): Record { const wrapped: Record = { ...nodeViews }; - for (const type of TOP_LEVEL_BLOCK_TYPES) { + for (const type of unitTypes(unit)) { const inner = nodeViews[type]; wrapped[type] = (node, view, getPos, decorations, innerDecorations) => { const topLevel = isTopLevel(view, getPos as () => number | undefined); const materialize = isMaterialized(decorations); - const belowThreshold = view.state.doc.childCount <= thresholdBlocks; + // Pages only exist once a note is past the threshold, so their presence + // is the gate; blocks are counted directly. + const belowThreshold = + unit === "pages" ? false : view.state.doc.childCount <= thresholdBlocks; if (!topLevel || belowThreshold) { profiler.count("virtualization.nodeView.unvirtualized"); diff --git a/packages/editor/src/extensions/virtualization/viewport-plugin.ts b/packages/editor/src/extensions/virtualization/viewport-plugin.ts index 315efff7c..7f4514693 100644 --- a/packages/editor/src/extensions/virtualization/viewport-plugin.ts +++ b/packages/editor/src/extensions/virtualization/viewport-plugin.ts @@ -21,7 +21,7 @@ import { Node as ProsemirrorNode } from "@tiptap/pm/model"; import { EditorState, Plugin, PluginKey } from "@tiptap/pm/state"; import { Decoration, DecorationSet } from "@tiptap/pm/view"; import { profiler } from "../../utils/profiler.js"; -import { TOP_LEVEL_BLOCK_TYPES } from "./node-views.js"; +import { VirtualizationUnit, unitTypes } from "./node-views.js"; export const virtualizationKey = new PluginKey( "notesnook-virtualization" @@ -42,9 +42,7 @@ const KEEP_OVERSCAN = 1.5; const MATERIALIZE_ATTRS = {}; const MATERIALIZE_SPEC = { materialize: true }; -function isPageable(typeName: string): boolean { - return TOP_LEVEL_BLOCK_TYPES.includes(typeName); -} +type IsPageable = (typeName: string) => boolean; function findScrollParent(node: HTMLElement): HTMLElement | null { let current: HTMLElement | null = node.parentElement; @@ -107,7 +105,8 @@ function materializeDecoration(from: number, to: number): Decoration { function buildDecorations( doc: ProsemirrorNode, visible: Set, - selectionIndex: number + selectionIndex: number, + isPageable: IsPageable ): DecorationSet { const end = profiler.start("virtualization.decorations"); const decorations: Decoration[] = []; @@ -185,7 +184,8 @@ function hasMaterializeDecoration( */ function repairSelection( set: DecorationSet, - state: EditorState + state: EditorState, + isPageable: IsPageable ): DecorationSet { const missing: Decoration[] = []; for (const range of selectionRanges(state)) { @@ -199,7 +199,12 @@ function repairSelection( return set.add(state.doc, missing); } -export function virtualizationPlugin(): Plugin { +export function virtualizationPlugin( + unit: VirtualizationUnit = "blocks" +): Plugin { + const types = unitTypes(unit); + const isPageable: IsPageable = (typeName) => types.includes(typeName); + return new Plugin({ key: virtualizationKey, state: { @@ -212,7 +217,8 @@ export function virtualizationPlugin(): Plugin { decorations: buildDecorations( state.doc, EMPTY_VISIBLE, - selectionIndex + selectionIndex, + isPageable ) }; }, @@ -237,7 +243,12 @@ export function virtualizationPlugin(): Plugin { visible, selectionIndex, blockCount, - decorations: buildDecorations(tr.doc, visible, selectionIndex) + decorations: buildDecorations( + tr.doc, + visible, + selectionIndex, + isPageable + ) }; } @@ -247,7 +258,8 @@ export function virtualizationPlugin(): Plugin { const end = profiler.start("virtualization.decorationMap"); const mapped = repairSelection( value.decorations.map(tr.mapping, tr.doc), - newState + newState, + isPageable ); end(); profiler.count("virtualization.decorationMaps"); diff --git a/packages/editor/src/index.ts b/packages/editor/src/index.ts index 8d60f6895..e4c520787 100644 --- a/packages/editor/src/index.ts +++ b/packages/editor/src/index.ts @@ -282,7 +282,10 @@ const useTiptap = ( BlockId, PagedDocument, Page, - Virtualization.configure({ enabled: mode === "blocks" }), + Virtualization.configure({ + enabled: mode !== "off", + unit: mode === "pages" ? "pages" : "blocks" + }), Paging.configure({ enabled: mode === "pages" }), EditorProfiler, Blockquote,