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