Skip to content

Commit

Permalink
[PE-210] feat: editor performance (#6269)
Browse files Browse the repository at this point in the history
* bump: upgrade editor

* fix: remove editor ref in use

* fix: added editor state to reduce rerenders

* fix: add editor rerendering optimization

* fix: wrong condition in scroll summary

* fix: removing ref usage internally in read only editor as well

* fix: remove unused methods from read only editor

* fix: add editable prop again

* regression: added the types for onHeadingChange

* fix: types

* fix: improve the check condition
  • Loading branch information
Palanikannan1437 authored Jan 15, 2025
1 parent 0345336 commit 996d11d
Show file tree
Hide file tree
Showing 8 changed files with 2,247 additions and 1,895 deletions.
12 changes: 6 additions & 6 deletions live/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -16,10 +16,10 @@
"author": "",
"license": "ISC",
"dependencies": {
"@hocuspocus/extension-database": "^2.11.3",
"@hocuspocus/extension-logger": "^2.11.3",
"@hocuspocus/extension-redis": "^2.13.5",
"@hocuspocus/server": "^2.11.3",
"@hocuspocus/extension-database": "^2.15.0",
"@hocuspocus/extension-logger": "^2.15.0",
"@hocuspocus/extension-redis": "^2.15.0",
"@hocuspocus/server": "^2.15.0",
"@plane/constants": "*",
"@plane/editor": "*",
"@plane/types": "*",
Expand All @@ -40,9 +40,9 @@
"pino-http": "^10.3.0",
"pino-pretty": "^11.2.2",
"uuid": "^10.0.0",
"y-prosemirror": "^1.2.9",
"y-prosemirror": "^1.2.15",
"y-protocols": "^1.0.6",
"yjs": "^13.6.14"
"yjs": "^13.6.20"
},
"devDependencies": {
"@babel/cli": "^7.25.6",
Expand Down
14 changes: 6 additions & 8 deletions packages/editor/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -12,14 +12,12 @@
"exports": {
".": {
"types": "./dist/index.d.mts",
"import": "./dist/index.mjs",
"module": "./dist/index.mjs"
"import": "./dist/index.mjs"
},
"./lib": {
"require": "./dist/lib.js",
"types": "./dist/lib.d.mts",
"import": "./dist/lib.mjs",
"module": "./dist/lib.mjs"
"import": "./dist/lib.mjs"
}
},
"scripts": {
Expand All @@ -36,7 +34,7 @@
},
"dependencies": {
"@floating-ui/react": "^0.26.4",
"@hocuspocus/provider": "^2.13.5",
"@hocuspocus/provider": "^2.15.0",
"@plane/types": "*",
"@plane/ui": "*",
"@plane/utils": "*",
Expand Down Expand Up @@ -67,12 +65,12 @@
"prosemirror-codemark": "^0.4.2",
"prosemirror-utils": "^1.2.2",
"tippy.js": "^6.3.7",
"tiptap-markdown": "^0.8.9",
"tiptap-markdown": "^0.8.10",
"uuid": "^10.0.0",
"y-indexeddb": "^9.0.12",
"y-prosemirror": "^1.2.5",
"y-prosemirror": "^1.2.15",
"y-protocols": "^1.0.6",
"yjs": "^13.6.15"
"yjs": "^13.6.20"
},
"devDependencies": {
"@plane/eslint-config": "*",
Expand Down
3 changes: 1 addition & 2 deletions packages/editor/src/core/components/menus/menu-items.ts
Original file line number Diff line number Diff line change
Expand Up @@ -202,8 +202,7 @@ export const ImageItem = (editor: Editor): EditorMenuItem<"image"> => ({
key: "image",
name: "Image",
isActive: () => editor?.isActive("image") || editor?.isActive("imageComponent"),
command: ({ savedSelection }) =>
insertImage({ editor, event: "insert", pos: savedSelection?.from ?? editor.state.selection.from }),
command: () => insertImage({ editor, event: "insert", pos: editor.state.selection.from }),
icon: ImageIcon,
});

Expand Down
Original file line number Diff line number Diff line change
@@ -1,27 +1,21 @@
import { MutableRefObject } from "react";
import { Selection } from "@tiptap/pm/state";
import { Editor } from "@tiptap/react";

export const insertContentAtSavedSelection = (
editorRef: MutableRefObject<Editor | null>,
content: string,
savedSelection: Selection
) => {
if (!editorRef.current || editorRef.current.isDestroyed) {
export const insertContentAtSavedSelection = (editor: Editor, content: string) => {
if (!editor || editor.isDestroyed) {
console.error("Editor reference is not available or has been destroyed.");
return;
}

if (!savedSelection) {
if (!editor.state.selection) {
console.error("Saved selection is invalid.");
return;
}

const docSize = editorRef.current.state.doc.content.size;
const safePosition = Math.max(0, Math.min(savedSelection.anchor, docSize));
const docSize = editor.state.doc.content.size;
const safePosition = Math.max(0, Math.min(editor.state.selection.anchor, docSize));

try {
editorRef.current.chain().focus().insertContentAt(safePosition, content).run();
editor.chain().focus().insertContentAt(safePosition, content).run();
} catch (error) {
console.error("An error occurred while inserting content at saved selection:", error);
}
Expand Down
125 changes: 49 additions & 76 deletions packages/editor/src/core/hooks/use-editor.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,11 @@
import { useImperativeHandle, useRef, MutableRefObject, useState, useEffect } from "react";
import { HocuspocusProvider } from "@hocuspocus/provider";
import { DOMSerializer } from "@tiptap/pm/model";
import { Selection } from "@tiptap/pm/state";
import { EditorProps } from "@tiptap/pm/view";
import { useEditor as useTiptapEditor, Editor, Extensions } from "@tiptap/react";
import { useEditor as useTiptapEditor, Extensions } from "@tiptap/react";
import { useImperativeHandle, MutableRefObject, useEffect } from "react";
import * as Y from "yjs";
// components
import { EditorMenuItem, getEditorMenuItems } from "@/components/menus";
import { getEditorMenuItems } from "@/components/menus";
// extensions
import { CoreEditorExtensions } from "@/extensions";
// helpers
Expand Down Expand Up @@ -71,14 +70,12 @@ export const useEditor = (props: CustomEditorProps) => {
provider,
autofocus = false,
} = props;
// states
const [savedSelection, setSavedSelection] = useState<Selection | null>(null);
// refs
const editorRef: MutableRefObject<Editor | null> = useRef(null);
const savedSelectionRef = useRef(savedSelection);

const editor = useTiptapEditor(
{
editable,
immediatelyRender: false,
shouldRerenderOnTransaction: false,
autofocus,
editorProps: {
...CoreEditorProps({
Expand All @@ -100,8 +97,7 @@ export const useEditor = (props: CustomEditorProps) => {
],
content: typeof initialValue === "string" && initialValue.trim() !== "" ? initialValue : "<p></p>",
onCreate: () => handleEditorReady?.(true),
onTransaction: ({ editor }) => {
setSavedSelection(editor.state.selection);
onTransaction: () => {
onTransaction?.();
},
onUpdate: ({ editor }) => onChange?.(editor.getJSON(), editor.getHTML()),
Expand All @@ -110,23 +106,17 @@ export const useEditor = (props: CustomEditorProps) => {
[editable]
);

// Update the ref whenever savedSelection changes
useEffect(() => {
savedSelectionRef.current = savedSelection;
}, [savedSelection]);

// Effect for syncing SWR data
useEffect(() => {
// value is null when intentionally passed where syncing is not yet
// supported and value is undefined when the data from swr is not populated
if (value === null || value === undefined) return;
if (value == null) return;
if (editor && !editor.isDestroyed && !editor.storage.imageComponent.uploadInProgress) {
try {
editor.commands.setContent(value, false, { preserveWhitespace: "full" });
const currentSavedSelection = savedSelectionRef.current;
if (currentSavedSelection) {
if (editor.state.selection) {
const docLength = editor.state.doc.content.size;
const relativePosition = Math.min(currentSavedSelection.from, docLength - 1);
const relativePosition = Math.min(editor.state.selection.from, docLength - 1);
editor.commands.setTextSelection(relativePosition);
}
} catch (error) {
Expand All @@ -138,46 +128,40 @@ export const useEditor = (props: CustomEditorProps) => {
useImperativeHandle(
forwardedRef,
() => ({
blur: () => editorRef.current?.commands.blur(),
blur: () => editor.commands.blur(),
scrollToNodeViaDOMCoordinates(behavior?: ScrollBehavior, pos?: number) {
const resolvedPos = pos ?? savedSelection?.from;
if (!editorRef.current || !resolvedPos) return;
scrollToNodeViaDOMCoordinates(editorRef.current, resolvedPos, behavior);
const resolvedPos = pos ?? editor.state.selection.from;
if (!editor || !resolvedPos) return;
scrollToNodeViaDOMCoordinates(editor, resolvedPos, behavior);
},
getCurrentCursorPosition: () => savedSelection?.from,
getCurrentCursorPosition: () => editor.state.selection.from,
clearEditor: (emitUpdate = false) => {
editorRef.current?.chain().setMeta("skipImageDeletion", true).clearContent(emitUpdate).run();
editor?.chain().setMeta("skipImageDeletion", true).clearContent(emitUpdate).run();
},
setEditorValue: (content: string) => {
editorRef.current?.commands.setContent(content, false, { preserveWhitespace: "full" });
editor?.commands.setContent(content, false, { preserveWhitespace: "full" });
},
setEditorValueAtCursorPosition: (content: string) => {
if (savedSelection) {
insertContentAtSavedSelection(editorRef, content, savedSelection);
if (editor.state.selection) {
insertContentAtSavedSelection(editor, content);
}
},
executeMenuItemCommand: (props) => {
const { itemKey } = props;
const editorItems = getEditorMenuItems(editorRef.current);
const editorItems = getEditorMenuItems(editor);

const getEditorMenuItem = (itemKey: TEditorCommands) => editorItems.find((item) => item.key === itemKey);

const item = getEditorMenuItem(itemKey);
if (item) {
if (item.key === "image") {
(item as EditorMenuItem<"image">).command({
savedSelection: savedSelectionRef.current,
});
} else {
item.command(props);
}
item.command(props);
} else {
console.warn(`No command found for item: ${itemKey}`);
}
},
isMenuItemActive: (props) => {
const { itemKey } = props;
const editorItems = getEditorMenuItems(editorRef.current);
const editorItems = getEditorMenuItems(editor);

const getEditorMenuItem = (itemKey: TEditorCommands) => editorItems.find((item) => item.key === itemKey);
const item = getEditorMenuItem(itemKey);
Expand All @@ -187,38 +171,38 @@ export const useEditor = (props: CustomEditorProps) => {
},
onHeadingChange: (callback: (headings: IMarking[]) => void) => {
// Subscribe to update event emitted from headers extension
editorRef.current?.on("update", () => {
callback(editorRef.current?.storage.headingList.headings);
editor?.on("update", () => {
callback(editor?.storage.headingList.headings);
});
// Return a function to unsubscribe to the continuous transactions of
// the editor on unmounting the component that has subscribed to this
// method
return () => {
editorRef.current?.off("update");
editor?.off("update");
};
},
getHeadings: () => editorRef?.current?.storage.headingList.headings,
getHeadings: () => editor?.storage.headingList.headings,
onStateChange: (callback: () => void) => {
// Subscribe to editor state changes
editorRef.current?.on("transaction", () => {
editor?.on("transaction", () => {
callback();
});

// Return a function to unsubscribe to the continuous transactions of
// the editor on unmounting the component that has subscribed to this
// method
return () => {
editorRef.current?.off("transaction");
editor?.off("transaction");
};
},
getMarkDown: (): string => {
const markdownOutput = editorRef.current?.storage.markdown.getMarkdown();
const markdownOutput = editor?.storage.markdown.getMarkdown();
return markdownOutput;
},
getDocument: () => {
const documentBinary = provider?.document ? Y.encodeStateAsUpdate(provider?.document) : null;
const documentHTML = editorRef.current?.getHTML() ?? "<p></p>";
const documentJSON = editorRef.current?.getJSON() ?? null;
const documentHTML = editor?.getHTML() ?? "<p></p>";
const documentJSON = editor.getJSON() ?? null;

return {
binary: documentBinary,
Expand All @@ -227,19 +211,19 @@ export const useEditor = (props: CustomEditorProps) => {
};
},
scrollSummary: (marking: IMarking): void => {
if (!editorRef.current) return;
scrollSummary(editorRef.current, marking);
if (!editor) return;
scrollSummary(editor, marking);
},
isEditorReadyToDiscard: () => editorRef.current?.storage.imageComponent.uploadInProgress === false,
isEditorReadyToDiscard: () => editor?.storage.imageComponent.uploadInProgress === false,
setFocusAtPosition: (position: number) => {
if (!editorRef.current || editorRef.current.isDestroyed) {
if (!editor || editor.isDestroyed) {
console.error("Editor reference is not available or has been destroyed.");
return;
}
try {
const docSize = editorRef.current.state.doc.content.size;
const docSize = editor.state.doc.content.size;
const safePosition = Math.max(0, Math.min(position, docSize));
editorRef.current
editor
.chain()
.insertContentAt(safePosition, [{ type: "paragraph" }])
.focus()
Expand All @@ -249,17 +233,17 @@ export const useEditor = (props: CustomEditorProps) => {
}
},
getSelectedText: () => {
if (!editorRef.current) return null;
if (!editor) return null;

const { state } = editorRef.current;
const { state } = editor;
const { from, to, empty } = state.selection;

if (empty) return null;

const nodesArray: string[] = [];
state.doc.nodesBetween(from, to, (node, _pos, parent) => {
if (parent === state.doc && editorRef.current) {
const serializer = DOMSerializer.fromSchema(editorRef.current?.schema);
if (parent === state.doc && editor) {
const serializer = DOMSerializer.fromSchema(editor.schema);
const dom = serializer.serializeNode(node);
const tempDiv = document.createElement("div");
tempDiv.appendChild(dom);
Expand All @@ -270,28 +254,21 @@ export const useEditor = (props: CustomEditorProps) => {
return selection;
},
insertText: (contentHTML, insertOnNextLine) => {
if (!editorRef.current) return;
// get selection
const { from, to, empty } = editorRef.current.state.selection;
if (!editor) return;
const { from, to, empty } = editor.state.selection;
if (empty) return;
if (insertOnNextLine) {
// move cursor to the end of the selection and insert a new line
editorRef.current
.chain()
.focus()
.setTextSelection(to)
.insertContent("<br />")
.insertContent(contentHTML)
.run();
editor.chain().focus().setTextSelection(to).insertContent("<br />").insertContent(contentHTML).run();
} else {
// replace selected text with the content provided
editorRef.current.chain().focus().deleteRange({ from, to }).insertContent(contentHTML).run();
editor.chain().focus().deleteRange({ from, to }).insertContent(contentHTML).run();
}
},
getDocumentInfo: () => ({
characters: editorRef?.current?.storage?.characterCount?.characters?.() ?? 0,
paragraphs: getParagraphCount(editorRef?.current?.state),
words: editorRef?.current?.storage?.characterCount?.words?.() ?? 0,
characters: editor?.storage?.characterCount?.characters?.() ?? 0,
paragraphs: getParagraphCount(editor?.state),
words: editor?.storage?.characterCount?.words?.() ?? 0,
}),
setProviderDocument: (value) => {
const document = provider?.document;
Expand All @@ -301,16 +278,12 @@ export const useEditor = (props: CustomEditorProps) => {
emitRealTimeUpdate: (message: TDocumentEventsServer) => provider?.sendStateless(message),
listenToRealTimeUpdate: () => provider && { on: provider.on.bind(provider), off: provider.off.bind(provider) },
}),
[editorRef, savedSelection]
[editor]
);

if (!editor) {
return null;
}

// the editorRef is used to access the editor instance from outside the hook
// and should only be used after editor is initialized
editorRef.current = editor;

return editor;
};
Loading

0 comments on commit 996d11d

Please sign in to comment.