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;