mirror of
https://github.com/streetwriters/notesnook.git
synced 2026-09-01 19:49:54 +02:00
editor: add opt-in block virtualization (hidden paging)
This commit is contained in:
73
packages/editor/src/extensions/virtualization/height-map.ts
Normal file
73
packages/editor/src/extensions/virtualization/height-map.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
96
packages/editor/src/extensions/virtualization/index.ts
Normal file
96
packages/editor/src/extensions/virtualization/index.ts
Normal 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";
|
||||
212
packages/editor/src/extensions/virtualization/node-views.ts
Normal file
212
packages/editor/src/extensions/virtualization/node-views.ts
Normal 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;
|
||||
}
|
||||
172
packages/editor/src/extensions/virtualization/viewport-plugin.ts
Normal file
172
packages/editor/src/extensions/virtualization/viewport-plugin.ts
Normal 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;
|
||||
}
|
||||
};
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
]
|
||||
);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user