[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
This commit is contained in:
parent
0345336d90
commit
996d11de12
8 changed files with 2250 additions and 1898 deletions
|
|
@ -16,10 +16,10 @@
|
||||||
"author": "",
|
"author": "",
|
||||||
"license": "ISC",
|
"license": "ISC",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@hocuspocus/extension-database": "^2.11.3",
|
"@hocuspocus/extension-database": "^2.15.0",
|
||||||
"@hocuspocus/extension-logger": "^2.11.3",
|
"@hocuspocus/extension-logger": "^2.15.0",
|
||||||
"@hocuspocus/extension-redis": "^2.13.5",
|
"@hocuspocus/extension-redis": "^2.15.0",
|
||||||
"@hocuspocus/server": "^2.11.3",
|
"@hocuspocus/server": "^2.15.0",
|
||||||
"@plane/constants": "*",
|
"@plane/constants": "*",
|
||||||
"@plane/editor": "*",
|
"@plane/editor": "*",
|
||||||
"@plane/types": "*",
|
"@plane/types": "*",
|
||||||
|
|
@ -40,9 +40,9 @@
|
||||||
"pino-http": "^10.3.0",
|
"pino-http": "^10.3.0",
|
||||||
"pino-pretty": "^11.2.2",
|
"pino-pretty": "^11.2.2",
|
||||||
"uuid": "^10.0.0",
|
"uuid": "^10.0.0",
|
||||||
"y-prosemirror": "^1.2.9",
|
"y-prosemirror": "^1.2.15",
|
||||||
"y-protocols": "^1.0.6",
|
"y-protocols": "^1.0.6",
|
||||||
"yjs": "^13.6.14"
|
"yjs": "^13.6.20"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@babel/cli": "^7.25.6",
|
"@babel/cli": "^7.25.6",
|
||||||
|
|
|
||||||
|
|
@ -12,14 +12,12 @@
|
||||||
"exports": {
|
"exports": {
|
||||||
".": {
|
".": {
|
||||||
"types": "./dist/index.d.mts",
|
"types": "./dist/index.d.mts",
|
||||||
"import": "./dist/index.mjs",
|
"import": "./dist/index.mjs"
|
||||||
"module": "./dist/index.mjs"
|
|
||||||
},
|
},
|
||||||
"./lib": {
|
"./lib": {
|
||||||
"require": "./dist/lib.js",
|
"require": "./dist/lib.js",
|
||||||
"types": "./dist/lib.d.mts",
|
"types": "./dist/lib.d.mts",
|
||||||
"import": "./dist/lib.mjs",
|
"import": "./dist/lib.mjs"
|
||||||
"module": "./dist/lib.mjs"
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|
@ -36,7 +34,7 @@
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@floating-ui/react": "^0.26.4",
|
"@floating-ui/react": "^0.26.4",
|
||||||
"@hocuspocus/provider": "^2.13.5",
|
"@hocuspocus/provider": "^2.15.0",
|
||||||
"@plane/types": "*",
|
"@plane/types": "*",
|
||||||
"@plane/ui": "*",
|
"@plane/ui": "*",
|
||||||
"@plane/utils": "*",
|
"@plane/utils": "*",
|
||||||
|
|
@ -67,12 +65,12 @@
|
||||||
"prosemirror-codemark": "^0.4.2",
|
"prosemirror-codemark": "^0.4.2",
|
||||||
"prosemirror-utils": "^1.2.2",
|
"prosemirror-utils": "^1.2.2",
|
||||||
"tippy.js": "^6.3.7",
|
"tippy.js": "^6.3.7",
|
||||||
"tiptap-markdown": "^0.8.9",
|
"tiptap-markdown": "^0.8.10",
|
||||||
"uuid": "^10.0.0",
|
"uuid": "^10.0.0",
|
||||||
"y-indexeddb": "^9.0.12",
|
"y-indexeddb": "^9.0.12",
|
||||||
"y-prosemirror": "^1.2.5",
|
"y-prosemirror": "^1.2.15",
|
||||||
"y-protocols": "^1.0.6",
|
"y-protocols": "^1.0.6",
|
||||||
"yjs": "^13.6.15"
|
"yjs": "^13.6.20"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@plane/eslint-config": "*",
|
"@plane/eslint-config": "*",
|
||||||
|
|
|
||||||
|
|
@ -202,8 +202,7 @@ export const ImageItem = (editor: Editor): EditorMenuItem<"image"> => ({
|
||||||
key: "image",
|
key: "image",
|
||||||
name: "Image",
|
name: "Image",
|
||||||
isActive: () => editor?.isActive("image") || editor?.isActive("imageComponent"),
|
isActive: () => editor?.isActive("image") || editor?.isActive("imageComponent"),
|
||||||
command: ({ savedSelection }) =>
|
command: () => insertImage({ editor, event: "insert", pos: editor.state.selection.from }),
|
||||||
insertImage({ editor, event: "insert", pos: savedSelection?.from ?? editor.state.selection.from }),
|
|
||||||
icon: ImageIcon,
|
icon: ImageIcon,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,27 +1,21 @@
|
||||||
import { MutableRefObject } from "react";
|
|
||||||
import { Selection } from "@tiptap/pm/state";
|
|
||||||
import { Editor } from "@tiptap/react";
|
import { Editor } from "@tiptap/react";
|
||||||
|
|
||||||
export const insertContentAtSavedSelection = (
|
export const insertContentAtSavedSelection = (editor: Editor, content: string) => {
|
||||||
editorRef: MutableRefObject<Editor | null>,
|
if (!editor || editor.isDestroyed) {
|
||||||
content: string,
|
|
||||||
savedSelection: Selection
|
|
||||||
) => {
|
|
||||||
if (!editorRef.current || editorRef.current.isDestroyed) {
|
|
||||||
console.error("Editor reference is not available or has been destroyed.");
|
console.error("Editor reference is not available or has been destroyed.");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!savedSelection) {
|
if (!editor.state.selection) {
|
||||||
console.error("Saved selection is invalid.");
|
console.error("Saved selection is invalid.");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const docSize = editorRef.current.state.doc.content.size;
|
const docSize = editor.state.doc.content.size;
|
||||||
const safePosition = Math.max(0, Math.min(savedSelection.anchor, docSize));
|
const safePosition = Math.max(0, Math.min(editor.state.selection.anchor, docSize));
|
||||||
|
|
||||||
try {
|
try {
|
||||||
editorRef.current.chain().focus().insertContentAt(safePosition, content).run();
|
editor.chain().focus().insertContentAt(safePosition, content).run();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("An error occurred while inserting content at saved selection:", error);
|
console.error("An error occurred while inserting content at saved selection:", error);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,11 @@
|
||||||
import { useImperativeHandle, useRef, MutableRefObject, useState, useEffect } from "react";
|
|
||||||
import { HocuspocusProvider } from "@hocuspocus/provider";
|
import { HocuspocusProvider } from "@hocuspocus/provider";
|
||||||
import { DOMSerializer } from "@tiptap/pm/model";
|
import { DOMSerializer } from "@tiptap/pm/model";
|
||||||
import { Selection } from "@tiptap/pm/state";
|
|
||||||
import { EditorProps } from "@tiptap/pm/view";
|
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";
|
import * as Y from "yjs";
|
||||||
// components
|
// components
|
||||||
import { EditorMenuItem, getEditorMenuItems } from "@/components/menus";
|
import { getEditorMenuItems } from "@/components/menus";
|
||||||
// extensions
|
// extensions
|
||||||
import { CoreEditorExtensions } from "@/extensions";
|
import { CoreEditorExtensions } from "@/extensions";
|
||||||
// helpers
|
// helpers
|
||||||
|
|
@ -71,14 +70,12 @@ export const useEditor = (props: CustomEditorProps) => {
|
||||||
provider,
|
provider,
|
||||||
autofocus = false,
|
autofocus = false,
|
||||||
} = props;
|
} = props;
|
||||||
// states
|
|
||||||
const [savedSelection, setSavedSelection] = useState<Selection | null>(null);
|
|
||||||
// refs
|
|
||||||
const editorRef: MutableRefObject<Editor | null> = useRef(null);
|
|
||||||
const savedSelectionRef = useRef(savedSelection);
|
|
||||||
const editor = useTiptapEditor(
|
const editor = useTiptapEditor(
|
||||||
{
|
{
|
||||||
editable,
|
editable,
|
||||||
|
immediatelyRender: false,
|
||||||
|
shouldRerenderOnTransaction: false,
|
||||||
autofocus,
|
autofocus,
|
||||||
editorProps: {
|
editorProps: {
|
||||||
...CoreEditorProps({
|
...CoreEditorProps({
|
||||||
|
|
@ -100,8 +97,7 @@ export const useEditor = (props: CustomEditorProps) => {
|
||||||
],
|
],
|
||||||
content: typeof initialValue === "string" && initialValue.trim() !== "" ? initialValue : "<p></p>",
|
content: typeof initialValue === "string" && initialValue.trim() !== "" ? initialValue : "<p></p>",
|
||||||
onCreate: () => handleEditorReady?.(true),
|
onCreate: () => handleEditorReady?.(true),
|
||||||
onTransaction: ({ editor }) => {
|
onTransaction: () => {
|
||||||
setSavedSelection(editor.state.selection);
|
|
||||||
onTransaction?.();
|
onTransaction?.();
|
||||||
},
|
},
|
||||||
onUpdate: ({ editor }) => onChange?.(editor.getJSON(), editor.getHTML()),
|
onUpdate: ({ editor }) => onChange?.(editor.getJSON(), editor.getHTML()),
|
||||||
|
|
@ -110,23 +106,17 @@ export const useEditor = (props: CustomEditorProps) => {
|
||||||
[editable]
|
[editable]
|
||||||
);
|
);
|
||||||
|
|
||||||
// Update the ref whenever savedSelection changes
|
|
||||||
useEffect(() => {
|
|
||||||
savedSelectionRef.current = savedSelection;
|
|
||||||
}, [savedSelection]);
|
|
||||||
|
|
||||||
// Effect for syncing SWR data
|
// Effect for syncing SWR data
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// value is null when intentionally passed where syncing is not yet
|
// value is null when intentionally passed where syncing is not yet
|
||||||
// supported and value is undefined when the data from swr is not populated
|
// 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) {
|
if (editor && !editor.isDestroyed && !editor.storage.imageComponent.uploadInProgress) {
|
||||||
try {
|
try {
|
||||||
editor.commands.setContent(value, false, { preserveWhitespace: "full" });
|
editor.commands.setContent(value, false, { preserveWhitespace: "full" });
|
||||||
const currentSavedSelection = savedSelectionRef.current;
|
if (editor.state.selection) {
|
||||||
if (currentSavedSelection) {
|
|
||||||
const docLength = editor.state.doc.content.size;
|
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);
|
editor.commands.setTextSelection(relativePosition);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|
@ -138,46 +128,40 @@ export const useEditor = (props: CustomEditorProps) => {
|
||||||
useImperativeHandle(
|
useImperativeHandle(
|
||||||
forwardedRef,
|
forwardedRef,
|
||||||
() => ({
|
() => ({
|
||||||
blur: () => editorRef.current?.commands.blur(),
|
blur: () => editor.commands.blur(),
|
||||||
scrollToNodeViaDOMCoordinates(behavior?: ScrollBehavior, pos?: number) {
|
scrollToNodeViaDOMCoordinates(behavior?: ScrollBehavior, pos?: number) {
|
||||||
const resolvedPos = pos ?? savedSelection?.from;
|
const resolvedPos = pos ?? editor.state.selection.from;
|
||||||
if (!editorRef.current || !resolvedPos) return;
|
if (!editor || !resolvedPos) return;
|
||||||
scrollToNodeViaDOMCoordinates(editorRef.current, resolvedPos, behavior);
|
scrollToNodeViaDOMCoordinates(editor, resolvedPos, behavior);
|
||||||
},
|
},
|
||||||
getCurrentCursorPosition: () => savedSelection?.from,
|
getCurrentCursorPosition: () => editor.state.selection.from,
|
||||||
clearEditor: (emitUpdate = false) => {
|
clearEditor: (emitUpdate = false) => {
|
||||||
editorRef.current?.chain().setMeta("skipImageDeletion", true).clearContent(emitUpdate).run();
|
editor?.chain().setMeta("skipImageDeletion", true).clearContent(emitUpdate).run();
|
||||||
},
|
},
|
||||||
setEditorValue: (content: string) => {
|
setEditorValue: (content: string) => {
|
||||||
editorRef.current?.commands.setContent(content, false, { preserveWhitespace: "full" });
|
editor?.commands.setContent(content, false, { preserveWhitespace: "full" });
|
||||||
},
|
},
|
||||||
setEditorValueAtCursorPosition: (content: string) => {
|
setEditorValueAtCursorPosition: (content: string) => {
|
||||||
if (savedSelection) {
|
if (editor.state.selection) {
|
||||||
insertContentAtSavedSelection(editorRef, content, savedSelection);
|
insertContentAtSavedSelection(editor, content);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
executeMenuItemCommand: (props) => {
|
executeMenuItemCommand: (props) => {
|
||||||
const { itemKey } = props;
|
const { itemKey } = props;
|
||||||
const editorItems = getEditorMenuItems(editorRef.current);
|
const editorItems = getEditorMenuItems(editor);
|
||||||
|
|
||||||
const getEditorMenuItem = (itemKey: TEditorCommands) => editorItems.find((item) => item.key === itemKey);
|
const getEditorMenuItem = (itemKey: TEditorCommands) => editorItems.find((item) => item.key === itemKey);
|
||||||
|
|
||||||
const item = getEditorMenuItem(itemKey);
|
const item = getEditorMenuItem(itemKey);
|
||||||
if (item) {
|
if (item) {
|
||||||
if (item.key === "image") {
|
|
||||||
(item as EditorMenuItem<"image">).command({
|
|
||||||
savedSelection: savedSelectionRef.current,
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
item.command(props);
|
item.command(props);
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
console.warn(`No command found for item: ${itemKey}`);
|
console.warn(`No command found for item: ${itemKey}`);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
isMenuItemActive: (props) => {
|
isMenuItemActive: (props) => {
|
||||||
const { itemKey } = props;
|
const { itemKey } = props;
|
||||||
const editorItems = getEditorMenuItems(editorRef.current);
|
const editorItems = getEditorMenuItems(editor);
|
||||||
|
|
||||||
const getEditorMenuItem = (itemKey: TEditorCommands) => editorItems.find((item) => item.key === itemKey);
|
const getEditorMenuItem = (itemKey: TEditorCommands) => editorItems.find((item) => item.key === itemKey);
|
||||||
const item = getEditorMenuItem(itemKey);
|
const item = getEditorMenuItem(itemKey);
|
||||||
|
|
@ -187,20 +171,20 @@ export const useEditor = (props: CustomEditorProps) => {
|
||||||
},
|
},
|
||||||
onHeadingChange: (callback: (headings: IMarking[]) => void) => {
|
onHeadingChange: (callback: (headings: IMarking[]) => void) => {
|
||||||
// Subscribe to update event emitted from headers extension
|
// Subscribe to update event emitted from headers extension
|
||||||
editorRef.current?.on("update", () => {
|
editor?.on("update", () => {
|
||||||
callback(editorRef.current?.storage.headingList.headings);
|
callback(editor?.storage.headingList.headings);
|
||||||
});
|
});
|
||||||
// Return a function to unsubscribe to the continuous transactions of
|
// Return a function to unsubscribe to the continuous transactions of
|
||||||
// the editor on unmounting the component that has subscribed to this
|
// the editor on unmounting the component that has subscribed to this
|
||||||
// method
|
// method
|
||||||
return () => {
|
return () => {
|
||||||
editorRef.current?.off("update");
|
editor?.off("update");
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
getHeadings: () => editorRef?.current?.storage.headingList.headings,
|
getHeadings: () => editor?.storage.headingList.headings,
|
||||||
onStateChange: (callback: () => void) => {
|
onStateChange: (callback: () => void) => {
|
||||||
// Subscribe to editor state changes
|
// Subscribe to editor state changes
|
||||||
editorRef.current?.on("transaction", () => {
|
editor?.on("transaction", () => {
|
||||||
callback();
|
callback();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -208,17 +192,17 @@ export const useEditor = (props: CustomEditorProps) => {
|
||||||
// the editor on unmounting the component that has subscribed to this
|
// the editor on unmounting the component that has subscribed to this
|
||||||
// method
|
// method
|
||||||
return () => {
|
return () => {
|
||||||
editorRef.current?.off("transaction");
|
editor?.off("transaction");
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
getMarkDown: (): string => {
|
getMarkDown: (): string => {
|
||||||
const markdownOutput = editorRef.current?.storage.markdown.getMarkdown();
|
const markdownOutput = editor?.storage.markdown.getMarkdown();
|
||||||
return markdownOutput;
|
return markdownOutput;
|
||||||
},
|
},
|
||||||
getDocument: () => {
|
getDocument: () => {
|
||||||
const documentBinary = provider?.document ? Y.encodeStateAsUpdate(provider?.document) : null;
|
const documentBinary = provider?.document ? Y.encodeStateAsUpdate(provider?.document) : null;
|
||||||
const documentHTML = editorRef.current?.getHTML() ?? "<p></p>";
|
const documentHTML = editor?.getHTML() ?? "<p></p>";
|
||||||
const documentJSON = editorRef.current?.getJSON() ?? null;
|
const documentJSON = editor.getJSON() ?? null;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
binary: documentBinary,
|
binary: documentBinary,
|
||||||
|
|
@ -227,19 +211,19 @@ export const useEditor = (props: CustomEditorProps) => {
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
scrollSummary: (marking: IMarking): void => {
|
scrollSummary: (marking: IMarking): void => {
|
||||||
if (!editorRef.current) return;
|
if (!editor) return;
|
||||||
scrollSummary(editorRef.current, marking);
|
scrollSummary(editor, marking);
|
||||||
},
|
},
|
||||||
isEditorReadyToDiscard: () => editorRef.current?.storage.imageComponent.uploadInProgress === false,
|
isEditorReadyToDiscard: () => editor?.storage.imageComponent.uploadInProgress === false,
|
||||||
setFocusAtPosition: (position: number) => {
|
setFocusAtPosition: (position: number) => {
|
||||||
if (!editorRef.current || editorRef.current.isDestroyed) {
|
if (!editor || editor.isDestroyed) {
|
||||||
console.error("Editor reference is not available or has been destroyed.");
|
console.error("Editor reference is not available or has been destroyed.");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
try {
|
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));
|
const safePosition = Math.max(0, Math.min(position, docSize));
|
||||||
editorRef.current
|
editor
|
||||||
.chain()
|
.chain()
|
||||||
.insertContentAt(safePosition, [{ type: "paragraph" }])
|
.insertContentAt(safePosition, [{ type: "paragraph" }])
|
||||||
.focus()
|
.focus()
|
||||||
|
|
@ -249,17 +233,17 @@ export const useEditor = (props: CustomEditorProps) => {
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
getSelectedText: () => {
|
getSelectedText: () => {
|
||||||
if (!editorRef.current) return null;
|
if (!editor) return null;
|
||||||
|
|
||||||
const { state } = editorRef.current;
|
const { state } = editor;
|
||||||
const { from, to, empty } = state.selection;
|
const { from, to, empty } = state.selection;
|
||||||
|
|
||||||
if (empty) return null;
|
if (empty) return null;
|
||||||
|
|
||||||
const nodesArray: string[] = [];
|
const nodesArray: string[] = [];
|
||||||
state.doc.nodesBetween(from, to, (node, _pos, parent) => {
|
state.doc.nodesBetween(from, to, (node, _pos, parent) => {
|
||||||
if (parent === state.doc && editorRef.current) {
|
if (parent === state.doc && editor) {
|
||||||
const serializer = DOMSerializer.fromSchema(editorRef.current?.schema);
|
const serializer = DOMSerializer.fromSchema(editor.schema);
|
||||||
const dom = serializer.serializeNode(node);
|
const dom = serializer.serializeNode(node);
|
||||||
const tempDiv = document.createElement("div");
|
const tempDiv = document.createElement("div");
|
||||||
tempDiv.appendChild(dom);
|
tempDiv.appendChild(dom);
|
||||||
|
|
@ -270,28 +254,21 @@ export const useEditor = (props: CustomEditorProps) => {
|
||||||
return selection;
|
return selection;
|
||||||
},
|
},
|
||||||
insertText: (contentHTML, insertOnNextLine) => {
|
insertText: (contentHTML, insertOnNextLine) => {
|
||||||
if (!editorRef.current) return;
|
if (!editor) return;
|
||||||
// get selection
|
const { from, to, empty } = editor.state.selection;
|
||||||
const { from, to, empty } = editorRef.current.state.selection;
|
|
||||||
if (empty) return;
|
if (empty) return;
|
||||||
if (insertOnNextLine) {
|
if (insertOnNextLine) {
|
||||||
// move cursor to the end of the selection and insert a new line
|
// move cursor to the end of the selection and insert a new line
|
||||||
editorRef.current
|
editor.chain().focus().setTextSelection(to).insertContent("<br />").insertContent(contentHTML).run();
|
||||||
.chain()
|
|
||||||
.focus()
|
|
||||||
.setTextSelection(to)
|
|
||||||
.insertContent("<br />")
|
|
||||||
.insertContent(contentHTML)
|
|
||||||
.run();
|
|
||||||
} else {
|
} else {
|
||||||
// replace selected text with the content provided
|
// 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: () => ({
|
getDocumentInfo: () => ({
|
||||||
characters: editorRef?.current?.storage?.characterCount?.characters?.() ?? 0,
|
characters: editor?.storage?.characterCount?.characters?.() ?? 0,
|
||||||
paragraphs: getParagraphCount(editorRef?.current?.state),
|
paragraphs: getParagraphCount(editor?.state),
|
||||||
words: editorRef?.current?.storage?.characterCount?.words?.() ?? 0,
|
words: editor?.storage?.characterCount?.words?.() ?? 0,
|
||||||
}),
|
}),
|
||||||
setProviderDocument: (value) => {
|
setProviderDocument: (value) => {
|
||||||
const document = provider?.document;
|
const document = provider?.document;
|
||||||
|
|
@ -301,16 +278,12 @@ export const useEditor = (props: CustomEditorProps) => {
|
||||||
emitRealTimeUpdate: (message: TDocumentEventsServer) => provider?.sendStateless(message),
|
emitRealTimeUpdate: (message: TDocumentEventsServer) => provider?.sendStateless(message),
|
||||||
listenToRealTimeUpdate: () => provider && { on: provider.on.bind(provider), off: provider.off.bind(provider) },
|
listenToRealTimeUpdate: () => provider && { on: provider.on.bind(provider), off: provider.off.bind(provider) },
|
||||||
}),
|
}),
|
||||||
[editorRef, savedSelection]
|
[editor]
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!editor) {
|
if (!editor) {
|
||||||
return null;
|
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;
|
return editor;
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
import { useImperativeHandle, useRef, MutableRefObject, useEffect } from "react";
|
|
||||||
import { HocuspocusProvider } from "@hocuspocus/provider";
|
import { HocuspocusProvider } from "@hocuspocus/provider";
|
||||||
import { EditorProps } from "@tiptap/pm/view";
|
import { EditorProps } from "@tiptap/pm/view";
|
||||||
import { useEditor as useCustomEditor, Editor, Extensions } from "@tiptap/react";
|
import { useEditor as useTiptapEditor, Extensions } from "@tiptap/react";
|
||||||
|
import { useImperativeHandle, MutableRefObject, useEffect } from "react";
|
||||||
import * as Y from "yjs";
|
import * as Y from "yjs";
|
||||||
// extensions
|
// extensions
|
||||||
import { CoreReadOnlyEditorExtensions } from "@/extensions";
|
import { CoreReadOnlyEditorExtensions } from "@/extensions";
|
||||||
|
|
@ -11,13 +11,7 @@ import { IMarking, scrollSummary } from "@/helpers/scroll-to-node";
|
||||||
// props
|
// props
|
||||||
import { CoreReadOnlyEditorProps } from "@/props";
|
import { CoreReadOnlyEditorProps } from "@/props";
|
||||||
// types
|
// types
|
||||||
import type {
|
import type { EditorReadOnlyRefApi, TExtensions, TFileHandler, TReadOnlyMentionHandler } from "@/types";
|
||||||
EditorReadOnlyRefApi,
|
|
||||||
TExtensions,
|
|
||||||
TDocumentEventsServer,
|
|
||||||
TFileHandler,
|
|
||||||
TReadOnlyMentionHandler,
|
|
||||||
} from "@/types";
|
|
||||||
|
|
||||||
interface CustomReadOnlyEditorProps {
|
interface CustomReadOnlyEditorProps {
|
||||||
disabledExtensions: TExtensions[];
|
disabledExtensions: TExtensions[];
|
||||||
|
|
@ -46,8 +40,10 @@ export const useReadOnlyEditor = (props: CustomReadOnlyEditorProps) => {
|
||||||
provider,
|
provider,
|
||||||
} = props;
|
} = props;
|
||||||
|
|
||||||
const editor = useCustomEditor({
|
const editor = useTiptapEditor({
|
||||||
editable: false,
|
editable: false,
|
||||||
|
immediatelyRender: true,
|
||||||
|
shouldRerenderOnTransaction: false,
|
||||||
content: typeof initialValue === "string" && initialValue.trim() !== "" ? initialValue : "<p></p>",
|
content: typeof initialValue === "string" && initialValue.trim() !== "" ? initialValue : "<p></p>",
|
||||||
editorProps: {
|
editorProps: {
|
||||||
...CoreReadOnlyEditorProps({
|
...CoreReadOnlyEditorProps({
|
||||||
|
|
@ -77,23 +73,21 @@ export const useReadOnlyEditor = (props: CustomReadOnlyEditorProps) => {
|
||||||
if (editor && !editor.isDestroyed) editor?.commands.setContent(initialValue, false, { preserveWhitespace: "full" });
|
if (editor && !editor.isDestroyed) editor?.commands.setContent(initialValue, false, { preserveWhitespace: "full" });
|
||||||
}, [editor, initialValue]);
|
}, [editor, initialValue]);
|
||||||
|
|
||||||
const editorRef: MutableRefObject<Editor | null> = useRef(null);
|
|
||||||
|
|
||||||
useImperativeHandle(forwardedRef, () => ({
|
useImperativeHandle(forwardedRef, () => ({
|
||||||
clearEditor: (emitUpdate = false) => {
|
clearEditor: (emitUpdate = false) => {
|
||||||
editorRef.current?.chain().setMeta("skipImageDeletion", true).clearContent(emitUpdate).run();
|
editor?.chain().setMeta("skipImageDeletion", true).clearContent(emitUpdate).run();
|
||||||
},
|
},
|
||||||
setEditorValue: (content: string) => {
|
setEditorValue: (content: string) => {
|
||||||
editorRef.current?.commands.setContent(content, false, { preserveWhitespace: "full" });
|
editor?.commands.setContent(content, false, { preserveWhitespace: "full" });
|
||||||
},
|
},
|
||||||
getMarkDown: (): string => {
|
getMarkDown: (): string => {
|
||||||
const markdownOutput = editorRef.current?.storage.markdown.getMarkdown();
|
const markdownOutput = editor?.storage.markdown.getMarkdown();
|
||||||
return markdownOutput;
|
return markdownOutput;
|
||||||
},
|
},
|
||||||
getDocument: () => {
|
getDocument: () => {
|
||||||
const documentBinary = provider?.document ? Y.encodeStateAsUpdate(provider?.document) : null;
|
const documentBinary = provider?.document ? Y.encodeStateAsUpdate(provider?.document) : null;
|
||||||
const documentHTML = editorRef.current?.getHTML() ?? "<p></p>";
|
const documentHTML = editor?.getHTML() ?? "<p></p>";
|
||||||
const documentJSON = editorRef.current?.getJSON() ?? null;
|
const documentJSON = editor?.getJSON() ?? null;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
binary: documentBinary,
|
binary: documentBinary,
|
||||||
|
|
@ -102,35 +96,22 @@ export const useReadOnlyEditor = (props: CustomReadOnlyEditorProps) => {
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
scrollSummary: (marking: IMarking): void => {
|
scrollSummary: (marking: IMarking): void => {
|
||||||
if (!editorRef.current) return;
|
if (!editor) return;
|
||||||
scrollSummary(editorRef.current, marking);
|
scrollSummary(editor, marking);
|
||||||
},
|
},
|
||||||
getDocumentInfo: () => ({
|
getDocumentInfo: () => {
|
||||||
characters: editorRef?.current?.storage?.characterCount?.characters?.() ?? 0,
|
if (!editor) return;
|
||||||
paragraphs: getParagraphCount(editorRef?.current?.state),
|
return {
|
||||||
words: editorRef?.current?.storage?.characterCount?.words?.() ?? 0,
|
characters: editor.storage?.characterCount?.characters?.() ?? 0,
|
||||||
}),
|
paragraphs: getParagraphCount(editor.state),
|
||||||
onHeadingChange: (callback: (headings: IMarking[]) => void) => {
|
words: editor.storage?.characterCount?.words?.() ?? 0,
|
||||||
// Subscribe to update event emitted from headers extension
|
|
||||||
editorRef.current?.on("update", () => {
|
|
||||||
callback(editorRef.current?.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");
|
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
emitRealTimeUpdate: (message: TDocumentEventsServer) => provider?.sendStateless(message),
|
|
||||||
listenToRealTimeUpdate: () => provider && { on: provider.on.bind(provider), off: provider.off.bind(provider) },
|
|
||||||
getHeadings: () => editorRef?.current?.storage.headingList.headings,
|
|
||||||
}));
|
}));
|
||||||
|
|
||||||
if (!editor) {
|
if (!editor) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
editorRef.current = editor;
|
|
||||||
return editor;
|
return editor;
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -86,10 +86,6 @@ export type EditorReadOnlyRefApi = {
|
||||||
paragraphs: number;
|
paragraphs: number;
|
||||||
words: number;
|
words: number;
|
||||||
};
|
};
|
||||||
onHeadingChange: (callback: (headings: IMarking[]) => void) => () => void;
|
|
||||||
getHeadings: () => IMarking[];
|
|
||||||
emitRealTimeUpdate: (action: TDocumentEventsServer) => void;
|
|
||||||
listenToRealTimeUpdate: () => TDocumentEventEmitter | undefined;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export interface EditorRefApi extends EditorReadOnlyRefApi {
|
export interface EditorRefApi extends EditorReadOnlyRefApi {
|
||||||
|
|
@ -105,6 +101,10 @@ export interface EditorRefApi extends EditorReadOnlyRefApi {
|
||||||
getSelectedText: () => string | null;
|
getSelectedText: () => string | null;
|
||||||
insertText: (contentHTML: string, insertOnNextLine?: boolean) => void;
|
insertText: (contentHTML: string, insertOnNextLine?: boolean) => void;
|
||||||
setProviderDocument: (value: Uint8Array) => void;
|
setProviderDocument: (value: Uint8Array) => void;
|
||||||
|
onHeadingChange: (callback: (headings: IMarking[]) => void) => () => void;
|
||||||
|
getHeadings: () => IMarking[];
|
||||||
|
emitRealTimeUpdate: (action: TDocumentEventsServer) => void;
|
||||||
|
listenToRealTimeUpdate: () => TDocumentEventEmitter | undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
// editor props
|
// editor props
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue