mirror of
https://github.com/streetwriters/notesnook.git
synced 2026-09-02 04:01:43 +02:00
editor: drop block virtualization and fold paging into one extension
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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 />,
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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";
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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)
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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";
|
||||
|
||||
110
packages/editor/src/extensions/paging/page-view.ts
Normal file
110
packages/editor/src/extensions/paging/page-view.ts
Normal 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);
|
||||
}
|
||||
@@ -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);
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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() {
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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";
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
@@ -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`,
|
||||
|
||||
Reference in New Issue
Block a user