mirror of
https://github.com/streetwriters/notesnook.git
synced 2025-12-23 06:59:31 +01:00
editor: fix performance issues due to using portals for react node views
This commit is contained in:
committed by
Abdullah Atta
parent
716239abfd
commit
a8db65b5af
@@ -87,6 +87,7 @@ function saveContent(noteId: string, ignoreEdit: boolean, content: string) {
|
||||
const deferredSave = debounceWithId(saveContent, 100);
|
||||
|
||||
export default function TabsView() {
|
||||
const sessions = useEditorStore((store) => store.sessions);
|
||||
const documentPreview = useEditorStore((store) => store.documentPreview);
|
||||
const activeSessionId = useEditorStore((store) => store.activeSessionId);
|
||||
const arePropertiesVisible = useEditorStore(
|
||||
@@ -95,7 +96,6 @@ export default function TabsView() {
|
||||
const isTOCVisible = useEditorStore((store) => store.isTOCVisible);
|
||||
const [dropRef, overlayRef] = useDragOverlay();
|
||||
|
||||
const sessions = useEditorStore.getState().sessions;
|
||||
return (
|
||||
<>
|
||||
{IS_DESKTOP_APP ? (
|
||||
|
||||
@@ -24,7 +24,6 @@ import "@notesnook/editor/styles/fonts.css";
|
||||
import {
|
||||
Toolbar,
|
||||
useTiptap,
|
||||
PortalProvider,
|
||||
Editor,
|
||||
AttachmentType,
|
||||
usePermissionHandler,
|
||||
@@ -41,7 +40,6 @@ import {
|
||||
import { Box, Flex } from "@theme-ui/components";
|
||||
import {
|
||||
PropsWithChildren,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useLayoutEffect,
|
||||
useMemo,
|
||||
@@ -61,7 +59,6 @@ import { writeToClipboard } from "../../utils/clipboard";
|
||||
import { useEditorStore } from "../../stores/editor-store";
|
||||
import { parseInternalLink } from "@notesnook/core";
|
||||
import Skeleton from "react-loading-skeleton";
|
||||
import { showToast } from "../../utils/toast";
|
||||
|
||||
export type OnChangeHandler = (
|
||||
content: () => string,
|
||||
@@ -402,58 +399,56 @@ function TiptapWrapper(
|
||||
}, [theme]);
|
||||
|
||||
return (
|
||||
<PortalProvider>
|
||||
<Flex
|
||||
ref={containerRef}
|
||||
sx={{
|
||||
flex: 1,
|
||||
flexDirection: "column",
|
||||
".tiptap.ProseMirror": { pb: 150 }
|
||||
<Flex
|
||||
ref={containerRef}
|
||||
sx={{
|
||||
flex: 1,
|
||||
flexDirection: "column",
|
||||
".tiptap.ProseMirror": { pb: 150 }
|
||||
}}
|
||||
>
|
||||
<TipTap
|
||||
{...props}
|
||||
onLoad={(editor) => {
|
||||
props.onLoad?.(editor);
|
||||
containerRef.current
|
||||
?.querySelector(".editor-loading-container")
|
||||
?.remove();
|
||||
}}
|
||||
>
|
||||
<TipTap
|
||||
{...props}
|
||||
onLoad={(editor) => {
|
||||
props.onLoad?.(editor);
|
||||
containerRef.current
|
||||
?.querySelector(".editor-loading-container")
|
||||
?.remove();
|
||||
}}
|
||||
editorContainer={() => {
|
||||
if (editorContainerRef.current) return editorContainerRef.current;
|
||||
const editorContainer = document.createElement("div");
|
||||
editorContainer.classList.add("selectable");
|
||||
editorContainer.style.flex = "1";
|
||||
editorContainer.style.cursor = "text";
|
||||
editorContainer.style.color =
|
||||
theme.scopes.editor?.primary?.paragraph ||
|
||||
theme.scopes.base.primary.paragraph;
|
||||
editorContainer.style.fontSize = `${editorConfig.fontSize}px`;
|
||||
editorContainer.style.fontFamily =
|
||||
getFontById(editorConfig.fontFamily)?.font || "sans-serif";
|
||||
editorContainerRef.current = editorContainer;
|
||||
return editorContainer;
|
||||
}}
|
||||
fontFamily={editorConfig.fontFamily}
|
||||
fontSize={editorConfig.fontSize}
|
||||
editorContainer={() => {
|
||||
if (editorContainerRef.current) return editorContainerRef.current;
|
||||
const editorContainer = document.createElement("div");
|
||||
editorContainer.classList.add("selectable");
|
||||
editorContainer.style.flex = "1";
|
||||
editorContainer.style.cursor = "text";
|
||||
editorContainer.style.color =
|
||||
theme.scopes.editor?.primary?.paragraph ||
|
||||
theme.scopes.base.primary.paragraph;
|
||||
editorContainer.style.fontSize = `${editorConfig.fontSize}px`;
|
||||
editorContainer.style.fontFamily =
|
||||
getFontById(editorConfig.fontFamily)?.font || "sans-serif";
|
||||
editorContainerRef.current = editorContainer;
|
||||
return editorContainer;
|
||||
}}
|
||||
fontFamily={editorConfig.fontFamily}
|
||||
fontSize={editorConfig.fontSize}
|
||||
/>
|
||||
{props.children}
|
||||
<Box className="editor-loading-container">
|
||||
<Skeleton
|
||||
enableAnimation={false}
|
||||
height={22}
|
||||
style={{ marginTop: 16 }}
|
||||
count={2}
|
||||
/>
|
||||
{props.children}
|
||||
<Box className="editor-loading-container">
|
||||
<Skeleton
|
||||
enableAnimation={false}
|
||||
height={22}
|
||||
style={{ marginTop: 16 }}
|
||||
count={2}
|
||||
/>
|
||||
<Skeleton
|
||||
enableAnimation={false}
|
||||
height={22}
|
||||
width={25}
|
||||
style={{ marginTop: 16 }}
|
||||
/>
|
||||
</Box>
|
||||
</Flex>
|
||||
</PortalProvider>
|
||||
<Skeleton
|
||||
enableAnimation={false}
|
||||
height={22}
|
||||
width={25}
|
||||
style={{ marginTop: 16 }}
|
||||
/>
|
||||
</Box>
|
||||
</Flex>
|
||||
);
|
||||
}
|
||||
export default TiptapWrapper;
|
||||
|
||||
@@ -21,7 +21,6 @@ import {
|
||||
Editor,
|
||||
getFontById,
|
||||
getTableOfContents,
|
||||
PortalProvider,
|
||||
TiptapOptions,
|
||||
Toolbar,
|
||||
usePermissionHandler,
|
||||
@@ -801,11 +800,7 @@ const TiptapProvider = (): JSX.Element => {
|
||||
}
|
||||
}, [settings.fontSize, settings.fontFamily]);
|
||||
|
||||
return (
|
||||
<PortalProvider>
|
||||
<Tiptap settings={settings} getContentDiv={getContentDiv} />
|
||||
</PortalProvider>
|
||||
);
|
||||
return <Tiptap settings={settings} getContentDiv={getContentDiv} />;
|
||||
};
|
||||
|
||||
export default TiptapProvider;
|
||||
|
||||
@@ -16,12 +16,7 @@ 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 {
|
||||
PortalProvider,
|
||||
TiptapOptions,
|
||||
getFontById,
|
||||
useTiptap
|
||||
} from "@notesnook/editor";
|
||||
import { TiptapOptions, getFontById, useTiptap } from "@notesnook/editor";
|
||||
import { useThemeColors } from "@notesnook/theme";
|
||||
import {
|
||||
useCallback,
|
||||
@@ -71,11 +66,7 @@ export const ReadonlyEditorProvider = (): JSX.Element => {
|
||||
}
|
||||
}, [settings.fontSize, settings.fontFamily]);
|
||||
|
||||
return (
|
||||
<PortalProvider>
|
||||
<Tiptap settings={settings} getContentDiv={getContentDiv} />
|
||||
</PortalProvider>
|
||||
);
|
||||
return <Tiptap settings={settings} getContentDiv={getContentDiv} />;
|
||||
};
|
||||
|
||||
const Tiptap = ({
|
||||
|
||||
@@ -41,7 +41,7 @@ declare module "prosemirror-view" {
|
||||
slice: Slice
|
||||
): { dom: HTMLElement; text: string };
|
||||
}
|
||||
|
||||
const portalProviderAPI = new PortalProviderAPI();
|
||||
export class ReactNodeView<P extends ReactNodeViewProps> implements NodeView {
|
||||
private domRef!: HTMLElement;
|
||||
private contentDOMWrapper?: Node;
|
||||
@@ -53,30 +53,10 @@ export class ReactNodeView<P extends ReactNodeViewProps> implements NodeView {
|
||||
pos = -1;
|
||||
posEnd: number | undefined;
|
||||
|
||||
// in order to cleanly unmount a React Portal, we have to preserve the
|
||||
// dom structure for the full node. However, Prosemirror/browser can,
|
||||
// sometimes, detach the node before React is able to call "unmount".
|
||||
// Unmounting a React child whose DOM counterpart is already removed
|
||||
// results in a DOMException.
|
||||
// To fix that, we observe and store all the detached subnodes and later add
|
||||
// them back when we are ready to destroy our node view once & for all.
|
||||
// This wouldn't be necessary if React allowed for a way to "sync" the DOM
|
||||
// changes to its Virtual DOM.
|
||||
detached: Set<Node> = new Set();
|
||||
detachObserver = new MutationObserver((mutations) => {
|
||||
const filtered = mutations.filter(
|
||||
(m) => m.target === this.domRef && m.removedNodes.length > 0
|
||||
);
|
||||
for (const mutation of filtered) {
|
||||
for (const node of mutation.removedNodes) this.detached.add(node);
|
||||
}
|
||||
});
|
||||
|
||||
constructor(
|
||||
node: PMNode,
|
||||
protected readonly editor: Editor,
|
||||
protected readonly getPos: GetPosNode,
|
||||
protected readonly portalProviderAPI: PortalProviderAPI,
|
||||
protected readonly options: ReactNodeViewOptions<P>
|
||||
) {
|
||||
this.node = node;
|
||||
@@ -105,11 +85,6 @@ export class ReactNodeView<P extends ReactNodeViewProps> implements NodeView {
|
||||
init() {
|
||||
this.domRef = this.createDomRef();
|
||||
this.domRef.ondragstart = (ev) => this.onDragStart(ev);
|
||||
this.detachObserver.observe(this.domRef, {
|
||||
childList: true,
|
||||
subtree: true
|
||||
});
|
||||
// this.setDomAttrs(this.node, this.domRef);
|
||||
|
||||
const { dom: contentDOMWrapper, contentDOM } = this.getContentDOM() ?? {};
|
||||
|
||||
@@ -131,12 +106,12 @@ export class ReactNodeView<P extends ReactNodeViewProps> implements NodeView {
|
||||
|
||||
private render() {
|
||||
if (process.env.NODE_ENV === "test") return;
|
||||
if (!this.domRef || !this.portalProviderAPI) {
|
||||
if (!this.domRef) {
|
||||
console.warn("Cannot render node view");
|
||||
return;
|
||||
}
|
||||
|
||||
this.portalProviderAPI.render(this.Component, this.domRef);
|
||||
portalProviderAPI.render(this.Component, this.domRef);
|
||||
}
|
||||
|
||||
createDomRef(): HTMLElement {
|
||||
@@ -489,14 +464,7 @@ export class ReactNodeView<P extends ReactNodeViewProps> implements NodeView {
|
||||
}
|
||||
|
||||
destroy() {
|
||||
this.detachObserver.disconnect();
|
||||
// add back the detached nodes because React expects an untouched
|
||||
// DOM representation (and there's no way to reconcile the DOM later).
|
||||
for (const node of this.detached) {
|
||||
this.domRef.appendChild(node);
|
||||
}
|
||||
this.portalProviderAPI.remove(this.domRef);
|
||||
this.detached.clear();
|
||||
portalProviderAPI.remove(this.domRef);
|
||||
this.domRef.remove();
|
||||
}
|
||||
}
|
||||
@@ -507,18 +475,11 @@ export function createNodeView<TProps extends ReactNodeViewProps>(
|
||||
) {
|
||||
return ({ node, getPos, editor }: NodeViewRendererProps) => {
|
||||
const _getPos = () => (typeof getPos === "boolean" ? -1 : getPos());
|
||||
if (!editor.storage.portalProviderAPI) return {};
|
||||
|
||||
return new ReactNodeView<TProps>(
|
||||
node,
|
||||
editor as Editor,
|
||||
_getPos,
|
||||
editor.storage.portalProviderAPI,
|
||||
{
|
||||
...options,
|
||||
component
|
||||
}
|
||||
).init();
|
||||
return new ReactNodeView<TProps>(node, editor as Editor, _getPos, {
|
||||
...options,
|
||||
component
|
||||
}).init();
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -17,18 +17,10 @@ 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 React, {
|
||||
FunctionComponent,
|
||||
PropsWithChildren,
|
||||
useContext,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState
|
||||
} from "react";
|
||||
import { createPortal, flushSync } from "react-dom";
|
||||
import { FunctionComponent, PropsWithChildren } from "react";
|
||||
import { flushSync } from "react-dom";
|
||||
import { EventDispatcher } from "./event-dispatcher";
|
||||
import { nanoid } from "nanoid";
|
||||
import { Root, createRoot } from "react-dom/client";
|
||||
|
||||
export type BasePortalProviderProps = PropsWithChildren<unknown>;
|
||||
|
||||
@@ -45,93 +37,31 @@ export type PortalRendererState = {
|
||||
|
||||
export class PortalProviderAPI extends EventDispatcher<Portals> {
|
||||
portals: Map<HTMLElement, MountedPortal> = new Map();
|
||||
roots: Map<HTMLElement, Root> = new Map();
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
}
|
||||
|
||||
/**
|
||||
* Trigger an update in all subscribers.
|
||||
*/
|
||||
private update() {
|
||||
this.emit("update", this.portals);
|
||||
}
|
||||
|
||||
render(Component: FunctionComponent, container: HTMLElement) {
|
||||
const portal = this.portals.get(container);
|
||||
this.portals.set(container, { Component, key: portal?.key ?? nanoid() });
|
||||
this.update();
|
||||
}
|
||||
|
||||
/**
|
||||
* Force an update in all the portals by setting new keys for every portal.
|
||||
*
|
||||
* Delete all orphaned containers (deleted from the DOM). This is useful for
|
||||
* Decoration where there is no destroy method.
|
||||
*/
|
||||
forceUpdate(): void {
|
||||
for (const [container, { Component }] of this.portals) {
|
||||
this.portals.set(container, { Component, key: nanoid() });
|
||||
}
|
||||
const root = this.roots.get(container) || createRoot(container);
|
||||
flushSync(() => root.render(<Component />));
|
||||
this.roots.set(container, root);
|
||||
}
|
||||
|
||||
remove(container: HTMLElement) {
|
||||
// Remove the portal which was being wrapped in the provided container.
|
||||
this.portals.delete(container);
|
||||
// if container is already unmounted (maybe by prosemirror),
|
||||
// no need to proceed
|
||||
if (!container.parentNode) return;
|
||||
|
||||
// Trigger an update
|
||||
this.update();
|
||||
const root = this.roots.get(container);
|
||||
if (!root) return;
|
||||
this.roots.delete(container);
|
||||
|
||||
try {
|
||||
root.unmount();
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const PortalProviderContext = React.createContext<
|
||||
PortalProviderAPI | undefined
|
||||
>(undefined);
|
||||
export function usePortalProvider() {
|
||||
return useContext(PortalProviderContext);
|
||||
}
|
||||
|
||||
export function PortalProvider(props: PropsWithChildren) {
|
||||
const portalProviderAPI = useMemo(() => new PortalProviderAPI(), []);
|
||||
|
||||
return (
|
||||
<PortalProviderContext.Provider value={portalProviderAPI}>
|
||||
{props.children}
|
||||
<PortalRenderer portalProviderAPI={portalProviderAPI} />
|
||||
</PortalProviderContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
function PortalRenderer(props: { portalProviderAPI: PortalProviderAPI }) {
|
||||
const { portalProviderAPI } = props;
|
||||
const mounted = useRef(true);
|
||||
const [portals, setPortals] = useState(() =>
|
||||
Array.from(portalProviderAPI.portals.entries())
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
mounted.current = true;
|
||||
function onUpdate(portalMap: Portals) {
|
||||
// we have to make sure the component is mounted otherwise React
|
||||
// throws an error.
|
||||
if (!mounted.current) return;
|
||||
|
||||
// flushSync is necessary here, otherwise we get into a loop where
|
||||
// ProseMirror destroys and recreates the node view over and over again.
|
||||
flushSync(() => setPortals(Array.from(portalMap.entries())));
|
||||
}
|
||||
portalProviderAPI.on("update", onUpdate);
|
||||
return () => {
|
||||
mounted.current = false;
|
||||
portalProviderAPI.off("update", onUpdate);
|
||||
};
|
||||
}, [portalProviderAPI]);
|
||||
|
||||
return (
|
||||
<>
|
||||
{portals.map(([container, { Component, key }]) =>
|
||||
createPortal(<Component />, container, key)
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -82,8 +82,6 @@ export function TableComponent(props: ReactNodeViewProps) {
|
||||
}
|
||||
|
||||
export function TableNodeView(editor: TiptapEditor) {
|
||||
if (!editor.storage.portalProviderAPI) return;
|
||||
const api = editor.storage.portalProviderAPI;
|
||||
class TableNode
|
||||
extends ReactNodeView<ReactNodeViewProps<unknown>>
|
||||
implements NodeView
|
||||
@@ -93,7 +91,6 @@ export function TableNodeView(editor: TiptapEditor) {
|
||||
node,
|
||||
editor,
|
||||
() => 0, // todo
|
||||
api,
|
||||
{
|
||||
component: TableComponent,
|
||||
shouldUpdate: (prev, next) => {
|
||||
|
||||
@@ -61,7 +61,6 @@ import OrderedList from "./extensions/ordered-list";
|
||||
import { OutlineList } from "./extensions/outline-list";
|
||||
import { OutlineListItem } from "./extensions/outline-list-item";
|
||||
import { Paragraph } from "./extensions/paragraph";
|
||||
import { PortalProviderAPI, usePortalProvider } from "./extensions/react";
|
||||
import { SearchReplace } from "./extensions/search-replace";
|
||||
import { Table } from "./extensions/table";
|
||||
import TableCell from "./extensions/table-cell";
|
||||
@@ -87,7 +86,6 @@ import { useEditorSearchStore } from "./toolbar/stores/search-store";
|
||||
import { DiffHighlighter } from "./extensions/diff-highlighter";
|
||||
|
||||
interface TiptapStorage {
|
||||
portalProviderAPI?: PortalProviderAPI;
|
||||
dateFormat?: DateTimeOptions["dateFormat"];
|
||||
timeFormat?: DateTimeOptions["timeFormat"];
|
||||
openLink?: (url: string) => void;
|
||||
@@ -124,7 +122,7 @@ export type TiptapOptions = EditorOptions &
|
||||
Omit<WebClipOptions, "HTMLAttributes"> &
|
||||
Omit<ImageOptions, "HTMLAttributes"> &
|
||||
DateTimeOptions &
|
||||
Omit<TiptapStorage, "portalProviderAPI"> & {
|
||||
TiptapStorage & {
|
||||
downloadOptions?: DownloadOptions;
|
||||
isMobile?: boolean;
|
||||
doubleSpacedLines?: boolean;
|
||||
@@ -153,7 +151,6 @@ const useTiptap = (
|
||||
...restOptions
|
||||
} = options;
|
||||
|
||||
const PortalProviderAPI = usePortalProvider();
|
||||
const setIsMobile = useToolbarStore((store) => store.setIsMobile);
|
||||
const closeAllPopups = useToolbarStore((store) => store.closeAllPopups);
|
||||
const setDownloadOptions = useToolbarStore(
|
||||
@@ -356,7 +353,6 @@ const useTiptap = (
|
||||
})
|
||||
],
|
||||
onBeforeCreate: ({ editor }) => {
|
||||
editor.storage.portalProviderAPI = PortalProviderAPI;
|
||||
editor.storage.dateFormat = dateFormat;
|
||||
editor.storage.timeFormat = timeFormat;
|
||||
|
||||
@@ -378,7 +374,6 @@ const useTiptap = (
|
||||
downloadAttachment,
|
||||
openAttachmentPicker,
|
||||
getAttachmentData,
|
||||
PortalProviderAPI,
|
||||
onBeforeCreate,
|
||||
openLink,
|
||||
dateFormat,
|
||||
@@ -403,7 +398,6 @@ const useTiptap = (
|
||||
export { type Fragment } from "prosemirror-model";
|
||||
export { type Attachment, type AttachmentType } from "./extensions/attachment";
|
||||
export { type ImageAttributes } from "./extensions/image";
|
||||
export * from "./extensions/react";
|
||||
export * from "./toolbar";
|
||||
export * from "./types";
|
||||
export * from "./utils/word-counter";
|
||||
|
||||
Reference in New Issue
Block a user