editor: drop block virtualization and fold paging into one extension

This commit is contained in:
Ammar Ahmed
2026-08-27 08:47:53 +05:00
parent ac5b749299
commit 6ac09c5478
33 changed files with 1036 additions and 1710 deletions

View File

@@ -58,7 +58,7 @@ export type Settings = {
timeFormat: string;
fontScale: number;
markdownShortcuts: boolean;
virtualization?: "off" | "blocks" | "pages";
virtualization?: boolean;
features: Record<any, any>;
loggedIn: boolean;
defaultLineHeight: number;

View File

@@ -34,7 +34,6 @@ import {
DateFormatPicker,
DayFormatPicker,
WeekFormatPicker,
EditorVirtualizationPicker,
FontPicker,
HomePicker,
ImageCompressionPicker,
@@ -65,7 +64,6 @@ export const components: { [name: string]: ReactElement } = {
licenses: <Licenses />,
"trash-interval-selector": <TrashIntervalPicker />,
"font-selector": <FontPicker />,
"editor-virtualization-selector": <EditorVirtualizationPicker />,
"title-format": <TitleFormat />,
"date-format-selector": <DateFormatPicker />,
"time-format-selector": <TimeFormatPicker />,

View File

@@ -95,34 +95,6 @@ export const HomePicker = createSettingsPicker({
isOptionAvailable: async () => true
});
type VirtualizationMode = NonNullable<Settings["editorVirtualization"]>;
const VIRTUALIZATION_MODES: VirtualizationMode[] = ["off", "blocks", "pages"];
export const EditorVirtualizationPicker = createSettingsPicker<
VirtualizationMode,
VirtualizationMode
>({
getValue: () =>
useSettingStore.getState().settings.editorVirtualization || "off",
updateValue: async (item) => {
SettingsService.set({ editorVirtualization: item });
},
formatValue: (item) => {
const mode = (
typeof item === "object" ? "off" : item
) as VirtualizationMode;
if (mode === "blocks") return strings.editorVirtualizationBlocks();
if (mode === "pages") return strings.editorVirtualizationPages();
return strings.editorVirtualizationOff();
},
getItemKey: (item) => item,
options: VIRTUALIZATION_MODES,
compareValue: (current, item) => current === item,
isFeatureAvailable: async () => true,
isOptionAvailable: async () => true
});
export const SidebarTabPicker = createSettingsPicker({
getValue: () => useSettingStore.getState().settings.defaultSidebarTab,
updateValue: async (item) => {

View File

@@ -1027,8 +1027,7 @@ export const settingsGroups: SettingSection[] = [
id: "editor-virtualization",
name: strings.editorVirtualization(),
description: strings.editorVirtualizationDesc(),
type: "component",
component: "editor-virtualization-selector",
type: "switch",
property: "editorVirtualization",
icon: "page-next-outline"
},

View File

@@ -78,7 +78,7 @@ export type Settings = {
sessionExpired: boolean;
version: string | null;
doubleSpacedLines?: boolean;
editorVirtualization?: "off" | "blocks" | "pages";
editorVirtualization?: boolean;
disableAutoSync?: boolean;
disableSync?: boolean;
reminderNotifications?: boolean;
@@ -197,7 +197,7 @@ export const defaultSettings: SettingStore["settings"] = {
sessionExpired: false,
version: null,
doubleSpacedLines: true,
editorVirtualization: "off",
editorVirtualization: false,
reminderNotifications: true,
defaultSnoozeTime: "5",
corsProxy: "https://cors.notesnook.com",

View File

@@ -43,7 +43,6 @@ import {
restoreScrollAnchor,
serializeDocumentHTML,
toFlatPosition,
type VirtualizationMode,
type Selection
} from "@notesnook/editor";
import { installProfilerGlobals, setProfiledEditor } from "./profiling";
@@ -122,7 +121,7 @@ type TipTapProps = {
dayFormat: DayFormat;
markdownShortcuts: boolean;
fontLigatures: boolean;
virtualization: VirtualizationMode;
virtualization: boolean;
};
function countCharacters(text: string) {

View File

@@ -18,7 +18,6 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import { SettingsGroup } from "./types";
import { VirtualizationMode } from "@notesnook/editor";
import {
editorConfig,
onEditorConfigChange,
@@ -166,18 +165,10 @@ export const EditorSettings: SettingsGroup[] = [
useSettingStore.subscribe((c) => c.editorVirtualization, listener),
components: [
{
type: "dropdown",
options: [
{ value: "off", title: strings.editorVirtualizationOff() },
{ value: "blocks", title: strings.editorVirtualizationBlocks() },
{ value: "pages", title: strings.editorVirtualizationPages() }
],
selectedOption: () =>
useSettingStore.getState().editorVirtualization,
onSelectionChanged: (value) =>
useSettingStore
.getState()
.setEditorVirtualization(value as VirtualizationMode)
type: "toggle",
isToggled: () => useSettingStore.getState().editorVirtualization,
toggle: () =>
useSettingStore.getState().toggleEditorVirtualization()
}
]
}

View File

@@ -25,7 +25,6 @@ import Config from "../utils/config";
import BaseStore from "./index";
import { TimeFormat, DayFormat, WeekFormat, EVENTS } from "@notesnook/core";
import { Profile, TrashCleanupInterval } from "@notesnook/core";
import { VirtualizationMode, toVirtualizationMode } from "@notesnook/editor";
import { showToast } from "../utils/toast";
import { ConfirmDialog } from "../dialogs/confirm";
import * as openpgp from "openpgp";
@@ -58,9 +57,7 @@ class SettingStore extends BaseStore<SettingStore> {
doubleSpacedParagraphs = Config.get("doubleSpacedLines", true);
markdownShortcuts = Config.get("markdownShortcuts", false);
fontLigatures = Config.get("fontLigatures", false);
editorVirtualization: VirtualizationMode = toVirtualizationMode(
Config.get<VirtualizationMode | boolean>("editorVirtualization", "off")
);
editorVirtualization = Config.get("editorVirtualization", false);
notificationsSettings = Config.get("notifications", { reminder: true });
isFullOfflineMode = Config.get("fullOfflineMode", false);
serverUrls: Partial<Record<HostId, string>> = Config.get("serverUrls", {});
@@ -254,9 +251,10 @@ class SettingStore extends BaseStore<SettingStore> {
Config.set("fontLigatures", !fontLigatures);
};
setEditorVirtualization = (mode: VirtualizationMode) => {
this.set((state) => (state.editorVirtualization = mode));
Config.set("editorVirtualization", mode);
toggleEditorVirtualization = (toggleState?: boolean) => {
const next = toggleState ?? !this.get().editorVirtualization;
this.set((state) => (state.editorVirtualization = next));
Config.set("editorVirtualization", next);
};
togglePrivacyMode = async () => {

View File

@@ -259,7 +259,7 @@ const Tiptap = ({
timeFormat: settings.timeFormat as "12-hour" | "24-hour" | undefined,
dayFormat: settings.dayFormat,
enableInputRules: settings.markdownShortcuts,
virtualization: settings.virtualization || "off"
virtualization: !!settings.virtualization
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [
@@ -963,7 +963,7 @@ const Tiptap = ({
// once and only rebuilds its view afterwards, so extension options are
// frozen at construction — toggling paging only takes effect when this
// component remounts.
key={tick + tab.id + "-editor-" + (settings.virtualization || "off")}
key={tick + tab.id + "-editor-" + !!settings.virtualization}
options={tiptapOptions}
settings={settings}
onEditorUpdate={(editor) => {

View File

@@ -52,7 +52,7 @@ export type Settings = {
dateFormat: string;
fontScale: number;
markdownShortcuts: boolean;
virtualization?: "off" | "blocks" | "pages";
virtualization?: boolean;
features: Record<any, any>;
loggedIn: boolean;
defaultLineHeight: number;

View File

@@ -0,0 +1,142 @@
/*
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 { afterEach, 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, viewportKey } from "../index.js";
import { BlockId } from "../../block-id/block-id.js";
import { profiler } from "../../../utils/profiler.js";
const PagedDocument = Node.create({
name: "doc",
topNode: true,
content: "(page | block)+"
});
const BLOCKS = 200;
const PAGE_SIZE = 50;
function savedNoteHTML(n: number) {
let content = "";
for (let i = 0; i < n; i++)
content += `<p data-block-id="blk${i}">Paragraph number ${i}.</p>`;
return content;
}
function createEditor() {
return new Editor({
extensions: [
StarterKit.configure({ document: false }),
PagedDocument,
Page,
BlockId,
Paging.configure({
enabled: true,
pageSize: PAGE_SIZE,
thresholdBlocks: 10
})
],
content: savedNoteHTML(BLOCKS)
});
}
function decorations(editor: Editor): DecorationSet {
return viewportKey.getState(editor.state)?.decorations ?? DecorationSet.empty;
}
function counters() {
return profiler.report().counters;
}
afterEach(() => {
profiler.disable();
profiler.reset();
});
describe("viewport decorations", () => {
test("a transaction that changes nothing relevant reuses the state", () => {
const editor = createEditor();
profiler.enable();
const before = decorations(editor);
editor.view.dispatch(editor.state.tr.setMeta("unrelated", true));
expect(decorations(editor)).toBe(before);
expect(counters()["paging.decorationReuses"]).toBe(1);
expect(counters()["paging.decorationBuilds"]).toBeUndefined();
editor.destroy();
});
test("a text edit maps the decorations instead of rebuilding them", () => {
const editor = createEditor();
profiler.enable();
editor.view.dispatch(editor.state.tr.insertText("x", 3));
expect(counters()["paging.decorationMaps"]).toBeGreaterThanOrEqual(1);
expect(counters()["paging.decorationBuilds"]).toBeUndefined();
editor.destroy();
});
test("adding a page rebuilds the decorations", () => {
const editor = createEditor();
profiler.enable();
editor.view.dispatch(
editor.state.tr.replaceWith(
0,
0,
editor.state.schema.nodes.paragraph.create()
)
);
expect(counters()["paging.decorationBuilds"]).toBeGreaterThanOrEqual(1);
editor.destroy();
});
test("the caret's page stays rendered while typing at its end", () => {
const editor = createEditor();
const firstPage = editor.state.doc.child(0);
editor.commands.setTextSelection(firstPage.nodeSize - 2);
profiler.enable();
for (let i = 0; i < 5; i++)
editor.view.dispatch(
editor.state.tr.insertText("a", editor.state.selection.from)
);
const page = editor.view.dom.children[0] as HTMLElement;
expect(page.hasAttribute("data-virtual-placeholder")).toBe(false);
editor.destroy();
});
test("only pages are decorated", () => {
const editor = createEditor();
const spans = decorations(editor).find(0, editor.state.doc.content.size);
expect(spans.length).toBeGreaterThan(0);
for (const span of spans) {
const node = editor.state.doc.nodeAt(span.from);
expect(node?.type.name).toBe("page");
}
editor.destroy();
});
});

View File

@@ -22,7 +22,7 @@ import { Editor, Node } from "@tiptap/core";
import StarterKit from "@tiptap/starter-kit";
import { Node as ProsemirrorNode } from "@tiptap/pm/model";
import { HeightMap } from "../height-map.js";
import { Page, Paging } from "../../paging/index.js";
import { Page, Paging } from "../index.js";
import { BlockId } from "../../block-id/block-id.js";
import { ImageNode } from "../../image/index.js";
import { Table } from "../../table/index.js";

View File

@@ -23,10 +23,7 @@ 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";
import { viewportKey } from "../index.js";
const PagedDocument = Node.create({
name: "doc",
@@ -55,8 +52,7 @@ function createEditor() {
enabled: true,
pageSize: PAGE_SIZE,
thresholdBlocks: 10
}),
Virtualization.configure({ enabled: true, unit: "pages" })
})
],
content: savedNoteHTML(BLOCKS)
});
@@ -67,9 +63,7 @@ async function created() {
}
function decorations(editor: Editor): DecorationSet {
return (
virtualizationKey.getState(editor.state)?.decorations ?? DecorationSet.empty
);
return viewportKey.getState(editor.state)?.decorations ?? DecorationSet.empty;
}
describe("paged virtualization", () => {

View File

@@ -20,9 +20,8 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
import { afterEach, describe, expect, test } from "vitest";
import { Editor, Node } from "@tiptap/core";
import StarterKit from "@tiptap/starter-kit";
import { Page, Paging } from "../../paging/index.js";
import { Page, Paging } from "../index.js";
import { BlockId } from "../../block-id/block-id.js";
import { Virtualization } from "../index.js";
import { getScrollAnchor, restoreScrollAnchor } from "../anchor.js";
const PagedDocument = Node.create({
@@ -105,8 +104,7 @@ function createEditor(container?: HTMLElement) {
enabled: true,
pageSize: PAGE_SIZE,
thresholdBlocks: 10
}),
Virtualization.configure({ enabled: true, unit: "pages" })
})
],
content: savedNoteHTML(BLOCKS)
});

View File

@@ -18,13 +18,19 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { Editor } from "@tiptap/core";
import { Editor, Node } from "@tiptap/core";
import StarterKit from "@tiptap/starter-kit";
import { Virtualization, virtualizationKey } from "../index.js";
import { Page, Paging, viewportKey } from "../index.js";
import { BlockId } from "../../block-id/block-id.js";
const BLOCKS = 60;
const BLOCK_HEIGHT = 100;
const PagedDocument = Node.create({
name: "doc",
topNode: true,
content: "(page | block)+"
});
const PAGES = 60;
const PAGE_HEIGHT = 100;
function savedNoteHTML(n: number) {
let content = "";
@@ -47,17 +53,16 @@ function rect(top: number, height: number) {
} as DOMRect;
}
/** happy-dom does no layout, so the block geometry is stubbed in. */
function stubLayout(editor: Editor, blockHeight = BLOCK_HEIGHT) {
/** happy-dom does no layout, so the page geometry is stubbed in. */
function stubLayout(editor: Editor, pageHeight = PAGE_HEIGHT) {
const dom = editor.view.dom as HTMLElement;
const total = dom.children.length * blockHeight;
dom.getBoundingClientRect = () => rect(0, total);
dom.getBoundingClientRect = () => rect(0, dom.children.length * pageHeight);
let top = 0;
for (const child of Array.from(dom.children)) {
const childTop = top;
(child as HTMLElement).getBoundingClientRect = () =>
rect(childTop, blockHeight);
top += blockHeight;
rect(childTop, pageHeight);
top += pageHeight;
}
}
@@ -70,8 +75,28 @@ function frames(count = 2) {
});
}
function visibleBlocks(editor: Editor) {
return [...(virtualizationKey.getState(editor.state)?.visible ?? [])].sort();
/** The indexes of the pages the window currently considers visible. */
function visiblePages(editor: Editor) {
const visible = viewportKey.getState(editor.state)?.visible ?? new Set();
const indexes: number[] = [];
editor.state.doc.forEach((page, _offset, index) => {
if (visible.has(page.attrs.blockId)) indexes.push(index);
});
return indexes;
}
// one block per page keeps the geometry easy to reason about
function createEditor() {
return new Editor({
extensions: [
StarterKit.configure({ document: false }),
PagedDocument,
Page,
BlockId,
Paging.configure({ enabled: true, pageSize: 1, thresholdBlocks: 5 })
],
content: savedNoteHTML(PAGES)
});
}
let observers: number;
@@ -92,18 +117,88 @@ afterEach(() => {
vi.restoreAllMocks();
});
function createEditor() {
return new Editor({
extensions: [
StarterKit,
BlockId,
Virtualization.configure({ enabled: true, thresholdBlocks: 5 })
],
content: savedNoteHTML(BLOCKS)
});
}
describe("viewport window", () => {
test("observes nothing: no IntersectionObserver is created", async () => {
const editor = createEditor();
stubLayout(editor);
editor.view.dispatch(editor.state.tr.setMeta("nudge", true));
await frames();
expect(observers).toBe(0);
editor.destroy();
});
test("tracks only the pages within the overscan band", async () => {
const editor = createEditor();
stubLayout(editor);
editor.view.dispatch(editor.state.tr.setMeta("nudge", true));
await frames();
const visible = visiblePages(editor);
expect(visible.length).toBeGreaterThan(0);
expect(visible.length).toBeLessThan(PAGES);
// happy-dom reports a 768px viewport and the band reaches one viewport
// past it, so nothing beyond ~1536px may be tracked
expect(Math.max(...visible) * PAGE_HEIGHT).toBeLessThanOrEqual(1536);
editor.destroy();
});
test("does not measure while the editor has no layout", async () => {
const editor = createEditor();
editor.view.dispatch(editor.state.tr.setMeta("nudge", true));
await frames();
expect(visiblePages(editor)).toEqual([]);
editor.destroy();
});
test("keeps pages that drift into the hysteresis band", async () => {
const editor = createEditor();
stubLayout(editor);
editor.view.dispatch(editor.state.tr.setMeta("nudge", true));
await frames();
const before = visiblePages(editor);
const dom = editor.view.dom as HTMLElement;
let top = 384;
for (const child of Array.from(dom.children)) {
const childTop = top;
(child as HTMLElement).getBoundingClientRect = () =>
rect(childTop, PAGE_HEIGHT);
top += PAGE_HEIGHT;
}
editor.view.dispatch(editor.state.tr.setMeta("nudge", true));
await frames();
const after = visiblePages(editor);
for (const index of before) expect(after).toContain(index);
editor.destroy();
});
test("keeps the visible page still when a placeholder resizes", async () => {
const editor = createEditor();
stubLayout(editor);
editor.view.dispatch(editor.state.tr.setMeta("nudge", true));
await frames();
const container = editor.view.dom.parentElement;
const before = container?.scrollTop ?? 0;
const dom = editor.view.dom as HTMLElement;
let top = 0;
for (const [index, child] of Array.from(dom.children).entries()) {
const height = index === 1 ? PAGE_HEIGHT * 10 : PAGE_HEIGHT;
const childTop = top;
(child as HTMLElement).getBoundingClientRect = () =>
rect(childTop, height);
top += height;
}
editor.view.dispatch(editor.state.tr.setMeta("nudge", true));
await frames();
expect(container?.scrollTop ?? 0).toBeGreaterThanOrEqual(before);
editor.destroy();
});
test("re-measures when the editor is re-laid out", async () => {
const observed: Element[] = [];
const RealResizeObserver = globalThis.ResizeObserver;
@@ -123,113 +218,21 @@ describe("viewport window", () => {
stubLayout(editor);
await frames();
// zooming changes the layout without scrolling or editing, so a resize is
// the only signal that what is on screen has moved
expect(observed).toContain(editor.view.dom);
expect(notify).toBeDefined();
const dom = editor.view.dom as HTMLElement;
let top = 0;
for (const child of Array.from(dom.children)) {
const childTop = top;
(child as HTMLElement).getBoundingClientRect = () =>
rect(childTop, BLOCK_HEIGHT * 4);
top += BLOCK_HEIGHT * 4;
rect(childTop, PAGE_HEIGHT * 4);
top += PAGE_HEIGHT * 4;
}
notify?.();
await frames();
expect(visibleBlocks(editor).length).toBeGreaterThan(0);
expect(visiblePages(editor).length).toBeGreaterThan(0);
globalThis.ResizeObserver = RealResizeObserver;
editor.destroy();
});
test("observes nothing: no IntersectionObserver is created", async () => {
const editor = createEditor();
stubLayout(editor);
editor.view.dispatch(editor.state.tr.setMeta("nudge", true));
await frames();
expect(observers).toBe(0);
editor.destroy();
});
test("tracks only the blocks within the overscan band", async () => {
const editor = createEditor();
stubLayout(editor);
editor.view.dispatch(editor.state.tr.setMeta("nudge", true));
await frames();
const visible = visibleBlocks(editor);
expect(visible.length).toBeGreaterThan(0);
expect(visible.length).toBeLessThan(BLOCKS);
// window.innerHeight is 768 in happy-dom and the add band is one viewport
// in each direction, so blocks past ~1536px must stay out.
const highest = Math.max(
...visible.map((id) => Number(id.replace("blk", "")))
);
expect(highest * BLOCK_HEIGHT).toBeLessThanOrEqual(1536);
editor.destroy();
});
test("does not measure while the editor has no layout", async () => {
const editor = createEditor();
editor.view.dispatch(editor.state.tr.setMeta("nudge", true));
await frames();
expect(visibleBlocks(editor)).toEqual([]);
editor.destroy();
});
test("keeps the visible unit still when a placeholder resizes", async () => {
const editor = createEditor();
stubLayout(editor);
editor.view.dispatch(editor.state.tr.setMeta("nudge", true));
await frames();
const container = editor.view.dom.parentElement;
const before = container?.scrollTop ?? 0;
// the second block renders and turns out to be far taller than its
// estimate: everything after it moves, so the scroll must follow
const dom = editor.view.dom as HTMLElement;
let top = 0;
for (const [index, child] of Array.from(dom.children).entries()) {
const height = index === 1 ? BLOCK_HEIGHT * 10 : BLOCK_HEIGHT;
const childTop = top;
(child as HTMLElement).getBoundingClientRect = () =>
rect(childTop, height);
top += height;
}
editor.view.dispatch(editor.state.tr.setMeta("nudge", true));
await frames();
expect(container?.scrollTop ?? 0).toBeGreaterThanOrEqual(before);
editor.destroy();
});
test("keeps blocks that drift into the hysteresis band", async () => {
const editor = createEditor();
stubLayout(editor);
editor.view.dispatch(editor.state.tr.setMeta("nudge", true));
await frames();
const before = visibleBlocks(editor);
// Everything shifts down by half a viewport: blocks that leave the add
// band but stay inside the keep band must not be dropped.
const dom = editor.view.dom as HTMLElement;
let top = 384;
for (const child of Array.from(dom.children)) {
const childTop = top;
(child as HTMLElement).getBoundingClientRect = () =>
rect(childTop, BLOCK_HEIGHT);
top += BLOCK_HEIGHT;
}
editor.view.dispatch(editor.state.tr.setMeta("nudge", true));
await frames();
for (const id of before) expect(visibleBlocks(editor)).toContain(id);
editor.destroy();
});
});

View File

@@ -20,8 +20,8 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
import { Node as ProsemirrorNode } from "@tiptap/pm/model";
import { EditorView } from "@tiptap/pm/view";
import { profiler } from "../../utils/profiler.js";
import { isPage } from "../paging/split.js";
import { findScrollParent, virtualizationKey } from "./viewport-plugin.js";
import { isPage } from "./split.js";
import { findScrollParent, viewportKey } from "./viewport-plugin.js";
export type ScrollAnchor = {
/** The block that was at the top of the viewport. */
@@ -59,7 +59,6 @@ export function getScrollAnchor(view: EditorView): ScrollAnchor | undefined {
: undefined;
}
// Inside a rendered page, anchor on the exact block at the fold.
for (const child of Array.from(element.children)) {
const childRect = child.getBoundingClientRect();
if (childRect.bottom <= top) continue;
@@ -67,8 +66,6 @@ export function getScrollAnchor(view: EditorView): ScrollAnchor | undefined {
if (blockId) return { blockId, offset: Math.round(top - childRect.top) };
}
// A page that has not rendered has no blocks to inspect, but the document
// still knows which block it starts with.
const blockId = node.firstChild?.attrs.blockId as string | undefined;
return blockId
? { blockId, offset: Math.round(top - rect.top) }
@@ -105,7 +102,7 @@ export function restoreScrollAnchor(
view: EditorView,
anchor: ScrollAnchor
): boolean {
const end = profiler.start("virtualization.restoreAnchor");
const end = profiler.start("paging.restoreAnchor");
const { container, top } = containerOf(view);
if (!container) {
end();
@@ -115,18 +112,18 @@ export function restoreScrollAnchor(
const target = findBlock(view.state.doc, anchor.blockId);
if (!target.found) {
end();
profiler.count("virtualization.restoreAnchorMissed");
profiler.count("paging.restoreAnchorMissed");
return false;
}
if (target.pageId) {
const visible = virtualizationKey.getState(view.state)?.visible;
const visible = viewportKey.getState(view.state)?.visible;
if (!visible?.has(target.pageId)) {
const next = new Set(visible ?? []);
next.add(target.pageId);
view.dispatch(
view.state.tr
.setMeta(virtualizationKey, { visible: next })
.setMeta(viewportKey, { visible: next })
.setMeta("preventUpdate", true)
.setMeta("addToHistory", false)
);
@@ -138,13 +135,13 @@ export function restoreScrollAnchor(
);
if (!element) {
end();
profiler.count("virtualization.restoreAnchorMissed");
profiler.count("paging.restoreAnchorMissed");
return false;
}
container.scrollTop +=
element.getBoundingClientRect().top - top - anchor.offset;
end();
profiler.count("virtualization.restoreAnchors");
profiler.count("paging.restoreAnchors");
return true;
}

View File

@@ -147,12 +147,8 @@ export class HeightMap {
const stored = this.storedHeight(node);
if (stored) return stored;
// Structure beats the type's fallback: a two-row table is not as tall as
// the average table, it is as tall as two rows.
if (node.type.name === TABLE_TYPE) return this.table(node) || base;
// Lists, callouts, quotes, pages: a container is as tall as its contents,
// and its children carry their own structure down as far as it goes.
if (this.holdsBlocks(node)) {
let total = 0;
node.forEach((child) => (total += this.heightFor(child)));
@@ -168,8 +164,6 @@ export class HeightMap {
* line model cannot see.
*/
private text(node: ProsemirrorNode, base: number): number {
// `content.size` is O(1) and proportional to how much text a node holds,
// unlike `textContent`, which would copy every character of every page.
const content = node.content.size;
if (!content) return base;
@@ -248,8 +242,6 @@ export class HeightMap {
this.estimates.delete(node);
profiler.gauge("virtualization.heightMap.size", this.measured.size);
// Pages are containers; calibrating from them would average away the
// difference between the types they hold.
if (node.type.name === PAGE_TYPE) return;
const content = node.content.size;

View File

@@ -19,9 +19,14 @@ 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 { HeightMap } from "./height-map.js";
import { installPagingParser } from "./parser.js";
import { installFlatteningSerializer } from "./serializer.js";
import { DEFAULT_PAGE_SIZE, countPages, toPages } from "./split.js";
import { viewportPlugin } from "./viewport-plugin.js";
/** Paging only engages for notes larger than this many top-level blocks. */
const DEFAULT_THRESHOLD_BLOCKS = 300;
export type PagingOptions = {
enabled: boolean;
@@ -29,6 +34,19 @@ export type PagingOptions = {
thresholdBlocks: number;
};
export type PagingStorage = {
heights: HeightMap;
};
/**
* Renders only the pages near the viewport, keeping the rest of the note in the
* document as empty boxes of their estimated height. Pages are grouped as a
* note is parsed and stripped again on serialization, so nothing about how a
* note is stored changes.
*
* Browser find-in-page and printing only cover rendered pages, so this is
* opt-in and only engages past a size threshold.
*/
export const Paging = Extension.create<PagingOptions>({
name: "paging",
@@ -36,12 +54,14 @@ export const Paging = Extension.create<PagingOptions>({
return {
enabled: false,
pageSize: DEFAULT_PAGE_SIZE,
thresholdBlocks: 300
thresholdBlocks: DEFAULT_THRESHOLD_BLOCKS
};
},
// Runs before the first view exists, so the document arrives already paged
// and is never rendered flat.
addStorage(): PagingStorage {
return { heights: new HeightMap() };
},
onBeforeCreate() {
installFlatteningSerializer(this.editor.schema);
if (!this.options.enabled) return;
@@ -51,8 +71,6 @@ export const Paging = Extension.create<PagingOptions>({
});
},
// 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;
@@ -60,22 +78,37 @@ export const Paging = Extension.create<PagingOptions>({
if (doc.childCount <= this.options.thresholdBlocks) return;
if (countPages(doc) > 0) return;
const end = profiler.start("paging.split");
const pages = toPages(doc, editor.schema, this.options.pageSize);
editor.view.dispatch(
editor.state.tr
.replaceWith(0, doc.content.size, pages)
.replaceWith(
0,
doc.content.size,
toPages(doc, editor.schema, this.options.pageSize)
)
.setMeta("preventUpdate", true)
.setMeta("addToHistory", false)
.setMeta("ignoreEdit", true)
);
end();
profiler.count("paging.splits");
profiler.gauge("paging.pages", countPages(editor.state.doc));
},
addProseMirrorPlugins() {
if (!this.options.enabled) return [];
return [viewportPlugin(this.storage.heights)];
}
});
export { Page, PAGE_NODE } from "./page.js";
export { HeightMap } from "./height-map.js";
export { PAGE_NODE, Page } from "./page.js";
export {
getScrollAnchor,
restoreScrollAnchor,
type ScrollAnchor
} from "./anchor.js";
export { installPagingParser, uninstallPagingParser } from "./parser.js";
export { fromFlatPosition, toFlatPosition } from "./positions.js";
export { serializeDocumentHTML } from "./serialize.js";
export { installFlatteningSerializer } from "./serializer.js";
export {
DEFAULT_PAGE_SIZE,
countPages,
@@ -84,7 +117,4 @@ export {
isPage,
toPages
} from "./split.js";
export { installFlatteningSerializer } from "./serializer.js";
export { installPagingParser, uninstallPagingParser } from "./parser.js";
export { fromFlatPosition, toFlatPosition } from "./positions.js";
export { serializeDocumentHTML } from "./serialize.js";
export { viewportKey } from "./viewport-plugin.js";

View File

@@ -0,0 +1,110 @@
/*
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 { DOMSerializer, Node as ProsemirrorNode } from "@tiptap/pm/model";
import { Decoration, NodeView } from "@tiptap/pm/view";
import { profiler } from "../../utils/profiler.js";
import { HeightMap } from "./height-map.js";
export function isMaterialized(decorations: readonly Decoration[]): boolean {
return decorations.some(
(d) => (d.spec as { materialize?: boolean })?.materialize
);
}
let template: HTMLDivElement | undefined;
function placeholderElement(): HTMLDivElement {
if (!template) {
template = document.createElement("div");
template.setAttribute("data-virtual-placeholder", "true");
template.style.width = "100%";
template.style.contain = "strict";
}
return template.cloneNode(false) as HTMLDivElement;
}
/**
* An empty box standing in for a page. ProseMirror keeps the page and its
* blocks in the document but renders nothing for them, so the browser lays out
* and paints one sized div instead of a few hundred elements.
*/
function placeholder(node: ProsemirrorNode, heights: HeightMap): NodeView {
profiler.count("paging.placeholderCreated");
const dom = placeholderElement();
const blockId = node.attrs.blockId as string | undefined;
if (blockId) dom.setAttribute("data-block-id", blockId);
dom.style.height = `${heights.heightFor(node)}px`;
return {
dom,
contentDOM: null,
update(updated, decorations) {
if (updated.type !== node.type) return false;
if (isMaterialized(decorations)) {
profiler.count("paging.materialized");
return false;
}
node = updated;
dom.style.height = `${heights.heightFor(updated)}px`;
return true;
},
ignoreMutation() {
return true;
}
};
}
/** A page rendered normally, measured so its placeholder is the right size. */
function rendered(node: ProsemirrorNode, heights: HeightMap): NodeView {
const spec = node.type.spec.toDOM?.(node);
if (!spec) return { dom: document.createElement("div") };
const { dom, contentDOM } = DOMSerializer.renderSpec(document, spec);
const measure = () => {
if (dom instanceof HTMLElement) heights.record(node, dom.offsetHeight);
};
return {
dom,
contentDOM,
update(updated, decorations) {
if (updated.type !== node.type) return false;
if (!isMaterialized(decorations)) {
profiler.count("paging.dematerialized");
return false;
}
if (!node.sameMarkup(updated)) return false;
node = updated;
measure();
return true;
},
destroy: measure
};
}
export function createPageView(
node: ProsemirrorNode,
decorations: readonly Decoration[],
heights: HeightMap
): NodeView {
return isMaterialized(decorations)
? rendered(node, heights)
: placeholder(node, heights);
}

View File

@@ -18,14 +18,19 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import { Node, mergeAttributes } from "@tiptap/core";
import { HeightMap } from "./height-map.js";
import { createPageView } from "./page-view.js";
export const PAGE_NODE = "page";
/**
* A grouping wrapper around a run of top-level blocks. Pages exist only in the
* editor's document: they are created when a note is opened and removed again
* on serialization, so stored content is unchanged and older clients are
* unaffected. There is deliberately no `parseHTML` rule for the same reason.
* A run of top-level blocks, rendered as one unit so an off-screen page costs a
* single empty box instead of a few hundred elements.
*
* Pages exist only in the editor's document: they are created when a note is
* opened and removed again on serialization, so stored content is unchanged and
* older clients are unaffected. There is deliberately no `parseHTML` rule for
* the same reason.
*/
export const Page = Node.create({
name: PAGE_NODE,
@@ -33,11 +38,15 @@ export const Page = Node.create({
group: "page",
selectable: false,
// The block id must survive onto the element: the viewport plugin tracks
// pages by `data-block-id`, and a page that renders without one is invisible
// to it -- it materializes, disappears from the window, and dematerializes
// again on the next frame.
renderHTML({ HTMLAttributes }) {
return ["div", mergeAttributes(HTMLAttributes, { "data-page": "true" }), 0];
},
addNodeView() {
return ({ node, decorations }) => {
const heights = (this.editor.storage.paging as { heights: HeightMap })
.heights;
return createPageView(node, decorations, heights);
};
}
});

View File

@@ -54,8 +54,6 @@ export function installPagingParser(
): 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);
}

View File

@@ -17,29 +17,8 @@ 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 {
DOMSerializer,
Fragment,
Node as ProsemirrorNode,
Schema
} from "@tiptap/pm/model";
import { PAGE_NODE } from "./page.js";
function withoutPages(fragment: Fragment): Fragment {
let paged = false;
fragment.forEach((node) => {
if (node.type.name === PAGE_NODE) paged = true;
});
if (!paged) return fragment;
const blocks: ProsemirrorNode[] = [];
fragment.forEach((node) => {
if (node.type.name === PAGE_NODE)
node.content.forEach((child) => blocks.push(child));
else blocks.push(node);
});
return Fragment.fromArray(blocks);
}
import { DOMSerializer, Fragment, Schema } from "@tiptap/pm/model";
import { flattenPages } from "./split.js";
/**
* Serializes pages as if they were not there, so stored HTML, the clipboard and
@@ -53,7 +32,7 @@ class FlatteningDOMSerializer extends DOMSerializer {
options?: { document?: Document },
target?: HTMLElement | DocumentFragment
) {
return super.serializeFragment(withoutPages(fragment), options, target);
return super.serializeFragment(flattenPages(fragment), options, target);
}
}

View File

@@ -44,9 +44,6 @@ 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)
@@ -60,24 +57,26 @@ export function toPages(
return Fragment.fromArray(pages);
}
/** The document's blocks with every page wrapper removed. */
export function flattenBlocks(doc: ProsemirrorNode): ProsemirrorNode[] {
const blocks: ProsemirrorNode[] = [];
doc.forEach((node) => {
if (isPage(node)) node.forEach((child) => blocks.push(child));
else blocks.push(node);
});
return blocks;
}
/** The document's content with page wrappers removed, for serialization. */
export function flattenPages(doc: ProsemirrorNode): Fragment {
/** The same content with every page wrapper removed. */
export function flattenPages(fragment: Fragment): Fragment {
let paged = false;
doc.forEach((node) => {
fragment.forEach((node) => {
if (isPage(node)) paged = true;
});
if (!paged) return doc.content;
return Fragment.fromArray(flattenBlocks(doc));
if (!paged) return fragment;
const blocks: ProsemirrorNode[] = [];
fragment.forEach((node) => {
if (isPage(node)) node.content.forEach((child) => blocks.push(child));
else blocks.push(node);
});
return Fragment.fromArray(blocks);
}
export function flattenBlocks(doc: ProsemirrorNode): ProsemirrorNode[] {
const blocks: ProsemirrorNode[] = [];
flattenPages(doc.content).forEach((node) => blocks.push(node));
return blocks;
}
export function countPages(doc: ProsemirrorNode): number {

View File

@@ -22,13 +22,11 @@ import { EditorState, Plugin, PluginKey } from "@tiptap/pm/state";
import { Decoration, DecorationSet } from "@tiptap/pm/view";
import { profiler } from "../../utils/profiler.js";
import { HeightMap } from "./height-map.js";
import { VirtualizationUnit, unitTypes } from "./node-views.js";
import { PAGE_NODE } from "./page.js";
export const virtualizationKey = new PluginKey<VirtualizationState>(
"notesnook-virtualization"
);
export const viewportKey = new PluginKey<ViewportState>("notesnook-paging");
type VirtualizationState = {
type ViewportState = {
visible: Set<string>;
selectionIndex: number;
blockCount: number;
@@ -43,8 +41,6 @@ const KEEP_OVERSCAN = 1.5;
const MATERIALIZE_ATTRS = {};
const MATERIALIZE_SPEC = { materialize: true };
type IsPageable = (typeName: string) => boolean;
export function findScrollParent(node: HTMLElement): HTMLElement | null {
let current: HTMLElement | null = node.parentElement;
while (current) {
@@ -74,20 +70,20 @@ function shouldMaterialize(
selectionIndex: number
): boolean {
if (index === 0 || index === lastIndex) {
profiler.count("virtualization.materializedBy.edge");
profiler.count("paging.materializedBy.edge");
return true;
}
if (Math.abs(index - selectionIndex) <= 1) {
profiler.count("virtualization.materializedBy.selection");
profiler.count("paging.materializedBy.selection");
return true;
}
const blockId = node.attrs.blockId as string | undefined;
if (!blockId) {
profiler.count("virtualization.materializedBy.missingBlockId");
profiler.count("paging.materializedBy.missingBlockId");
return true;
}
if (visible.has(blockId)) {
profiler.count("virtualization.materializedBy.visible");
profiler.count("paging.materializedBy.visible");
return true;
}
return false;
@@ -106,17 +102,16 @@ function materializeDecoration(from: number, to: number): Decoration {
function buildDecorations(
doc: ProsemirrorNode,
visible: Set<string>,
selectionIndex: number,
isPageable: IsPageable
selectionIndex: number
): DecorationSet {
const end = profiler.start("virtualization.decorations");
const end = profiler.start("paging.decorations");
const decorations: Decoration[] = [];
const lastIndex = doc.childCount - 1;
let index = -1;
doc.forEach((node, offset) => {
index++;
if (!isPageable(node.type.name)) return;
if (node.type.name !== PAGE_NODE) return;
if (!shouldMaterialize(node, index, lastIndex, visible, selectionIndex))
return;
decorations.push(materializeDecoration(offset, offset + node.nodeSize));
@@ -124,9 +119,9 @@ function buildDecorations(
const set = DecorationSet.create(doc, decorations);
end();
profiler.count("virtualization.decorationBuilds");
profiler.gauge("virtualization.materializedBlocks", decorations.length);
profiler.gauge("virtualization.blocksInDoc", doc.childCount);
profiler.count("paging.decorationBuilds");
profiler.gauge("paging.materializedBlocks", decorations.length);
profiler.gauge("paging.blocksInDoc", doc.childCount);
return set;
}
@@ -185,30 +180,23 @@ function hasMaterializeDecoration(
*/
function repairSelection(
set: DecorationSet,
state: EditorState,
isPageable: IsPageable
state: EditorState
): DecorationSet {
const missing: Decoration[] = [];
for (const range of selectionRanges(state)) {
if (!isPageable(state.doc.child(range.index).type.name)) continue;
if (state.doc.child(range.index).type.name !== PAGE_NODE) continue;
if (hasMaterializeDecoration(set, range)) continue;
missing.push(materializeDecoration(range.from, range.to));
}
if (!missing.length) return set;
profiler.count("virtualization.decorationRepairs", missing.length);
profiler.count("paging.decorationRepairs", missing.length);
return set.add(state.doc, missing);
}
export function virtualizationPlugin(
unit: VirtualizationUnit = "blocks",
heightMap?: HeightMap
): Plugin<VirtualizationState> {
const types = unitTypes(unit);
const isPageable: IsPageable = (typeName) => types.includes(typeName);
return new Plugin<VirtualizationState>({
key: virtualizationKey,
export function viewportPlugin(heights: HeightMap): Plugin<ViewportState> {
return new Plugin<ViewportState>({
key: viewportKey,
state: {
init(_config, state) {
const selectionIndex = state.selection.$from.index(0);
@@ -219,13 +207,12 @@ export function virtualizationPlugin(
decorations: buildDecorations(
state.doc,
EMPTY_VISIBLE,
selectionIndex,
isPageable
selectionIndex
)
};
},
apply(tr, value, _oldState, newState) {
const meta = tr.getMeta(virtualizationKey) as
const meta = tr.getMeta(viewportKey) as
| { visible: Set<string> }
| undefined;
const visible = meta?.visible ?? value.visible;
@@ -236,7 +223,7 @@ export function virtualizationPlugin(
const structural = blockCount !== value.blockCount;
if (!meta && !selectionMoved && !structural && !tr.docChanged) {
profiler.count("virtualization.decorationReuses");
profiler.count("paging.decorationReuses");
return value;
}
@@ -245,33 +232,24 @@ export function virtualizationPlugin(
visible,
selectionIndex,
blockCount,
decorations: buildDecorations(
tr.doc,
visible,
selectionIndex,
isPageable
)
decorations: buildDecorations(tr.doc, visible, selectionIndex)
};
}
// Text-only edit: the block structure is unchanged, so the existing
// decorations only need their positions mapped instead of a full
// O(blocks x decorations) rebuild.
const end = profiler.start("virtualization.decorationMap");
const end = profiler.start("paging.decorationMap");
const mapped = repairSelection(
value.decorations.map(tr.mapping, tr.doc),
newState,
isPageable
newState
);
end();
profiler.count("virtualization.decorationMaps");
profiler.count("paging.decorationMaps");
return { visible, selectionIndex, blockCount, decorations: mapped };
}
},
props: {
decorations(state) {
return virtualizationKey.getState(state)?.decorations;
return viewportKey.getState(state)?.decorations;
}
},
view(editorView) {
@@ -281,12 +259,9 @@ export function virtualizationPlugin(
const measuredPages = new Set<string>();
const ensureScrollParent = () => {
// Resolved lazily: at view-init the document may not overflow yet.
const resolved = findScrollParent(editorView.dom);
if (!resolved || resolved === scrollParent) return;
scrollParent?.removeEventListener("scroll", schedule);
// Keep scroll anchoring on so a placeholder above the viewport growing
// to its real height does not shove the visible content.
resolved.style.overflowAnchor = "auto";
resolved.addEventListener("scroll", schedule, { passive: true });
scrollParent = resolved;
@@ -389,32 +364,32 @@ export function virtualizationPlugin(
const delta = element.getBoundingClientRect().top - pin.top;
if (!delta) return;
scrollParent.scrollTop += delta;
profiler.record("virtualization.pinCorrection", Math.abs(delta));
profiler.record("paging.pinCorrection", Math.abs(delta));
};
const flush = () => {
frame = 0;
const end = profiler.start("virtualization.measure");
const end = profiler.start("paging.measure");
ensureScrollParent();
const next = measure();
end();
profiler.count("virtualization.measures");
profiler.count("paging.measures");
if (!next) return;
const current = virtualizationKey.getState(editorView.state)?.visible;
const current = viewportKey.getState(editorView.state)?.visible;
if (current && sameSet(current, next)) {
profiler.count("virtualization.measuresUnchanged");
profiler.count("paging.measuresUnchanged");
return;
}
visible = next;
profiler.count("virtualization.visibilityFlushes");
profiler.gauge("virtualization.visibleBlocks", next.size);
profiler.count("paging.visibilityFlushes");
profiler.gauge("paging.visibleBlocks", next.size);
const pin = pinnedUnit();
editorView.dispatch(
editorView.state.tr
.setMeta(virtualizationKey, { visible: next })
.setMeta(viewportKey, { visible: next })
.setMeta("preventUpdate", true)
.setMeta("addToHistory", false)
);
@@ -430,7 +405,7 @@ export function virtualizationPlugin(
* place while they change.
*/
const resizePlaceholders = () => {
if (!heightMap?.needsRecalibration) return;
if (!heights.needsRecalibration) return;
const pin = pinnedUnit();
const children = editorView.dom.children;
const doc = editorView.state.doc;
@@ -438,11 +413,11 @@ export function virtualizationPlugin(
for (let i = 0; i < count; i++) {
const element = children[i] as HTMLElement;
if (!element.hasAttribute("data-virtual-placeholder")) continue;
element.style.height = `${heightMap.heightFor(doc.child(i))}px`;
element.style.height = `${heights.heightFor(doc.child(i))}px`;
}
heightMap.markRecalibrated();
heights.markRecalibrated();
restorePin(pin);
profiler.count("virtualization.placeholderResizes");
profiler.count("paging.placeholderResizes");
};
/**
@@ -450,13 +425,10 @@ export function virtualizationPlugin(
* its placeholder is the right size when it scrolls away again.
*/
const recordRenderedHeights = () => {
if (!heightMap) return;
// Read the layout the note is actually rendered in, so estimates for
// everything still off screen match what the reader will see.
const style = getComputedStyle(editorView.dom);
const fontSize = parseFloat(style.fontSize);
const lineHeight = parseFloat(style.lineHeight);
heightMap.setMetrics({
heights.setMetrics({
width: editorView.dom.clientWidth,
fontSize,
lineHeight: Number.isFinite(lineHeight) ? lineHeight : fontSize * 1.5
@@ -468,12 +440,8 @@ export function virtualizationPlugin(
const element = children[i] as HTMLElement;
if (element.hasAttribute("data-virtual-placeholder")) continue;
const node = doc.child(i);
heightMap.record(node, element.offsetHeight);
heights.record(node, element.offsetHeight);
// The blocks inside a rendered page are what the estimates are built
// from, and their ids outlive this session's page boundaries. Measure
// each page once; re-measuring on every flush would read layout for
// hundreds of elements for nothing.
const pageId = node.attrs.blockId as string | undefined;
if (node.type.name !== "page" || !pageId || measuredPages.has(pageId))
continue;
@@ -481,11 +449,11 @@ export function virtualizationPlugin(
const blocks = element.children;
const blockCount = Math.min(blocks.length, node.childCount);
for (let j = 0; j < blockCount; j++)
heightMap.record(
heights.record(
node.child(j),
(blocks[j] as HTMLElement).offsetHeight
);
profiler.count("virtualization.pagesMeasured");
profiler.count("paging.pagesMeasured");
}
};
@@ -496,9 +464,6 @@ export function virtualizationPlugin(
window.addEventListener("resize", schedule, { passive: true });
// Zooming or changing the font re-lays out the note without scrolling it
// and without touching the document, so nothing else would tell the
// window that what is on screen has moved.
let layout: ResizeObserver | undefined;
try {
if (typeof ResizeObserver !== "undefined") {
@@ -513,7 +478,6 @@ export function virtualizationPlugin(
return {
update() {
// Materializing changes block heights, which moves the window.
schedule();
},
destroy() {

View File

@@ -1,118 +0,0 @@
/*
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 { describe, expect, test } from "vitest";
import { Editor } from "@tiptap/core";
import StarterKit from "@tiptap/starter-kit";
import { EditorState, Plugin } from "@tiptap/pm/state";
import { DecorationSet } from "@tiptap/pm/view";
import { Virtualization, virtualizationKey } from "../index.js";
import { BlockId } from "../../block-id/block-id.js";
const BLOCKS = 60;
function html(n: number) {
let content = "";
for (let i = 0; i < n; i++)
content += `<p data-block-id="blk${i}">Paragraph number ${i}.</p>`;
return content;
}
function createEditor() {
return new Editor({
extensions: [
StarterKit,
BlockId,
Virtualization.configure({ enabled: true, thresholdBlocks: 10 })
],
content: html(BLOCKS)
});
}
function decorationsOf(editor: Editor, state: EditorState): DecorationSet {
const plugin = editor.state.plugins.find(
(p) => p.spec.key === virtualizationKey
) as Plugin;
const decorations = plugin.props.decorations as (
this: Plugin,
state: EditorState
) => DecorationSet;
return decorations.call(plugin, state);
}
describe("virtualization decoration cache", () => {
test("returns the identical set for an unchanged state", () => {
const editor = createEditor();
const first = decorationsOf(editor, editor.state);
const second = decorationsOf(editor, editor.state);
expect(second).toBe(first);
editor.destroy();
});
test("rebuilds when the document changes", () => {
const editor = createEditor();
const before = decorationsOf(editor, editor.state);
editor.view.dispatch(editor.state.tr.insertText("typed", 1));
const after = decorationsOf(editor, editor.state);
expect(after).not.toBe(before);
editor.destroy();
});
test("rebuilds when the visible set changes", () => {
const editor = createEditor();
const before = decorationsOf(editor, editor.state);
editor.view.dispatch(
editor.state.tr
.setMeta(virtualizationKey, { visible: new Set(["blk30"]) })
.setMeta("addToHistory", false)
);
const after = decorationsOf(editor, editor.state);
expect(after).not.toBe(before);
editor.destroy();
});
test("rebuilds when the selection moves to another block", () => {
const editor = createEditor();
const before = decorationsOf(editor, editor.state);
const target = editor.state.doc.resolve(1).after(1) + 1;
editor.commands.setTextSelection(target);
const after = decorationsOf(editor, editor.state);
expect(after).not.toBe(before);
editor.destroy();
});
test("keeps the cached set when a transaction changes nothing relevant", () => {
const editor = createEditor();
const before = decorationsOf(editor, editor.state);
editor.view.dispatch(editor.state.tr.setMeta("unrelated", true));
const after = decorationsOf(editor, editor.state);
expect(after).toBe(before);
editor.destroy();
});
});

View File

@@ -1,182 +0,0 @@
/*
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 { afterEach, describe, expect, test } from "vitest";
import { Editor } from "@tiptap/core";
import StarterKit from "@tiptap/starter-kit";
import { DecorationSet } from "@tiptap/pm/view";
import { Virtualization, virtualizationKey } from "../index.js";
import { BlockId } from "../../block-id/block-id.js";
import { profiler } from "../../../utils/profiler.js";
const BLOCKS = 40;
function savedNoteHTML(n: number, extra = "") {
let content = "";
for (let i = 0; i < n; i++)
content += `<p data-block-id="blk${i}">Paragraph number ${i}.</p>`;
return content + extra;
}
function createEditor(content: string) {
return new Editor({
extensions: [
StarterKit,
BlockId,
Virtualization.configure({ enabled: true, thresholdBlocks: 5 })
],
content
});
}
function decorationsOf(editor: Editor): DecorationSet {
return (
virtualizationKey.getState(editor.state)?.decorations ?? DecorationSet.empty
);
}
function counters() {
return profiler.report().counters;
}
function blockRange(editor: Editor, index: number) {
let from = 0;
for (let i = 0; i < index; i++) from += editor.state.doc.child(i).nodeSize;
return { from, to: from + editor.state.doc.child(index).nodeSize };
}
function isMaterialized(editor: Editor, index: number) {
const { from, to } = blockRange(editor, index);
return decorationsOf(editor)
.find(from, to)
.some(
(d) =>
d.from === from &&
d.to === to &&
(d.spec as { materialize?: boolean }).materialize
);
}
afterEach(() => {
profiler.disable();
profiler.reset();
});
describe("decoration updates", () => {
test("a text edit maps the decorations instead of rebuilding them", () => {
const editor = createEditor(savedNoteHTML(BLOCKS));
profiler.enable();
editor.view.dispatch(editor.state.tr.insertText("x", 2));
expect(counters()["virtualization.decorationMaps"]).toBeGreaterThanOrEqual(
1
);
expect(counters()["virtualization.decorationBuilds"]).toBeUndefined();
editor.destroy();
});
test("adding a block rebuilds the decorations", () => {
const editor = createEditor(savedNoteHTML(BLOCKS));
profiler.enable();
editor.commands.insertContentAt(2, "<p>new block</p>");
expect(
counters()["virtualization.decorationBuilds"]
).toBeGreaterThanOrEqual(1);
editor.destroy();
});
test("a transaction that changes nothing relevant reuses the state", () => {
const editor = createEditor(savedNoteHTML(BLOCKS));
profiler.enable();
editor.view.dispatch(editor.state.tr.setMeta("unrelated", true));
expect(counters()["virtualization.decorationReuses"]).toBe(1);
expect(counters()["virtualization.decorationBuilds"]).toBeUndefined();
expect(counters()["virtualization.decorationMaps"]).toBeUndefined();
editor.destroy();
});
test("the caret's block stays materialized while typing at its end", () => {
const editor = createEditor(savedNoteHTML(BLOCKS));
const endOfFirstBlock = editor.state.doc.child(0).nodeSize - 1;
editor.commands.setTextSelection(endOfFirstBlock);
profiler.enable();
for (let i = 0; i < 5; i++)
editor.view.dispatch(
editor.state.tr.insertText("a", editor.state.selection.from)
);
expect(isMaterialized(editor, 0)).toBe(true);
expect(counters()["virtualization.decorationBuilds"]).toBeUndefined();
editor.destroy();
});
test("mapped decorations keep covering the whole edited node", () => {
const editor = createEditor(savedNoteHTML(BLOCKS));
editor.commands.setTextSelection(3);
profiler.enable();
editor.view.dispatch(editor.state.tr.insertText("inserted", 3));
const { from, to } = blockRange(editor, 0);
const decoration = decorationsOf(editor)
.find(from, to)
.find((d) => (d.spec as { materialize?: boolean }).materialize);
expect(decoration?.from).toBe(from);
expect(decoration?.to).toBe(to);
editor.destroy();
});
test("non-pageable blocks get no decoration", () => {
const editor = createEditor(
savedNoteHTML(BLOCKS, "<pre><code>not pageable</code></pre>")
);
const lastIndex = editor.state.doc.childCount - 1;
expect(editor.state.doc.child(lastIndex).type.name).toBe("codeBlock");
expect(isMaterialized(editor, lastIndex)).toBe(false);
editor.destroy();
});
test("non-pageable blocks do not inflate the decoration set", () => {
let rules = "";
for (let i = 0; i < 100; i++) rules += "<hr>";
const editor = createEditor(savedNoteHTML(BLOCKS, rules));
expect(editor.state.doc.childCount).toBe(BLOCKS + 100);
expect(
decorationsOf(editor).find(0, editor.state.doc.content.size).length
).toBeLessThan(10);
editor.destroy();
});
test("indexes top-level positions once per document version", () => {
profiler.enable();
const editor = createEditor(savedNoteHTML(BLOCKS));
expect(editor.state.doc.childCount).toBe(BLOCKS);
expect(counters()["virtualization.topLevelIndexBuilds"]).toBe(1);
editor.destroy();
});
});

View File

@@ -1,112 +0,0 @@
/*
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 { describe, expect, test } from "vitest";
import { Editor } from "@tiptap/core";
import StarterKit from "@tiptap/starter-kit";
import { Virtualization } from "../index.js";
import { BlockId } from "../../block-id/block-id.js";
const BLOCKS = 400;
/**
* Mirrors a saved Notesnook note: BlockId renders `data-block-id` into the
* stored HTML, and paging keys off it.
*/
function savedNoteHTML(n: number) {
let html = "";
for (let i = 0; i < n; i++)
html += `<p data-block-id="blk${i}">Paragraph number ${i} with filler.</p>`;
return html;
}
/** A legacy/imported/pasted note, whose HTML has no block ids yet. */
function unidentifiedHTML(n: number) {
let html = "";
for (let i = 0; i < n; i++)
html += `<p>Paragraph number ${i} with filler.</p>`;
return html;
}
function makeEditor(enabled: boolean, content = savedNoteHTML(BLOCKS)) {
return new Editor({
element: document.createElement("div"),
content,
extensions: [
StarterKit,
// Paging keys off blockId: a block without one always materializes
// (see viewport-plugin), so this is required, not incidental.
BlockId,
Virtualization.configure({ enabled, thresholdBlocks: 50 })
]
});
}
function placeholderCount(editor: Editor) {
return editor.view.dom.querySelectorAll("[data-virtual-placeholder]").length;
}
describe("virtualization: the first view", () => {
test("the view built by the Editor constructor is already virtualized", () => {
const editor = makeEditor(true);
// Nothing has run except the constructor - no React effect, no manual
// createView(). If this passes, the document was never mounted in full.
expect(editor.state.doc.childCount).toBe(BLOCKS);
expect(placeholderCount(editor)).toBeGreaterThan(0);
editor.destroy();
});
test("recreating the view keeps virtualization installed", () => {
const editor = makeEditor(true);
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore - mirrors what useEditor does on every deps change
editor.createView();
expect(editor.state.doc.childCount).toBe(BLOCKS);
expect(placeholderCount(editor)).toBeGreaterThan(0);
editor.destroy();
});
test("nothing is virtualized when the extension is disabled", () => {
const editor = makeEditor(false);
expect(editor.state.doc.childCount).toBe(BLOCKS);
expect(placeholderCount(editor)).toBe(0);
editor.destroy();
});
// KNOWN GAP, not desired behaviour. BlockId assigns ids from an
// appendTransaction, which does not run during construction, so a note whose
// stored HTML has no data-block-id renders in full on its first view. The fix
// is to assign block ids at parse/load time - see
// docs/editor-performance/05-per-transaction-work.md section 4.1. When that
// lands, this expectation should flip to toBeGreaterThan(0).
test("a note with no block ids is not yet paged on its first view", () => {
const editor = makeEditor(true, unidentifiedHTML(BLOCKS));
expect(editor.state.doc.childCount).toBe(BLOCKS);
expect(placeholderCount(editor)).toBe(0);
editor.destroy();
});
});

View File

@@ -1,131 +0,0 @@
/*
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 { Editor, Extension } from "@tiptap/core";
import { HeightMap } from "./height-map.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. */
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;
};
/**
* Renders only the top-level blocks near the viewport, keeping the rest in
* editor state as content-less placeholders. This is the "hidden paging" from
* docs/editor-performance — the only lever that reduces the browser layout/paint
* cost of a very large document.
*
* High risk (breaks browser find-in-page and printing without the companion
* work). Disabled by default; enable per-note above a size threshold.
*/
export const Virtualization = Extension.create<VirtualizationOptions>({
name: "virtualization",
addOptions() {
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()
};
},
// Runs immediately before the Editor constructor creates its first view, so
// that view is already virtualized. Installing any later (e.g. from
// useEditor's effect) means the whole document gets mounted unvirtualized
// once, which is the exact cost virtualization exists to avoid.
onBeforeCreate() {
installVirtualization(this.editor);
},
addProseMirrorPlugins() {
if (!this.options.enabled) return [];
return [virtualizationPlugin(this.options.unit, this.storage.heightMap)];
}
});
/**
* Wraps the editor's node views with the virtualization layer.
*
* Called from the extension's own `onBeforeCreate`, which fires before the
* Editor constructor's `createView()` — so the very first view is virtualized.
*
* A ProseMirror plugin cannot do this: prosemirror-view consults the view's own
* `nodeViews` prop before any plugin (buildNodeViews is first-wins), and Tiptap
* overwrites `editorProps.nodeViews` with `extensionManager.nodeViews` via
* setProps right after construction. So we decorate the getter at its source.
*
* The patch lives on the extensionManager instance, which outlives individual
* views, so later `createView()` calls pick it up without reinstalling.
*/
export function installVirtualization(editor: Editor): void {
const storage = editor.storage.virtualization as
| VirtualizationStorage
| undefined;
if (!storage?.enabled) return;
const manager = editor.extensionManager as unknown as Record<string, unknown>;
if (Object.prototype.hasOwnProperty.call(manager, "nodeViews")) return;
const proto = Object.getPrototypeOf(editor.extensionManager);
const descriptor = Object.getOwnPropertyDescriptor(proto, "nodeViews");
const originalGetter = descriptor?.get;
if (!originalGetter) return;
Object.defineProperty(editor.extensionManager, "nodeViews", {
configurable: true,
get() {
return withVirtualization(
originalGetter.call(this),
storage.heightMap,
storage.thresholdBlocks,
storage.unit
);
}
});
}
export { HeightMap } from "./height-map.js";
export { virtualizationKey } from "./viewport-plugin.js";
export {
getScrollAnchor,
restoreScrollAnchor,
type ScrollAnchor
} from "./anchor.js";

View File

@@ -1,267 +0,0 @@
/*
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 { DOMSerializer, Node as ProsemirrorNode } from "@tiptap/pm/model";
import {
Decoration,
DecorationSource,
EditorView,
NodeView,
NodeViewConstructor
} from "@tiptap/pm/view";
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",
"blockquote",
"bulletList",
"orderedList",
"checkList",
"taskList",
"outlineList",
"codeblock",
"table",
"image",
"webclip",
"embed",
"mathBlock",
"callout"
];
function isMaterialized(decorations: readonly Decoration[]): boolean {
return decorations.some(
(d) => (d.spec as { materialize?: boolean })?.materialize
);
}
const topLevelOffsets = new WeakMap<ProsemirrorNode, Set<number>>();
function topLevelOffsetsOf(doc: ProsemirrorNode): Set<number> {
const cached = topLevelOffsets.get(doc);
if (cached) return cached;
const end = profiler.start("virtualization.topLevelIndex");
const offsets = new Set<number>();
doc.forEach((_node, offset) => offsets.add(offset));
end();
profiler.count("virtualization.topLevelIndexBuilds");
topLevelOffsets.set(doc, offsets);
return offsets;
}
function isTopLevel(
view: EditorView,
getPos: () => number | undefined
): boolean {
const pos = getPos();
if (pos == null) return false;
return topLevelOffsetsOf(view.state.doc).has(pos);
}
let placeholderTemplate: HTMLDivElement | undefined;
function createPlaceholderElement(): HTMLDivElement {
if (!placeholderTemplate) {
placeholderTemplate = document.createElement("div");
placeholderTemplate.setAttribute("data-virtual-placeholder", "true");
placeholderTemplate.style.width = "100%";
// an empty, explicitly sized box: tell the browser its (absent) contents
// can never affect layout elsewhere, so it stays out of layout cascades.
placeholderTemplate.style.contain = "strict";
}
return placeholderTemplate.cloneNode(false) as HTMLDivElement;
}
/**
* A content-less placeholder. ProseMirror keeps the node in state but renders
* nothing for its children, so the browser lays out and paints only an empty
* box of the node's estimated height. See prosemirror-view domFromPos /
* ignoreMutation handling of contentDOM-less node views.
*/
function createPlaceholder(
node: ProsemirrorNode,
getPos: () => number | undefined,
heightMap: HeightMap
): NodeView {
profiler.count("virtualization.nodeView.placeholderCreated");
const dom = createPlaceholderElement();
const blockId = node.attrs.blockId as string | undefined;
if (blockId) dom.setAttribute("data-block-id", blockId);
dom.style.height = `${heightMap.heightFor(node)}px`;
return {
dom,
contentDOM: null,
update(updatedNode: ProsemirrorNode, decorations: readonly Decoration[]) {
if (updatedNode.type !== node.type) return false;
if (isMaterialized(decorations)) {
profiler.count("virtualization.materialized");
return false;
}
node = updatedNode;
dom.style.height = `${heightMap.heightFor(updatedNode)}px`;
return true;
},
ignoreMutation() {
return true;
}
};
}
/**
* Renders a block that has no custom node view (paragraph, heading, etc.) the
* same way ProseMirror would by default — via the schema's DOM spec — so it can
* be materialized/dematerialized on viewport entry like the custom ones.
*/
function createMaterializedDefault(
node: ProsemirrorNode,
heightMap: HeightMap
): NodeView {
const spec = node.type.spec.toDOM?.(node);
if (!spec) {
const dom = document.createElement("div");
return { dom };
}
const { dom, contentDOM } = DOMSerializer.renderSpec(document, spec);
const record = () => {
if (dom instanceof HTMLElement) heightMap.record(node, dom.offsetHeight);
};
return {
dom,
contentDOM,
update(updatedNode: ProsemirrorNode, decorations: readonly Decoration[]) {
if (updatedNode.type !== node.type) return false;
if (!isMaterialized(decorations)) {
profiler.count("virtualization.dematerialized");
return false;
}
if (!node.sameMarkup(updatedNode)) return false;
node = updatedNode;
record();
return true;
},
destroy() {
record();
}
};
}
/**
* Wraps a custom node view so it de-materializes (returns false -> rebuild as a
* placeholder) when its materialize decoration disappears, and records its real
* height. The custom view keeps full ownership while materialized.
*/
function wrapCustom(
inner: NodeView,
node: ProsemirrorNode,
heightMap: HeightMap
): NodeView {
const originalUpdate = inner.update?.bind(inner);
const originalDestroy = inner.destroy?.bind(inner);
const record = () => {
if (inner.dom instanceof HTMLElement)
heightMap.record(node, inner.dom.offsetHeight);
};
inner.update = (
updatedNode: ProsemirrorNode,
decorations: readonly Decoration[],
innerDecorations: DecorationSource
) => {
if (!isMaterialized(decorations)) {
profiler.count("virtualization.dematerialized");
return false;
}
node = updatedNode;
record();
return originalUpdate
? originalUpdate(updatedNode, decorations, innerDecorations)
: updatedNode.type === node.type;
};
inner.destroy = () => {
record();
originalDestroy?.();
};
return inner;
}
export function withVirtualization(
nodeViews: Record<string, NodeViewConstructor>,
heightMap: HeightMap,
thresholdBlocks: number,
unit: VirtualizationUnit = "blocks"
): Record<string, NodeViewConstructor> {
const wrapped: Record<string, NodeViewConstructor> = { ...nodeViews };
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);
// 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");
return inner
? inner(node, view, getPos, decorations, innerDecorations)
: createMaterializedDefault(node, heightMap);
}
if (materialize) {
profiler.count("virtualization.nodeView.materializedCreated");
return inner
? wrapCustom(
inner(node, view, getPos, decorations, innerDecorations),
node,
heightMap
)
: createMaterializedDefault(node, heightMap);
}
return createPlaceholder(
node,
getPos as () => number | undefined,
heightMap
);
};
}
return wrapped;
}

View File

@@ -83,7 +83,6 @@ import CheckList from "./extensions/check-list/index.js";
import CheckListItem from "./extensions/check-list-item/index.js";
import { Callout } from "./extensions/callout/index.js";
import BlockId from "./extensions/block-id/index.js";
import { Virtualization } from "./extensions/virtualization/index.js";
import { EditorProfiler } from "./extensions/profiler/index.js";
import { Page, Paging } from "./extensions/paging/index.js";
import { useEditorSearchStore } from "./toolbar/stores/search-store.js";
@@ -141,7 +140,8 @@ export type TiptapOptions = EditorOptions &
isMobile?: boolean;
doubleSpacedLines?: boolean;
enableFontLigatures?: boolean;
virtualization?: VirtualizationMode | boolean;
/** Render only the pages near the viewport. */
virtualization?: boolean;
/** How many top-level blocks make up a page when paging is on. */
pageSize?: number;
} & {
@@ -198,7 +198,6 @@ const useTiptap = (
}, [closeAllPopups]);
const defaultOptions = useMemo<Partial<EditorOptions>>(() => {
const mode = toVirtualizationMode(virtualization);
return {
enableCoreExtensions: false,
editorProps: {
@@ -285,12 +284,8 @@ const useTiptap = (
BlockId,
PagedDocument,
Page,
Virtualization.configure({
enabled: mode !== "off",
unit: mode === "pages" ? "pages" : "blocks"
}),
Paging.configure({
enabled: mode === "pages",
enabled: !!virtualization,
...(pageSize ? { pageSize } : {})
}),
EditorProfiler,
@@ -472,16 +467,6 @@ const PagedDocument = TiptapNode.create({
content: "(page | block)+"
});
export type VirtualizationMode = "off" | "blocks" | "pages";
export function toVirtualizationMode(
value: VirtualizationMode | boolean | undefined
): VirtualizationMode {
if (value === true) return "blocks";
if (!value) return "off";
return value;
}
function hasStyle(element: HTMLElement | string) {
const style = (element as HTMLElement).getAttribute("style");
if (!style || style === "font-family: inherit;") return false;
@@ -510,7 +495,7 @@ export {
getScrollAnchor,
restoreScrollAnchor,
type ScrollAnchor
} from "./extensions/virtualization/index.js";
} from "./extensions/paging/index.js";
export * from "./utils/downloader.js";
export {
useTiptap,

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -2479,9 +2479,6 @@ Use this if changes from other devices are not appearing on this device. This wi
editorVirtualization: () => t`Editor paging (experimental)`,
editorVirtualizationDesc: () =>
t`Only render the part of a note that is currently on screen. Makes very large notes much faster to open, scroll and type in. While this is on, your browser's find (Ctrl+F) and printing will only cover the visible part of a note — use the editor's own search instead. If you face any issues, please turn it off.`,
editorVirtualizationOff: () => t`Off`,
editorVirtualizationBlocks: () => t`Blocks`,
editorVirtualizationPages: () => t`Pages`,
expandSidebar: () => t`Expand sidebar`,
viewAllLimits: () => `View all limits`,
freePlan: () => t`Free plan`,