editor: add opt-in block virtualization (hidden paging)

This commit is contained in:
Ammar Ahmed
2026-08-21 10:28:41 +05:00
parent 5d03d3033f
commit d789b4e65a
6 changed files with 561 additions and 1 deletions

View File

@@ -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 <http://www.gnu.org/licenses/>.
*/
import { Node as ProsemirrorNode } from "@tiptap/pm/model";
const DEFAULT_ESTIMATES: Record<string, number> = {
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<string, number>();
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<string, number> {
return Object.fromEntries(this.measured);
}
load(data: Record<string, number> | undefined): void {
if (!data) return;
for (const [id, height] of Object.entries(data)) {
if (Number.isFinite(height) && height > 0) this.measured.set(id, height);
}
}
}

View File

@@ -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 <http://www.gnu.org/licenses/>.
*/
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<VirtualizationOptions>({
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";

View File

@@ -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 <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 { 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<string, NodeViewConstructor>,
heightMap: HeightMap
): Record<string, NodeViewConstructor> {
const wrapped: Record<string, NodeViewConstructor> = { ...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;
}

View File

@@ -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 <http://www.gnu.org/licenses/>.
*/
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<VirtualizationState>(
"notesnook-virtualization"
);
type VirtualizationState = {
visible: Set<string>;
};
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<string>, b: Set<string>): 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<VirtualizationState> {
return new Plugin<VirtualizationState>({
key: virtualizationKey,
state: {
init: () => ({ visible: new Set<string>() }),
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<string>();
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<string>();
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;
}
};
}
});
}

View File

@@ -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

View File

@@ -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
]
);