diff --git a/packages/editor/src/extensions/virtualization/height-map.ts b/packages/editor/src/extensions/virtualization/height-map.ts
new file mode 100644
index 000000000..d30bc3b1f
--- /dev/null
+++ b/packages/editor/src/extensions/virtualization/height-map.ts
@@ -0,0 +1,73 @@
+/*
+This file is part of the Notesnook project (https://notesnook.com/)
+
+Copyright (C) 2023 Streetwriters (Private) Limited
+
+This program is free software: you can redistribute it and/or modify
+it under the terms of the GNU General Public License as published by
+the Free Software Foundation, either version 3 of the License, or
+(at your option) any later version.
+
+This program is distributed in the hope that it will be useful,
+but WITHOUT ANY WARRANTY; without even the implied warranty of
+MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+GNU General Public License for more details.
+
+You should have received a copy of the GNU General Public License
+along with this program. If not, see .
+*/
+
+import { Node as ProsemirrorNode } from "@tiptap/pm/model";
+
+const DEFAULT_ESTIMATES: Record = {
+ paragraph: 24,
+ heading: 40,
+ blockquote: 60,
+ bulletList: 120,
+ orderedList: 120,
+ checkList: 120,
+ taskList: 120,
+ outlineList: 120,
+ codeblock: 200,
+ table: 300,
+ image: 240,
+ webclip: 240,
+ embed: 240,
+ mathBlock: 60,
+ callout: 120
+};
+
+const FALLBACK_ESTIMATE = 40;
+
+export class HeightMap {
+ private measured = new Map();
+
+ estimate(node: ProsemirrorNode): number {
+ return DEFAULT_ESTIMATES[node.type.name] ?? FALLBACK_ESTIMATE;
+ }
+
+ heightFor(node: ProsemirrorNode): number {
+ const blockId = node.attrs.blockId as string | undefined;
+ if (blockId && this.measured.has(blockId)) {
+ return this.measured.get(blockId) as number;
+ }
+ return this.estimate(node);
+ }
+
+ record(node: ProsemirrorNode, height: number): void {
+ const blockId = node.attrs.blockId as string | undefined;
+ if (!blockId || !Number.isFinite(height) || height <= 0) return;
+ this.measured.set(blockId, Math.round(height));
+ }
+
+ toJSON(): Record {
+ return Object.fromEntries(this.measured);
+ }
+
+ load(data: Record | undefined): void {
+ if (!data) return;
+ for (const [id, height] of Object.entries(data)) {
+ if (Number.isFinite(height) && height > 0) this.measured.set(id, height);
+ }
+ }
+}
diff --git a/packages/editor/src/extensions/virtualization/index.ts b/packages/editor/src/extensions/virtualization/index.ts
new file mode 100644
index 000000000..4ebb81552
--- /dev/null
+++ b/packages/editor/src/extensions/virtualization/index.ts
@@ -0,0 +1,96 @@
+/*
+This file is part of the Notesnook project (https://notesnook.com/)
+
+Copyright (C) 2023 Streetwriters (Private) Limited
+
+This program is free software: you can redistribute it and/or modify
+it under the terms of the GNU General Public License as published by
+the Free Software Foundation, either version 3 of the License, or
+(at your option) any later version.
+
+This program is distributed in the hope that it will be useful,
+but WITHOUT ANY WARRANTY; without even the implied warranty of
+MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+GNU General Public License for more details.
+
+You should have received a copy of the GNU General Public License
+along with this program. If not, see .
+*/
+
+import { Editor, Extension } from "@tiptap/core";
+import { HeightMap } from "./height-map.js";
+import { withVirtualization } from "./node-views.js";
+import { virtualizationPlugin } from "./viewport-plugin.js";
+
+export type VirtualizationOptions = {
+ enabled: boolean;
+};
+
+export type VirtualizationStorage = {
+ enabled: boolean;
+ 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({
+ name: "virtualization",
+
+ addOptions() {
+ return { enabled: false };
+ },
+
+ addStorage(): VirtualizationStorage {
+ return {
+ enabled: this.options.enabled,
+ heightMap: new HeightMap()
+ };
+ },
+
+ addProseMirrorPlugins() {
+ if (!this.options.enabled) return [];
+ return [virtualizationPlugin()];
+ }
+});
+
+/**
+ * Wraps the editor's node views with the virtualization layer. Must run before
+ * the view is (re)created. A ProseMirror plugin cannot do this — prosemirror-view
+ * consults the view's own `nodeViews` prop before any plugin (buildNodeViews is
+ * first-wins) — so we decorate `extensionManager.nodeViews` at its source.
+ */
+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
+ >;
+ // already installed on this instance
+ 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);
+ }
+ });
+}
+
+export { HeightMap } from "./height-map.js";
+export { virtualizationKey } from "./viewport-plugin.js";
diff --git a/packages/editor/src/extensions/virtualization/node-views.ts b/packages/editor/src/extensions/virtualization/node-views.ts
new file mode 100644
index 000000000..0ae7d1ced
--- /dev/null
+++ b/packages/editor/src/extensions/virtualization/node-views.ts
@@ -0,0 +1,212 @@
+/*
+This file is part of the Notesnook project (https://notesnook.com/)
+
+Copyright (C) 2023 Streetwriters (Private) Limited
+
+This program is free software: you can redistribute it and/or modify
+it under the terms of the GNU General Public License as published by
+the Free Software Foundation, either version 3 of the License, or
+(at your option) any later version.
+
+This program is distributed in the hope that it will be useful,
+but WITHOUT ANY WARRANTY; without even the implied warranty of
+MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+GNU General Public License for more details.
+
+You should have received a copy of the GNU General Public License
+along with this program. If not, see .
+*/
+
+import { DOMSerializer, Node as ProsemirrorNode } from "@tiptap/pm/model";
+import {
+ Decoration,
+ DecorationSource,
+ EditorView,
+ NodeView,
+ NodeViewConstructor
+} from "@tiptap/pm/view";
+import { HeightMap } from "./height-map.js";
+
+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);
+}
+
+function isTopLevel(view: EditorView, getPos: () => number | undefined): boolean {
+ const pos = getPos();
+ if (pos == null || pos < 0 || pos > view.state.doc.content.size) return false;
+ return view.state.doc.resolve(pos).depth === 0;
+}
+
+/**
+ * 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 {
+ const dom = document.createElement("div");
+ dom.setAttribute("data-virtual-placeholder", "true");
+ const blockId = node.attrs.blockId as string | undefined;
+ if (blockId) dom.setAttribute("data-block-id", blockId);
+ dom.style.height = `${heightMap.heightFor(node)}px`;
+ dom.style.width = "100%";
+
+ return {
+ dom,
+ // children are never rendered
+ contentDOM: null,
+ update(updatedNode: ProsemirrorNode, decorations: readonly Decoration[]) {
+ if (updatedNode.type !== node.type) return false;
+ // switch to the real node once it enters the viewport
+ if (isMaterialized(decorations)) 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) {
+ // leaf-like or spec-less node: fall back to an empty box
+ 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;
+ // scrolled out of view -> rebuild as a placeholder
+ if (!isMaterialized(decorations)) return false;
+ // attribute/mark change -> let ProseMirror rebuild the DOM
+ 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)) 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,
+ heightMap: HeightMap
+): Record {
+ const wrapped: Record = { ...nodeViews };
+
+ for (const type of TOP_LEVEL_BLOCK_TYPES) {
+ const inner = nodeViews[type];
+ wrapped[type] = (node, view, getPos, decorations, innerDecorations) => {
+ const topLevel = isTopLevel(view, getPos as () => number | undefined);
+ const materialize = isMaterialized(decorations);
+
+ // Nested instances (inside callouts, tables, list items) are never
+ // virtualized — only the outermost blocks are paged.
+ if (!topLevel) {
+ return inner
+ ? inner(node, view, getPos, decorations, innerDecorations)
+ : createMaterializedDefault(node, heightMap);
+ }
+
+ if (materialize) {
+ return inner
+ ? wrapCustom(
+ inner(node, view, getPos, decorations, innerDecorations),
+ node,
+ heightMap
+ )
+ : createMaterializedDefault(node, heightMap);
+ }
+
+ return createPlaceholder(
+ node,
+ getPos as () => number | undefined,
+ heightMap
+ );
+ };
+ }
+
+ return wrapped;
+}
diff --git a/packages/editor/src/extensions/virtualization/viewport-plugin.ts b/packages/editor/src/extensions/virtualization/viewport-plugin.ts
new file mode 100644
index 000000000..3fe14d8fe
--- /dev/null
+++ b/packages/editor/src/extensions/virtualization/viewport-plugin.ts
@@ -0,0 +1,172 @@
+/*
+This file is part of the Notesnook project (https://notesnook.com/)
+
+Copyright (C) 2023 Streetwriters (Private) Limited
+
+This program is free software: you can redistribute it and/or modify
+it under the terms of the GNU General Public License as published by
+the Free Software Foundation, either version 3 of the License, or
+(at your option) any later version.
+
+This program is distributed in the hope that it will be useful,
+but WITHOUT ANY WARRANTY; without even the implied warranty of
+MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+GNU General Public License for more details.
+
+You should have received a copy of the GNU General Public License
+along with this program. If not, see .
+*/
+
+import { Plugin, PluginKey } from "@tiptap/pm/state";
+import { Decoration, DecorationSet, EditorView } from "@tiptap/pm/view";
+import { TOP_LEVEL_BLOCK_TYPES } from "./node-views.js";
+
+export const virtualizationKey = new PluginKey(
+ "notesnook-virtualization"
+);
+
+type VirtualizationState = {
+ visible: Set;
+};
+
+function isPageable(typeName: string): boolean {
+ return TOP_LEVEL_BLOCK_TYPES.includes(typeName);
+}
+
+function findScrollParent(node: HTMLElement): HTMLElement | null {
+ let current: HTMLElement | null = node.parentElement;
+ while (current) {
+ const overflowY = getComputedStyle(current).overflowY;
+ if (
+ (overflowY === "auto" || overflowY === "scroll") &&
+ current.scrollHeight > current.clientHeight
+ ) {
+ return current;
+ }
+ current = current.parentElement;
+ }
+ return null;
+}
+
+function sameSet(a: Set, b: Set): boolean {
+ if (a.size !== b.size) return false;
+ for (const value of a) if (!b.has(value)) return false;
+ return true;
+}
+
+export function virtualizationPlugin(): Plugin {
+ return new Plugin({
+ key: virtualizationKey,
+ state: {
+ init: () => ({ visible: new Set() }),
+ apply(tr, value) {
+ const meta = tr.getMeta(virtualizationKey) as
+ | VirtualizationState
+ | undefined;
+ return meta ?? value;
+ }
+ },
+ props: {
+ decorations(state) {
+ const pluginState = virtualizationKey.getState(state);
+ const visible = pluginState?.visible ?? new Set();
+ const doc = state.doc;
+
+ // Top-level index of the selection so we can always keep the block the
+ // caret is in (and its neighbours) rendered.
+ const selectionIndex = state.selection.$from.index(0);
+
+ const decorations: Decoration[] = [];
+ let index = -1;
+ doc.forEach((node, offset) => {
+ index++;
+ const nearSelection = Math.abs(index - selectionIndex) <= 1;
+ const isEdge = index === 0 || index === doc.childCount - 1;
+ const blockId = node.attrs.blockId as string | undefined;
+
+ const materialize =
+ !isPageable(node.type.name) ||
+ isEdge ||
+ nearSelection ||
+ (blockId ? visible.has(blockId) : true);
+
+ if (materialize) {
+ decorations.push(
+ Decoration.node(
+ offset,
+ offset + node.nodeSize,
+ {},
+ { materialize: true }
+ )
+ );
+ }
+ });
+
+ return DecorationSet.create(doc, decorations);
+ }
+ },
+ view(editorView) {
+ let observer: IntersectionObserver | null = null;
+ let frame = 0;
+ const intersecting = new Set();
+
+ const scrollParent = findScrollParent(editorView.dom);
+ if (scrollParent) scrollParent.style.overflowAnchor = "none";
+
+ const flush = () => {
+ frame = 0;
+ const current = virtualizationKey.getState(editorView.state)?.visible;
+ const next = new Set(intersecting);
+ if (current && sameSet(current, next)) return;
+ editorView.dispatch(
+ editorView.state.tr.setMeta(virtualizationKey, { visible: next })
+ );
+ };
+
+ const schedule = () => {
+ if (frame) return;
+ frame = requestAnimationFrame(flush);
+ };
+
+ const onIntersect: IntersectionObserverCallback = (entries) => {
+ for (const entry of entries) {
+ const blockId = (entry.target as HTMLElement).getAttribute(
+ "data-block-id"
+ );
+ if (!blockId) continue;
+ if (entry.isIntersecting) intersecting.add(blockId);
+ else intersecting.delete(blockId);
+ }
+ schedule();
+ };
+
+ const observe = () => {
+ observer?.disconnect();
+ observer = new IntersectionObserver(onIntersect, {
+ root: scrollParent,
+ // one viewport of overscan in each direction
+ rootMargin: "100% 0px 100% 0px",
+ threshold: 0
+ });
+ for (const child of Array.from(editorView.dom.children)) {
+ if (child instanceof HTMLElement) observer.observe(child);
+ }
+ };
+
+ observe();
+
+ return {
+ update(view, prevState) {
+ // Re-observe whenever the document structure changed, since
+ // materialize/dematerialize replaces the top-level DOM elements.
+ if (!prevState.doc.eq(view.state.doc)) observe();
+ },
+ destroy() {
+ if (frame) cancelAnimationFrame(frame);
+ observer?.disconnect();
+ observer = null;
+ }
+ };
+ }
+ });
+}
diff --git a/packages/editor/src/hooks/use-editor.ts b/packages/editor/src/hooks/use-editor.ts
index e8d6b83b7..e8f63d30f 100644
--- a/packages/editor/src/hooks/use-editor.ts
+++ b/packages/editor/src/hooks/use-editor.ts
@@ -23,6 +23,7 @@ import { Editor } from "../types.js";
import { useToolbarStore } from "../toolbar/stores/toolbar-store.js";
import { EditorView } from "@tiptap/pm/view";
import { useEditorSearchStore } from "../toolbar/stores/search-store.js";
+import { installVirtualization } from "../extensions/virtualization/index.js";
function useForceUpdate() {
const [, setValue] = useState(0);
@@ -50,6 +51,7 @@ export const useEditor = (
const oldIsFocused = editor.isFocused;
destroyView(editor.view);
+ installVirtualization(editor);
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore instead of creating a new editor, we just create
// a new view. Due to some reason this is faster than resetting
diff --git a/packages/editor/src/index.ts b/packages/editor/src/index.ts
index 18f720993..229854a69 100644
--- a/packages/editor/src/index.ts
+++ b/packages/editor/src/index.ts
@@ -82,6 +82,7 @@ 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 { useEditorSearchStore } from "./toolbar/stores/search-store.js";
import { DiffHighlighter } from "./extensions/diff-highlighter/index.js";
import { getChangedNodes } from "./utils/prosemirror.js";
@@ -137,6 +138,7 @@ export type TiptapOptions = EditorOptions &
isMobile?: boolean;
doubleSpacedLines?: boolean;
enableFontLigatures?: boolean;
+ virtualization?: boolean;
} & {
placeholder: string;
};
@@ -164,6 +166,7 @@ const useTiptap = (
downloadOptions,
editorProps,
enableFontLigatures,
+ virtualization,
...restOptions
} = options;
@@ -272,6 +275,7 @@ const useTiptap = (
}
}),
BlockId,
+ Virtualization.configure({ enabled: !!virtualization }),
Blockquote,
CharacterCount,
Underline,
@@ -424,7 +428,8 @@ const useTiptap = (
timeFormat,
editorProps,
copyToClipboard,
- createInternalLink
+ createInternalLink,
+ virtualization
]
);