diff --git a/live/src/core/hocuspocus-server.ts b/live/src/core/hocuspocus-server.ts index 7abfebf7f50..8f6170ea311 100644 --- a/live/src/core/hocuspocus-server.ts +++ b/live/src/core/hocuspocus-server.ts @@ -34,5 +34,6 @@ export const getHocusPocusServer = async () => { } }, extensions, + debounce: 10000 }); }; diff --git a/packages/editor/src/core/helpers/yjs.ts b/packages/editor/src/core/helpers/yjs.ts new file mode 100644 index 00000000000..ffd9367107d --- /dev/null +++ b/packages/editor/src/core/helpers/yjs.ts @@ -0,0 +1,16 @@ +import * as Y from "yjs"; + +/** + * @description apply updates to a doc and return the updated doc in base64(binary) format + * @param {Uint8Array} document + * @param {Uint8Array} updates + * @returns {string} base64(binary) form of the updated doc + */ +export const applyUpdates = (document: Uint8Array, updates: Uint8Array): Uint8Array => { + const yDoc = new Y.Doc(); + Y.applyUpdate(yDoc, document); + Y.applyUpdate(yDoc, updates); + + const encodedDoc = Y.encodeStateAsUpdate(yDoc); + return encodedDoc; +}; diff --git a/packages/editor/src/core/hooks/use-collaborative-editor.ts b/packages/editor/src/core/hooks/use-collaborative-editor.ts index 35456068301..3e42dc0db79 100644 --- a/packages/editor/src/core/hooks/use-collaborative-editor.ts +++ b/packages/editor/src/core/hooks/use-collaborative-editor.ts @@ -68,10 +68,6 @@ export const useCollaborativeEditor = (props: TCollaborativeEditorProps) => { editorProps, editorClassName, enableHistory: false, - fileHandler, - handleEditorReady, - forwardedRef, - mentionHandler, extensions: [ SideMenuExtension({ aiEnabled: !disabledExtensions?.includes("ai"), @@ -88,7 +84,12 @@ export const useCollaborativeEditor = (props: TCollaborativeEditorProps) => { userDetails: user, }), ], + fileHandler, + handleEditorReady, + forwardedRef, + mentionHandler, placeholder, + provider, tabIndex, }); diff --git a/packages/editor/src/core/hooks/use-editor.ts b/packages/editor/src/core/hooks/use-editor.ts index d89d62c95fe..05d87e596c8 100644 --- a/packages/editor/src/core/hooks/use-editor.ts +++ b/packages/editor/src/core/hooks/use-editor.ts @@ -1,8 +1,10 @@ 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 } from "@tiptap/react"; +import * as Y from "yjs"; // components import { getEditorMenuItems } from "@/components/menus"; // extensions @@ -32,6 +34,7 @@ export interface CustomEditorProps { }; onChange?: (json: object, html: string) => void; placeholder?: string | ((isFocused: boolean, value: string) => string); + provider?: HocuspocusProvider; tabIndex?: number; // undefined when prop is not passed, null if intentionally passed to stop // swr syncing @@ -52,6 +55,7 @@ export const useEditor = (props: CustomEditorProps) => { mentionHandler, onChange, placeholder, + provider, tabIndex, value, } = props; @@ -186,9 +190,16 @@ export const useEditor = (props: CustomEditorProps) => { const markdownOutput = editorRef.current?.storage.markdown.getMarkdown(); return markdownOutput; }, - getHTML: (): string => { - const htmlOutput = editorRef.current?.getHTML() ?? "

"; - return htmlOutput; + getDocument: () => { + const documentBinary = provider?.document ? Y.encodeStateAsUpdate(provider?.document) : null; + const documentHTML = editorRef.current?.getHTML() ?? "

