Skip to content

Commit 996d11d

Browse files
[PE-210] feat: editor performance (#6269)
* 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
1 parent 0345336 commit 996d11d

File tree

8 files changed

+2247
-1895
lines changed

8 files changed

+2247
-1895
lines changed

live/package.json

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -16,10 +16,10 @@
1616
"author": "",
1717
"license": "ISC",
1818
"dependencies": {
19-
"@hocuspocus/extension-database": "^2.11.3",
20-
"@hocuspocus/extension-logger": "^2.11.3",
21-
"@hocuspocus/extension-redis": "^2.13.5",
22-
"@hocuspocus/server": "^2.11.3",
19+
"@hocuspocus/extension-database": "^2.15.0",
20+
"@hocuspocus/extension-logger": "^2.15.0",
21+
"@hocuspocus/extension-redis": "^2.15.0",
22+
"@hocuspocus/server": "^2.15.0",
2323
"@plane/constants": "*",
2424
"@plane/editor": "*",
2525
"@plane/types": "*",
@@ -40,9 +40,9 @@
4040
"pino-http": "^10.3.0",
4141
"pino-pretty": "^11.2.2",
4242
"uuid": "^10.0.0",
43-
"y-prosemirror": "^1.2.9",
43+
"y-prosemirror": "^1.2.15",
4444
"y-protocols": "^1.0.6",
45-
"yjs": "^13.6.14"
45+
"yjs": "^13.6.20"
4646
},
4747
"devDependencies": {
4848
"@babel/cli": "^7.25.6",

packages/editor/package.json

Lines changed: 6 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -12,14 +12,12 @@
1212
"exports": {
1313
".": {
1414
"types": "./dist/index.d.mts",
15-
"import": "./dist/index.mjs",
16-
"module": "./dist/index.mjs"
15+
"import": "./dist/index.mjs"
1716
},
1817
"./lib": {
1918
"require": "./dist/lib.js",
2019
"types": "./dist/lib.d.mts",
21-
"import": "./dist/lib.mjs",
22-
"module": "./dist/lib.mjs"
20+
"import": "./dist/lib.mjs"
2321
}
2422
},
2523
"scripts": {
@@ -36,7 +34,7 @@
3634
},
3735
"dependencies": {
3836
"@floating-ui/react": "^0.26.4",
39-
"@hocuspocus/provider": "^2.13.5",
37+
"@hocuspocus/provider": "^2.15.0",
4038
"@plane/types": "*",
4139
"@plane/ui": "*",
4240
"@plane/utils": "*",
@@ -67,12 +65,12 @@
6765
"prosemirror-codemark": "^0.4.2",
6866
"prosemirror-utils": "^1.2.2",
6967
"tippy.js": "^6.3.7",
70-
"tiptap-markdown": "^0.8.9",
68+
"tiptap-markdown": "^0.8.10",
7169
"uuid": "^10.0.0",
7270
"y-indexeddb": "^9.0.12",
73-
"y-prosemirror": "^1.2.5",
71+
"y-prosemirror": "^1.2.15",
7472
"y-protocols": "^1.0.6",
75-
"yjs": "^13.6.15"
73+
"yjs": "^13.6.20"
7674
},
7775
"devDependencies": {
7876
"@plane/eslint-config": "*",

packages/editor/src/core/components/menus/menu-items.ts

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -202,8 +202,7 @@ export const ImageItem = (editor: Editor): EditorMenuItem<"image"> => ({
202202
key: "image",
203203
name: "Image",
204204
isActive: () => editor?.isActive("image") || editor?.isActive("imageComponent"),
205-
command: ({ savedSelection }) =>
206-
insertImage({ editor, event: "insert", pos: savedSelection?.from ?? editor.state.selection.from }),
205+
command: () => insertImage({ editor, event: "insert", pos: editor.state.selection.from }),
207206
icon: ImageIcon,
208207
});
209208

packages/editor/src/core/helpers/insert-content-at-cursor-position.ts

Lines changed: 6 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,27 +1,21 @@
1-
import { MutableRefObject } from "react";
2-
import { Selection } from "@tiptap/pm/state";
31
import { Editor } from "@tiptap/react";
42

5-
export const insertContentAtSavedSelection = (
6-
editorRef: MutableRefObject<Editor | null>,
7-
content: string,
8-
savedSelection: Selection
9-
) => {
10-
if (!editorRef.current || editorRef.current.isDestroyed) {
3+
export const insertContentAtSavedSelection = (editor: Editor, content: string) => {
4+
if (!editor || editor.isDestroyed) {
115
console.error("Editor reference is not available or has been destroyed.");
126
return;
137
}
148

15-
if (!savedSelection) {
9+
if (!editor.state.selection) {
1610
console.error("Saved selection is invalid.");
1711
return;
1812
}
1913

20-
const docSize = editorRef.current.state.doc.content.size;
21-
const safePosition = Math.max(0, Math.min(savedSelection.anchor, docSize));
14+
const docSize = editor.state.doc.content.size;
15+
const safePosition = Math.max(0, Math.min(editor.state.selection.anchor, docSize));
2216

2317
try {
24-
editorRef.current.chain().focus().insertContentAt(safePosition, content).run();
18+
editor.chain().focus().insertContentAt(safePosition, content).run();
2519
} catch (error) {
2620
console.error("An error occurred while inserting content at saved selection:", error);
2721
}

packages/editor/src/core/hooks/use-editor.ts

Lines changed: 49 additions & 76 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,11 @@
1-
import { useImperativeHandle, useRef, MutableRefObject, useState, useEffect } from "react";
21
import { HocuspocusProvider } from "@hocuspocus/provider";
32
import { DOMSerializer } from "@tiptap/pm/model";
4-
import { Selection } from "@tiptap/pm/state";
53
import { EditorProps } from "@tiptap/pm/view";
6-
import { useEditor as useTiptapEditor, Editor, Extensions } from "@tiptap/react";
4+
import { useEditor as useTiptapEditor, Extensions } from "@tiptap/react";
5+
import { useImperativeHandle, MutableRefObject, useEffect } from "react";
76
import * as Y from "yjs";
87
// components
9-
import { EditorMenuItem, getEditorMenuItems } from "@/components/menus";
8+
import { getEditorMenuItems } from "@/components/menus";
109
// extensions
1110
import { CoreEditorExtensions } from "@/extensions";
1211
// helpers
@@ -71,14 +70,12 @@ export const useEditor = (props: CustomEditorProps) => {
7170
provider,
7271
autofocus = false,
7372
} = props;
74-
// states
75-
const [savedSelection, setSavedSelection] = useState<Selection | null>(null);
76-
// refs
77-
const editorRef: MutableRefObject<Editor | null> = useRef(null);
78-
const savedSelectionRef = useRef(savedSelection);
73+
7974
const editor = useTiptapEditor(
8075
{
8176
editable,
77+
immediatelyRender: false,
78+
shouldRerenderOnTransaction: false,
8279
autofocus,
8380
editorProps: {
8481
...CoreEditorProps({
@@ -100,8 +97,7 @@ export const useEditor = (props: CustomEditorProps) => {
10097
],
10198
content: typeof initialValue === "string" && initialValue.trim() !== "" ? initialValue : "<p></p>",
10299
onCreate: () => handleEditorReady?.(true),
103-
onTransaction: ({ editor }) => {
104-
setSavedSelection(editor.state.selection);
100+
onTransaction: () => {
105101
onTransaction?.();
106102
},
107103
onUpdate: ({ editor }) => onChange?.(editor.getJSON(), editor.getHTML()),
@@ -110,23 +106,17 @@ export const useEditor = (props: CustomEditorProps) => {
110106
[editable]
111107
);
112108

113-
// Update the ref whenever savedSelection changes
114-
useEffect(() => {
115-
savedSelectionRef.current = savedSelection;
116-
}, [savedSelection]);
117-
118109
// Effect for syncing SWR data
119110
useEffect(() => {
120111
// value is null when intentionally passed where syncing is not yet
121112
// supported and value is undefined when the data from swr is not populated
122-
if (value === null || value === undefined) return;
113+
if (value == null) return;
123114
if (editor && !editor.isDestroyed && !editor.storage.imageComponent.uploadInProgress) {
124115
try {
125116
editor.commands.setContent(value, false, { preserveWhitespace: "full" });
126-
const currentSavedSelection = savedSelectionRef.current;
127-
if (currentSavedSelection) {
117+
if (editor.state.selection) {
128118
const docLength = editor.state.doc.content.size;
129-
const relativePosition = Math.min(currentSavedSelection.from, docLength - 1);
119+
const relativePosition = Math.min(editor.state.selection.from, docLength - 1);
130120
editor.commands.setTextSelection(relativePosition);
131121
}
132122
} catch (error) {
@@ -138,46 +128,40 @@ export const useEditor = (props: CustomEditorProps) => {
138128
useImperativeHandle(
139129
forwardedRef,
140130
() => ({
141-
blur: () => editorRef.current?.commands.blur(),
131+
blur: () => editor.commands.blur(),
142132
scrollToNodeViaDOMCoordinates(behavior?: ScrollBehavior, pos?: number) {
143-
const resolvedPos = pos ?? savedSelection?.from;
144-
if (!editorRef.current || !resolvedPos) return;
145-
scrollToNodeViaDOMCoordinates(editorRef.current, resolvedPos, behavior);
133+
const resolvedPos = pos ?? editor.state.selection.from;
134+
if (!editor || !resolvedPos) return;
135+
scrollToNodeViaDOMCoordinates(editor, resolvedPos, behavior);
146136
},
147-
getCurrentCursorPosition: () => savedSelection?.from,
137+
getCurrentCursorPosition: () => editor.state.selection.from,
148138
clearEditor: (emitUpdate = false) => {
149-
editorRef.current?.chain().setMeta("skipImageDeletion", true).clearContent(emitUpdate).run();
139+
editor?.chain().setMeta("skipImageDeletion", true).clearContent(emitUpdate).run();
150140
},
151141
setEditorValue: (content: string) => {
152-
editorRef.current?.commands.setContent(content, false, { preserveWhitespace: "full" });
142+
editor?.commands.setContent(content, false, { preserveWhitespace: "full" });
153143
},
154144
setEditorValueAtCursorPosition: (content: string) => {
155-
if (savedSelection) {
156-
insertContentAtSavedSelection(editorRef, content, savedSelection);
145+
if (editor.state.selection) {
146+
insertContentAtSavedSelection(editor, content);
157147
}
158148
},
159149
executeMenuItemCommand: (props) => {
160150
const { itemKey } = props;
161-
const editorItems = getEditorMenuItems(editorRef.current);
151+
const editorItems = getEditorMenuItems(editor);
162152

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

165155
const item = getEditorMenuItem(itemKey);
166156
if (item) {
167-
if (item.key === "image") {
168-
(item as EditorMenuItem<"image">).command({
169-
savedSelection: savedSelectionRef.current,
170-
});
171-
} else {
172-
item.command(props);
173-
}
157+
item.command(props);
174158
} else {
175159
console.warn(`No command found for item: ${itemKey}`);
176160
}
177161
},
178162
isMenuItemActive: (props) => {
179163
const { itemKey } = props;
180-
const editorItems = getEditorMenuItems(editorRef.current);
164+
const editorItems = getEditorMenuItems(editor);
181165

182166
const getEditorMenuItem = (itemKey: TEditorCommands) => editorItems.find((item) => item.key === itemKey);
183167
const item = getEditorMenuItem(itemKey);
@@ -187,38 +171,38 @@ export const useEditor = (props: CustomEditorProps) => {
187171
},
188172
onHeadingChange: (callback: (headings: IMarking[]) => void) => {
189173
// Subscribe to update event emitted from headers extension
190-
editorRef.current?.on("update", () => {
191-
callback(editorRef.current?.storage.headingList.headings);
174+
editor?.on("update", () => {
175+
callback(editor?.storage.headingList.headings);
192176
});
193177
// Return a function to unsubscribe to the continuous transactions of
194178
// the editor on unmounting the component that has subscribed to this
195179
// method
196180
return () => {
197-
editorRef.current?.off("update");
181+
editor?.off("update");
198182
};
199183
},
200-
getHeadings: () => editorRef?.current?.storage.headingList.headings,
184+
getHeadings: () => editor?.storage.headingList.headings,
201185
onStateChange: (callback: () => void) => {
202186
// Subscribe to editor state changes
203-
editorRef.current?.on("transaction", () => {
187+
editor?.on("transaction", () => {
204188
callback();
205189
});
206190

207191
// Return a function to unsubscribe to the continuous transactions of
208192
// the editor on unmounting the component that has subscribed to this
209193
// method
210194
return () => {
211-
editorRef.current?.off("transaction");
195+
editor?.off("transaction");
212196
};
213197
},
214198
getMarkDown: (): string => {
215-
const markdownOutput = editorRef.current?.storage.markdown.getMarkdown();
199+
const markdownOutput = editor?.storage.markdown.getMarkdown();
216200
return markdownOutput;
217201
},
218202
getDocument: () => {
219203
const documentBinary = provider?.document ? Y.encodeStateAsUpdate(provider?.document) : null;
220-
const documentHTML = editorRef.current?.getHTML() ?? "<p></p>";
221-
const documentJSON = editorRef.current?.getJSON() ?? null;
204+
const documentHTML = editor?.getHTML() ?? "<p></p>";
205+
const documentJSON = editor.getJSON() ?? null;
222206

223207
return {
224208
binary: documentBinary,
@@ -227,19 +211,19 @@ export const useEditor = (props: CustomEditorProps) => {
227211
};
228212
},
229213
scrollSummary: (marking: IMarking): void => {
230-
if (!editorRef.current) return;
231-
scrollSummary(editorRef.current, marking);
214+
if (!editor) return;
215+
scrollSummary(editor, marking);
232216
},
233-
isEditorReadyToDiscard: () => editorRef.current?.storage.imageComponent.uploadInProgress === false,
217+
isEditorReadyToDiscard: () => editor?.storage.imageComponent.uploadInProgress === false,
234218
setFocusAtPosition: (position: number) => {
235-
if (!editorRef.current || editorRef.current.isDestroyed) {
219+
if (!editor || editor.isDestroyed) {
236220
console.error("Editor reference is not available or has been destroyed.");
237221
return;
238222
}
239223
try {
240-
const docSize = editorRef.current.state.doc.content.size;
224+
const docSize = editor.state.doc.content.size;
241225
const safePosition = Math.max(0, Math.min(position, docSize));
242-
editorRef.current
226+
editor
243227
.chain()
244228
.insertContentAt(safePosition, [{ type: "paragraph" }])
245229
.focus()
@@ -249,17 +233,17 @@ export const useEditor = (props: CustomEditorProps) => {
249233
}
250234
},
251235
getSelectedText: () => {
252-
if (!editorRef.current) return null;
236+
if (!editor) return null;
253237

254-
const { state } = editorRef.current;
238+
const { state } = editor;
255239
const { from, to, empty } = state.selection;
256240

257241
if (empty) return null;
258242

259243
const nodesArray: string[] = [];
260244
state.doc.nodesBetween(from, to, (node, _pos, parent) => {
261-
if (parent === state.doc && editorRef.current) {
262-
const serializer = DOMSerializer.fromSchema(editorRef.current?.schema);
245+
if (parent === state.doc && editor) {
246+
const serializer = DOMSerializer.fromSchema(editor.schema);
263247
const dom = serializer.serializeNode(node);
264248
const tempDiv = document.createElement("div");
265249
tempDiv.appendChild(dom);
@@ -270,28 +254,21 @@ export const useEditor = (props: CustomEditorProps) => {
270254
return selection;
271255
},
272256
insertText: (contentHTML, insertOnNextLine) => {
273-
if (!editorRef.current) return;
274-
// get selection
275-
const { from, to, empty } = editorRef.current.state.selection;
257+
if (!editor) return;
258+
const { from, to, empty } = editor.state.selection;
276259
if (empty) return;
277260
if (insertOnNextLine) {
278261
// move cursor to the end of the selection and insert a new line
279-
editorRef.current
280-
.chain()
281-
.focus()
282-
.setTextSelection(to)
283-
.insertContent("<br />")
284-
.insertContent(contentHTML)
285-
.run();
262+
editor.chain().focus().setTextSelection(to).insertContent("<br />").insertContent(contentHTML).run();
286263
} else {
287264
// replace selected text with the content provided
288-
editorRef.current.chain().focus().deleteRange({ from, to }).insertContent(contentHTML).run();
265+
editor.chain().focus().deleteRange({ from, to }).insertContent(contentHTML).run();
289266
}
290267
},
291268
getDocumentInfo: () => ({
292-
characters: editorRef?.current?.storage?.characterCount?.characters?.() ?? 0,
293-
paragraphs: getParagraphCount(editorRef?.current?.state),
294-
words: editorRef?.current?.storage?.characterCount?.words?.() ?? 0,
269+
characters: editor?.storage?.characterCount?.characters?.() ?? 0,
270+
paragraphs: getParagraphCount(editor?.state),
271+
words: editor?.storage?.characterCount?.words?.() ?? 0,
295272
}),
296273
setProviderDocument: (value) => {
297274
const document = provider?.document;
@@ -301,16 +278,12 @@ export const useEditor = (props: CustomEditorProps) => {
301278
emitRealTimeUpdate: (message: TDocumentEventsServer) => provider?.sendStateless(message),
302279
listenToRealTimeUpdate: () => provider && { on: provider.on.bind(provider), off: provider.off.bind(provider) },
303280
}),
304-
[editorRef, savedSelection]
281+
[editor]
305282
);
306283

307284
if (!editor) {
308285
return null;
309286
}
310287

311-
// the editorRef is used to access the editor instance from outside the hook
312-
// and should only be used after editor is initialized
313-
editorRef.current = editor;
314-
315288
return editor;
316289
};

0 commit comments

Comments
 (0)