"; + const documentJSON = editorRef.current?.getJSON() ?? null; + + return { + binary: documentBinary, + html: documentHTML, + json: documentJSON, + }; }, scrollSummary: (marking: IMarking): void => { if (!editorRef.current) return; @@ -259,6 +270,11 @@ export const useEditor = (props: CustomEditorProps) => { words: editorRef?.current?.storage?.characterCount?.words?.() ?? 0, }; }, + setProviderDocument: (value) => { + const document = provider?.document; + if (!document) return; + Y.applyUpdate(document, value); + }, }), [editorRef, savedSelection] ); diff --git a/packages/editor/src/core/hooks/use-read-only-collaborative-editor.ts b/packages/editor/src/core/hooks/use-read-only-collaborative-editor.ts index fb85fb8a12c..3846ebb0f61 100644 --- a/packages/editor/src/core/hooks/use-read-only-collaborative-editor.ts +++ b/packages/editor/src/core/hooks/use-read-only-collaborative-editor.ts @@ -54,15 +54,16 @@ export const useReadOnlyCollaborativeEditor = (props: TReadOnlyCollaborativeEdit const editor = useReadOnlyEditor({ editorProps, editorClassName, - forwardedRef, - handleEditorReady, - mentionHandler, extensions: [ ...(extensions ?? []), Collaboration.configure({ document: provider.document, }), ], + forwardedRef, + handleEditorReady, + mentionHandler, + provider, }); return { editor, isIndexedDbSynced: true }; diff --git a/packages/editor/src/core/hooks/use-read-only-editor.ts b/packages/editor/src/core/hooks/use-read-only-editor.ts index 66d7fed18b9..84b700666f7 100644 --- a/packages/editor/src/core/hooks/use-read-only-editor.ts +++ b/packages/editor/src/core/hooks/use-read-only-editor.ts @@ -1,6 +1,8 @@ import { useImperativeHandle, useRef, MutableRefObject, useEffect } from "react"; +import { HocuspocusProvider } from "@hocuspocus/provider"; import { EditorProps } from "@tiptap/pm/view"; import { useEditor as useCustomEditor, Editor } from "@tiptap/react"; +import * as Y from "yjs"; // extensions import { CoreReadOnlyEditorExtensions } from "@/extensions"; // helpers @@ -21,17 +23,21 @@ interface CustomReadOnlyEditorProps { mentionHandler: { highlights: () => Promise; }; + provider?: HocuspocusProvider; } -export const useReadOnlyEditor = ({ - initialValue, - editorClassName, - forwardedRef, - extensions = [], - editorProps = {}, - handleEditorReady, - mentionHandler, -}: CustomReadOnlyEditorProps) => { +export const useReadOnlyEditor = (props: CustomReadOnlyEditorProps) => { + const { + initialValue, + editorClassName, + forwardedRef, + extensions = [], + editorProps = {}, + handleEditorReady, + mentionHandler, + provider, + } = props; + const editor = useCustomEditor({ editable: false, content: typeof initialValue === "string" && initialValue.trim() !== "" ? initialValue : "

", @@ -74,9 +80,16 @@ export const useReadOnlyEditor = ({ const markdownOutput = editorRef.current?.storage.markdown.getMarkdown(); return markdownOutput; }, - getHTML: (): string => { - const htmlOutput = editorRef.current?.getHTML() ?? "

"; - return htmlOutput; + getDocument: () => { + const documentBinary = provider?.document ? Y.encodeStateAsUpdate(provider?.document) : null; + const documentHTML = editorRef.current?.getHTML() ?? "

"; + const documentJSON = editorRef.current?.getJSON() ?? null; + + return { + binary: documentBinary, + html: documentHTML, + json: documentJSON, + }; }, scrollSummary: (marking: IMarking): void => { if (!editorRef.current) return; diff --git a/packages/editor/src/core/types/editor.ts b/packages/editor/src/core/types/editor.ts index c89771e1514..c5e2a8c865d 100644 --- a/packages/editor/src/core/types/editor.ts +++ b/packages/editor/src/core/types/editor.ts @@ -1,3 +1,4 @@ +import { JSONContent } from "@tiptap/core"; // helpers import { IMarking } from "@/helpers/scroll-to-node"; // types @@ -16,7 +17,11 @@ import { // editor refs export type EditorReadOnlyRefApi = { getMarkDown: () => string; - getHTML: () => string; + getDocument: () => { + binary: Uint8Array | null; + html: string; + json: JSONContent | null; + }; clearEditor: (emitUpdate?: boolean) => void; setEditorValue: (content: string) => void; scrollSummary: (marking: IMarking) => void; @@ -38,6 +43,7 @@ export interface EditorRefApi extends EditorReadOnlyRefApi { isEditorReadyToDiscard: () => boolean; getSelectedText: () => string | null; insertText: (contentHTML: string, insertOnNextLine?: boolean) => void; + setProviderDocument: (value: Uint8Array) => void; } // editor props diff --git a/packages/editor/src/index.ts b/packages/editor/src/index.ts index 0ee93d4c411..fc9fe1ac603 100644 --- a/packages/editor/src/index.ts +++ b/packages/editor/src/index.ts @@ -21,6 +21,7 @@ export { isCellSelection } from "@/extensions/table/table/utilities/is-cell-sele // helpers export * from "@/helpers/common"; export * from "@/helpers/editor-commands"; +export * from "@/helpers/yjs"; export * from "@/extensions/table/table"; // components diff --git a/packages/types/src/pages.d.ts b/packages/types/src/pages.d.ts index a78ff30568b..011f92d69ba 100644 --- a/packages/types/src/pages.d.ts +++ b/packages/types/src/pages.d.ts @@ -63,4 +63,10 @@ export type TPageVersion = { updated_at: string; updated_by: string; workspace: string; +} + +export type TDocumentPayload = { + description_binary: string; + description_html: string; + description: object; } \ No newline at end of file diff --git a/web/core/components/pages/editor/editor-body.tsx b/web/core/components/pages/editor/editor-body.tsx index b6480e9a75e..f1a1f2fc4eb 100644 --- a/web/core/components/pages/editor/editor-body.tsx +++ b/web/core/components/pages/editor/editor-body.tsx @@ -205,7 +205,6 @@ export const PageEditorBody: React.FC = observer((props) => { }, }} realtimeConfig={realtimeConfig} - serverHandler={serverHandler} user={{ id: currentUser?.id ?? "", name: currentUser?.display_name ?? "", diff --git a/web/core/components/pages/editor/header/extra-options.tsx b/web/core/components/pages/editor/header/extra-options.tsx index c45d9e99f54..6ac20a144c5 100644 --- a/web/core/components/pages/editor/header/extra-options.tsx +++ b/web/core/components/pages/editor/header/extra-options.tsx @@ -1,7 +1,6 @@ "use client"; import { observer } from "mobx-react"; -import { CircleAlert } from "lucide-react"; // editor import { EditorReadOnlyRefApi, EditorRefApi } from "@plane/editor"; // ui @@ -19,13 +18,12 @@ import { IPage } from "@/store/pages/page"; type Props = { editorRef: React.RefObject; handleDuplicatePage: () => void; - hasConnectionFailed: boolean; page: IPage; readOnlyEditorRef: React.RefObject; }; export const PageExtraOptions: React.FC = observer((props) => { - const { editorRef, handleDuplicatePage, hasConnectionFailed, page, readOnlyEditorRef } = props; + const { editorRef, handleDuplicatePage, page, readOnlyEditorRef } = props; // derived values const { archived_at, @@ -79,17 +77,6 @@ export const PageExtraOptions: React.FC = observer((props) => { )} - {hasConnectionFailed && isOnline && ( - -
- - Server error -
-
- )} {canCurrentUserFavoritePage && ( ; handleDuplicatePage: () => void; - hasConnectionFailed: boolean; page: IPage; readOnlyEditorReady: boolean; readOnlyEditorRef: React.RefObject; @@ -25,7 +24,6 @@ export const PageEditorMobileHeaderRoot: React.FC = observer((props) => { editorReady, editorRef, handleDuplicatePage, - hasConnectionFailed, page, readOnlyEditorReady, readOnlyEditorRef, @@ -53,7 +51,6 @@ export const PageEditorMobileHeaderRoot: React.FC = observer((props) => { diff --git a/web/core/components/pages/editor/header/root.tsx b/web/core/components/pages/editor/header/root.tsx index bdac23822ba..9640f4e43b6 100644 --- a/web/core/components/pages/editor/header/root.tsx +++ b/web/core/components/pages/editor/header/root.tsx @@ -14,7 +14,6 @@ type Props = { editorReady: boolean; editorRef: React.RefObject; handleDuplicatePage: () => void; - hasConnectionFailed: boolean; page: IPage; readOnlyEditorReady: boolean; readOnlyEditorRef: React.RefObject; @@ -27,7 +26,6 @@ export const PageEditorHeaderRoot: React.FC = observer((props) => { editorReady, editorRef, handleDuplicatePage, - hasConnectionFailed, page, readOnlyEditorReady, readOnlyEditorRef, @@ -67,7 +65,6 @@ export const PageEditorHeaderRoot: React.FC = observer((props) => { @@ -79,7 +76,6 @@ export const PageEditorHeaderRoot: React.FC = observer((props) => { editorReady={editorReady} readOnlyEditorReady={readOnlyEditorReady} handleDuplicatePage={handleDuplicatePage} - hasConnectionFailed={hasConnectionFailed} page={page} sidePeekVisible={sidePeekVisible} setSidePeekVisible={setSidePeekVisible} diff --git a/web/core/components/pages/editor/page-root.tsx b/web/core/components/pages/editor/page-root.tsx index ff77983be9d..ff1f3519e93 100644 --- a/web/core/components/pages/editor/page-root.tsx +++ b/web/core/components/pages/editor/page-root.tsx @@ -2,7 +2,7 @@ import { useEffect, useRef, useState } from "react"; import { observer } from "mobx-react"; import { useSearchParams } from "next/navigation"; // editor -import { EditorRefApi } from "@plane/editor"; +import { EditorReadOnlyRefApi, EditorRefApi } from "@plane/editor"; // types import { TPage } from "@plane/types"; // ui @@ -12,9 +12,11 @@ import { PageEditorHeaderRoot, PageEditorBody, PageVersionsOverlay, PagesVersion // hooks import { useProjectPages } from "@/hooks/store"; import { useAppRouter } from "@/hooks/use-app-router"; +import { usePageFallback } from "@/hooks/use-page-fallback"; import { useQueryParams } from "@/hooks/use-query-params"; // services -import { ProjectPageVersionService } from "@/services/page"; +import { ProjectPageService, ProjectPageVersionService } from "@/services/page"; +const projectPageService = new ProjectPageService(); const projectPageVersionService = new ProjectPageVersionService(); // store import { IPage } from "@/store/pages/page"; @@ -29,8 +31,8 @@ export const PageRoot = observer((props: TPageRootProps) => { const { projectId, workspaceSlug, page } = props; // states const [editorReady, setEditorReady] = useState(false); - const [readOnlyEditorReady, setReadOnlyEditorReady] = useState(false); const [hasConnectionFailed, setHasConnectionFailed] = useState(false); + const [readOnlyEditorReady, setReadOnlyEditorReady] = useState(false); const [sidePeekVisible, setSidePeekVisible] = useState(window.innerWidth >= 768); const [isVersionsOverlayOpen, setIsVersionsOverlayOpen] = useState(false); // refs @@ -43,8 +45,17 @@ export const PageRoot = observer((props: TPageRootProps) => { // store hooks const { createPage } = useProjectPages(); // derived values - const { access, description_html, name, isContentEditable } = page; - + const { access, description_html, name, isContentEditable, updateDescription } = page; + // page fallback + usePageFallback({ + editorRef, + fetchPageDescription: async () => { + if (!page.id) return; + return await projectPageService.fetchDescriptionBinary(workspaceSlug, projectId, page.id); + }, + hasConnectionFailed, + updatePageDescription: async (data) => await updateDescription(data), + }); // update query params const { updateQueryParams } = useQueryParams(); @@ -53,7 +64,7 @@ export const PageRoot = observer((props: TPageRootProps) => { const handleDuplicatePage = async () => { const formData: Partial = { name: "Copy of " + name, - description_html: editorRef.current?.getHTML() ?? description_html ?? "

", + description_html: editorRef.current?.getDocument().html ?? description_html ?? "

", access, }; @@ -89,8 +100,8 @@ export const PageRoot = observer((props: TPageRootProps) => { editorRef.current?.setEditorValue(descriptionHTML); }; const currentVersionDescription = isContentEditable - ? editorRef.current?.getHTML() - : readOnlyEditorRef.current?.getHTML(); + ? editorRef.current?.getDocument().html + : readOnlyEditorRef.current?.getDocument().html; return ( <> @@ -125,7 +136,6 @@ export const PageRoot = observer((props: TPageRootProps) => { editorReady={editorReady} editorRef={editorRef} handleDuplicatePage={handleDuplicatePage} - hasConnectionFailed={hasConnectionFailed} page={page} readOnlyEditorReady={readOnlyEditorReady} readOnlyEditorRef={readOnlyEditorRef} diff --git a/web/core/hooks/use-auto-save.tsx b/web/core/hooks/use-auto-save.tsx index c477fb10453..8f4dd39f43b 100644 --- a/web/core/hooks/use-auto-save.tsx +++ b/web/core/hooks/use-auto-save.tsx @@ -1,9 +1,9 @@ import { useEffect, useRef } from "react"; import { debounce } from "lodash"; -const AUTO_SAVE_TIME = 10000; +const AUTO_SAVE_TIME = 30000; -const useAutoSave = (handleSaveDescription: (forceSync?: boolean, yjsAsUpdate?: Uint8Array) => void) => { +const useAutoSave = (handleSaveDescription: () => void) => { const intervalIdRef = useRef(null); const handleSaveDescriptionRef = useRef(handleSaveDescription); @@ -16,7 +16,7 @@ const useAutoSave = (handleSaveDescription: (forceSync?: boolean, yjsAsUpdate?: useEffect(() => { intervalIdRef.current = setInterval(() => { try { - handleSaveDescriptionRef.current(true); + handleSaveDescriptionRef.current(); } catch (error) { console.error("Autosave before manual save failed:", error); } @@ -43,7 +43,7 @@ const useAutoSave = (handleSaveDescription: (forceSync?: boolean, yjsAsUpdate?: clearInterval(intervalIdRef.current); intervalIdRef.current = setInterval(() => { try { - handleSaveDescriptionRef.current(true); + handleSaveDescriptionRef.current(); } catch (error) { console.error("Autosave after manual save failed:", error); } diff --git a/web/core/hooks/use-page-fallback.ts b/web/core/hooks/use-page-fallback.ts new file mode 100644 index 00000000000..9f5ef348293 --- /dev/null +++ b/web/core/hooks/use-page-fallback.ts @@ -0,0 +1,48 @@ +import { useCallback, useEffect } from "react"; +// plane editor +import { EditorRefApi } from "@plane/editor"; +// plane types +import { TDocumentPayload } from "@plane/types"; +// hooks +import useAutoSave from "@/hooks/use-auto-save"; + +type TArgs = { + editorRef: React.RefObject; + fetchPageDescription: () => Promise; + hasConnectionFailed: boolean; + updatePageDescription: (data: TDocumentPayload) => Promise; +}; + +export const usePageFallback = (args: TArgs) => { + const { editorRef, fetchPageDescription, hasConnectionFailed, updatePageDescription } = args; + + const handleUpdateDescription = useCallback(async () => { + if (!hasConnectionFailed) return; + const editor = editorRef.current; + if (!editor) return; + + const latestEncodedDescription = await fetchPageDescription(); + const latestDecodedDescription = latestEncodedDescription + ? new Uint8Array(latestEncodedDescription) + : new Uint8Array(); + + editor.setProviderDocument(latestDecodedDescription); + const { binary, html, json } = editor.getDocument(); + if (!binary || !json) return; + const encodedBinary = Buffer.from(binary).toString("base64"); + + await updatePageDescription({ + description_binary: encodedBinary, + description_html: html, + description: json, + }); + }, [hasConnectionFailed]); + + useEffect(() => { + if (hasConnectionFailed) { + handleUpdateDescription(); + } + }, [hasConnectionFailed]); + + useAutoSave(handleUpdateDescription); +}; diff --git a/web/core/services/page/project-page.service.ts b/web/core/services/page/project-page.service.ts index 8439f36ab09..e2f22d5ad3c 100644 --- a/web/core/services/page/project-page.service.ts +++ b/web/core/services/page/project-page.service.ts @@ -1,5 +1,5 @@ // types -import { TPage } from "@plane/types"; +import { TDocumentPayload, TPage } from "@plane/types"; // helpers import { API_BASE_URL } from "@/helpers/common.helper"; // services @@ -128,7 +128,7 @@ export class ProjectPageService extends APIService { }); } - async fetchDescriptionYJS(workspaceSlug: string, projectId: string, pageId: string): Promise { + async fetchDescriptionBinary(workspaceSlug: string, projectId: string, pageId: string): Promise { return this.get(`/api/workspaces/${workspaceSlug}/projects/${projectId}/pages/${pageId}/description/`, { headers: { "Content-Type": "application/octet-stream", @@ -145,10 +145,7 @@ export class ProjectPageService extends APIService { workspaceSlug: string, projectId: string, pageId: string, - data: { - description_binary: string; - description_html: string; - } + data: TDocumentPayload ): Promise { return this.patch(`/api/workspaces/${workspaceSlug}/projects/${projectId}/pages/${pageId}/description/`, data) .then((response) => response?.data) diff --git a/web/core/store/pages/page.ts b/web/core/store/pages/page.ts index a6e5629848e..ee4c499b80c 100644 --- a/web/core/store/pages/page.ts +++ b/web/core/store/pages/page.ts @@ -1,7 +1,7 @@ import set from "lodash/set"; import { action, computed, makeObservable, observable, reaction, runInAction } from "mobx"; // types -import { TLogoProps, TPage } from "@plane/types"; +import { TDocumentPayload, TLogoProps, TPage } from "@plane/types"; // constants import { EPageAccess } from "@/constants/page"; import { EUserPermissions } from "@/plane-web/constants/user-permissions"; @@ -33,7 +33,7 @@ export interface IPage extends TPage { // actions update: (pageData: Partial) => Promise; updateTitle: (title: string) => void; - updateDescription: (binaryString: string, descriptionHTML: string) => Promise; + updateDescription: (document: TDocumentPayload) => Promise; makePublic: () => Promise; makePrivate: () => Promise; lock: () => Promise; @@ -367,23 +367,19 @@ export class Page implements IPage { /** * @description update the page description - * @param {string} binaryString - * @param {string} descriptionHTML + * @param {TDocumentPayload} document */ - updateDescription = async (binaryString: string, descriptionHTML: string) => { + updateDescription = async (document: TDocumentPayload) => { const { workspaceSlug, projectId } = this.store.router; if (!workspaceSlug || !projectId || !this.id) return undefined; const currentDescription = this.description_html; runInAction(() => { - this.description_html = descriptionHTML; + this.description_html = document.description_html; }); try { - await this.pageService.updateDescriptionYJS(workspaceSlug, projectId, this.id, { - description_binary: binaryString, - description_html: descriptionHTML, - }); + await this.pageService.updateDescriptionYJS(workspaceSlug, projectId, this.id, document); } catch (error) { runInAction(() => { this.description_html = currentDescription;