From c440e59c247859df1654f6d42cb951d00459ea94 Mon Sep 17 00:00:00 2001 From: Aaryan Khandelwal Date: Tue, 17 Sep 2024 15:46:02 +0530 Subject: [PATCH 1/6] chore: add fallback for the live server --- packages/editor/src/core/helpers/yjs.ts | 17 +++++++ .../core/hooks/use-collaborative-editor.ts | 9 ++-- packages/editor/src/core/hooks/use-editor.ts | 17 +++++-- .../use-read-only-collaborative-editor.ts | 7 +-- .../src/core/hooks/use-read-only-editor.ts | 37 +++++++++----- packages/editor/src/core/types/editor.ts | 7 ++- packages/editor/src/index.ts | 1 + packages/types/src/pages.d.ts | 6 +++ .../components/pages/editor/editor-body.tsx | 19 ------- .../pages/editor/header/extra-options.tsx | 15 +----- .../pages/editor/header/mobile-root.tsx | 3 -- .../components/pages/editor/header/root.tsx | 4 -- .../components/pages/editor/page-root.tsx | 24 ++++++--- web/core/hooks/use-auto-save.tsx | 8 +-- web/core/hooks/use-page-fallback.ts | 49 +++++++++++++++++++ .../services/page/project-page.service.ts | 9 ++-- web/core/store/pages/page.ts | 16 +++--- 17 files changed, 157 insertions(+), 91 deletions(-) create mode 100644 packages/editor/src/core/helpers/yjs.ts create mode 100644 web/core/hooks/use-page-fallback.ts diff --git a/packages/editor/src/core/helpers/yjs.ts b/packages/editor/src/core/helpers/yjs.ts new file mode 100644 index 00000000000..b8122031f72 --- /dev/null +++ b/packages/editor/src/core/helpers/yjs.ts @@ -0,0 +1,17 @@ +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): string => { + const yDoc = new Y.Doc(); + Y.applyUpdate(yDoc, document); + Y.applyUpdate(yDoc, updates); + + const encodedDoc = Y.encodeStateAsUpdate(yDoc); + const base64Updates = Buffer.from(encodedDoc).toString("base64"); + return base64Updates; +}; 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 0542418e5d5..53ee3736c5a 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; @@ -170,9 +174,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; 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 3ee7f8e9075..fab18a8131a 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 f6c790305f3..2041e33d334 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; 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 6cd898c0079..f9d3fe9345a 100644 --- a/web/core/components/pages/editor/editor-body.tsx +++ b/web/core/components/pages/editor/editor-body.tsx @@ -38,7 +38,6 @@ const fileService = new FileService(); type Props = { editorRef: React.RefObject; - handleConnectionStatus: (status: boolean) => void; handleEditorReady: (value: boolean) => void; handleReadOnlyEditorReady: (value: boolean) => void; markings: IMarking[]; @@ -51,7 +50,6 @@ type Props = { export const PageEditorBody: React.FC = observer((props) => { const { editorRef, - handleConnectionStatus, handleEditorReady, handleReadOnlyEditorReady, markings, @@ -101,21 +99,6 @@ export const PageEditorBody: React.FC = observer((props) => { [editorRef] ); - const handleServerConnect = useCallback(() => { - handleConnectionStatus(false); - }, []); - const handleServerError = useCallback(() => { - handleConnectionStatus(true); - }, []); - - const serverHandler: TServerHandler = useMemo( - () => ({ - onConnect: handleServerConnect, - onServerError: handleServerError, - }), - [] - ); - useEffect(() => { updateMarkings(pageDescription ?? "

"); }, [pageDescription, updateMarkings]); @@ -187,7 +170,6 @@ export const PageEditorBody: React.FC = observer((props) => { issue: issueEmbedProps, }} realtimeConfig={realtimeConfig} - serverHandler={serverHandler} user={{ id: currentUser?.id ?? "", name: currentUser?.display_name ?? "", @@ -215,7 +197,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; markings: IMarking[]; page: IPage; readOnlyEditorReady: boolean; @@ -26,7 +25,6 @@ export const PageEditorMobileHeaderRoot: React.FC = observer((props) => { editorReady, editorRef, handleDuplicatePage, - hasConnectionFailed, markings, page, readOnlyEditorReady, @@ -56,7 +54,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 8fe5040650c..39269cfb219 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; markings: IMarking[]; page: IPage; readOnlyEditorReady: boolean; @@ -28,7 +27,6 @@ export const PageEditorHeaderRoot: React.FC = observer((props) => { editorReady, editorRef, handleDuplicatePage, - hasConnectionFailed, markings, page, readOnlyEditorReady, @@ -68,7 +66,6 @@ export const PageEditorHeaderRoot: React.FC = observer((props) => { @@ -81,7 +78,6 @@ export const PageEditorHeaderRoot: React.FC = observer((props) => { readOnlyEditorReady={readOnlyEditorReady} markings={markings} 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 b3c02cd22c8..bc7b0b3d9a0 100644 --- a/web/core/components/pages/editor/page-root.tsx +++ b/web/core/components/pages/editor/page-root.tsx @@ -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"; @@ -30,7 +32,6 @@ export const PageRoot = observer((props: TPageRootProps) => { // states const [editorReady, setEditorReady] = useState(false); const [readOnlyEditorReady, setReadOnlyEditorReady] = useState(false); - const [hasConnectionFailed, setHasConnectionFailed] = useState(false); const [sidePeekVisible, setSidePeekVisible] = useState(window.innerWidth >= 768); const [isVersionsOverlayOpen, setIsVersionsOverlayOpen] = useState(false); // refs @@ -43,7 +44,16 @@ 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); + }, + updatePageDescription: async (data) => await updateDescription(data), + }); // editor markings hook const { markings, updateMarkings } = useEditorMarkings(); // update query params @@ -54,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, }; @@ -90,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 ( <> @@ -126,7 +136,6 @@ export const PageRoot = observer((props: TPageRootProps) => { editorReady={editorReady} editorRef={editorRef} handleDuplicatePage={handleDuplicatePage} - hasConnectionFailed={hasConnectionFailed} markings={markings} page={page} readOnlyEditorReady={readOnlyEditorReady} @@ -136,7 +145,6 @@ export const PageRoot = observer((props: TPageRootProps) => { /> setHasConnectionFailed(status)} handleEditorReady={(val) => setEditorReady(val)} handleReadOnlyEditorReady={() => setReadOnlyEditorReady(true)} markings={markings} 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..8137501450f --- /dev/null +++ b/web/core/hooks/use-page-fallback.ts @@ -0,0 +1,49 @@ +import { useCallback } from "react"; +import useSWR from "swr"; +// plane editor +import { applyUpdates, EditorRefApi } from "@plane/editor"; +// plane types +import { TDocumentPayload } from "@plane/types"; +// helpers +import { LIVE_URL } from "@/helpers/common.helper"; +// hooks +import useAutoSave from "@/hooks/use-auto-save"; + +type TArgs = { + editorRef: React.RefObject; + fetchPageDescription: () => Promise; + updatePageDescription: (data: TDocumentPayload) => Promise; +}; + +export const usePageFallback = (args: TArgs) => { + const { editorRef, fetchPageDescription, updatePageDescription } = args; + + const { error } = useSWR("LIVE_SERVER_HEALTH_CHECK", async () => await fetch(`${LIVE_URL}/collaboration/health`), { + errorRetryCount: 5, + revalidateOnFocus: true, + revalidateOnReconnect: true, + }); + + const handleUpdateDescription = useCallback(async () => { + if (!error) return; + const editor = editorRef.current; + if (!editor) return; + const { binary, html, json } = editor.getDocument(); + if (!binary || !json) return; + + const latestEncodedDescription = await fetchPageDescription(); + const latestDecodedDescription = latestEncodedDescription + ? new Uint8Array(latestEncodedDescription) + : new Uint8Array(); + + const mergedEncodedDescription = applyUpdates(latestDecodedDescription, binary); + + await updatePageDescription({ + description_binary: mergedEncodedDescription, + description_html: html, + description: json, + }); + }, [error]); + + 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 dd772ad01a9..efd05aa32b5 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; @@ -373,23 +373,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; From 69db4600583c894648db8b37c13e88eecc37235c Mon Sep 17 00:00:00 2001 From: Aaryan Khandelwal Date: Tue, 17 Sep 2024 17:07:21 +0530 Subject: [PATCH 2/6] fix: update provider document after patch request --- packages/editor/src/core/helpers/yjs.ts | 5 ++--- packages/editor/src/core/hooks/use-editor.ts | 5 +++++ packages/editor/src/core/types/editor.ts | 1 + web/core/hooks/use-page-fallback.ts | 11 ++++++----- 4 files changed, 14 insertions(+), 8 deletions(-) diff --git a/packages/editor/src/core/helpers/yjs.ts b/packages/editor/src/core/helpers/yjs.ts index b8122031f72..ffd9367107d 100644 --- a/packages/editor/src/core/helpers/yjs.ts +++ b/packages/editor/src/core/helpers/yjs.ts @@ -6,12 +6,11 @@ import * as Y from "yjs"; * @param {Uint8Array} updates * @returns {string} base64(binary) form of the updated doc */ -export const applyUpdates = (document: Uint8Array, updates: Uint8Array): string => { +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); - const base64Updates = Buffer.from(encodedDoc).toString("base64"); - return base64Updates; + return encodedDoc; }; diff --git a/packages/editor/src/core/hooks/use-editor.ts b/packages/editor/src/core/hooks/use-editor.ts index 53ee3736c5a..f737ce70336 100644 --- a/packages/editor/src/core/hooks/use-editor.ts +++ b/packages/editor/src/core/hooks/use-editor.ts @@ -254,6 +254,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/types/editor.ts b/packages/editor/src/core/types/editor.ts index 2041e33d334..40afa3a9075 100644 --- a/packages/editor/src/core/types/editor.ts +++ b/packages/editor/src/core/types/editor.ts @@ -41,6 +41,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/web/core/hooks/use-page-fallback.ts b/web/core/hooks/use-page-fallback.ts index 8137501450f..c2ce390b754 100644 --- a/web/core/hooks/use-page-fallback.ts +++ b/web/core/hooks/use-page-fallback.ts @@ -1,7 +1,7 @@ import { useCallback } from "react"; import useSWR from "swr"; // plane editor -import { applyUpdates, EditorRefApi } from "@plane/editor"; +import { EditorRefApi } from "@plane/editor"; // plane types import { TDocumentPayload } from "@plane/types"; // helpers @@ -28,18 +28,19 @@ export const usePageFallback = (args: TArgs) => { if (!error) return; const editor = editorRef.current; if (!editor) return; - const { binary, html, json } = editor.getDocument(); - if (!binary || !json) return; const latestEncodedDescription = await fetchPageDescription(); const latestDecodedDescription = latestEncodedDescription ? new Uint8Array(latestEncodedDescription) : new Uint8Array(); - const mergedEncodedDescription = applyUpdates(latestDecodedDescription, binary); + editor.setProviderDocument(latestDecodedDescription); + const { binary, html, json } = editor.getDocument(); + if (!binary || !json) return; + const encodedBinary = Buffer.from(binary).toString("base64"); await updatePageDescription({ - description_binary: mergedEncodedDescription, + description_binary: encodedBinary, description_html: html, description: json, }); From 3331499b106af2308d144d91ab017864b8a9a61e Mon Sep 17 00:00:00 2001 From: Aaryan Khandelwal Date: Tue, 17 Sep 2024 17:21:17 +0530 Subject: [PATCH 3/6] chore: make the health check call only on connection fail --- .../components/pages/editor/editor-body.tsx | 18 ++++++++++++++++ .../components/pages/editor/page-root.tsx | 3 +++ web/core/hooks/use-page-fallback.ts | 21 ++++++++++++------- 3 files changed, 34 insertions(+), 8 deletions(-) diff --git a/web/core/components/pages/editor/editor-body.tsx b/web/core/components/pages/editor/editor-body.tsx index f9d3fe9345a..b3cbf73e9b9 100644 --- a/web/core/components/pages/editor/editor-body.tsx +++ b/web/core/components/pages/editor/editor-body.tsx @@ -38,6 +38,7 @@ const fileService = new FileService(); type Props = { editorRef: React.RefObject; + handleConnectionStatus: (status: boolean) => void; handleEditorReady: (value: boolean) => void; handleReadOnlyEditorReady: (value: boolean) => void; markings: IMarking[]; @@ -50,6 +51,7 @@ type Props = { export const PageEditorBody: React.FC = observer((props) => { const { editorRef, + handleConnectionStatus, handleEditorReady, handleReadOnlyEditorReady, markings, @@ -99,6 +101,21 @@ export const PageEditorBody: React.FC = observer((props) => { [editorRef] ); + const handleServerConnect = useCallback(() => { + handleConnectionStatus(false); + }, []); + const handleServerError = useCallback(() => { + handleConnectionStatus(true); + }, []); + + const serverHandler: TServerHandler = useMemo( + () => ({ + onConnect: handleServerConnect, + onServerError: handleServerError, + }), + [] + ); + useEffect(() => { updateMarkings(pageDescription ?? "

"); }, [pageDescription, updateMarkings]); @@ -170,6 +187,7 @@ export const PageEditorBody: React.FC = observer((props) => { issue: issueEmbedProps, }} realtimeConfig={realtimeConfig} + serverHandler={serverHandler} user={{ id: currentUser?.id ?? "", name: currentUser?.display_name ?? "", diff --git a/web/core/components/pages/editor/page-root.tsx b/web/core/components/pages/editor/page-root.tsx index bc7b0b3d9a0..94ed9c07e43 100644 --- a/web/core/components/pages/editor/page-root.tsx +++ b/web/core/components/pages/editor/page-root.tsx @@ -31,6 +31,7 @@ export const PageRoot = observer((props: TPageRootProps) => { const { projectId, workspaceSlug, page } = props; // states const [editorReady, setEditorReady] = useState(false); + const [hasConnectionFailed, setHasConnectionFailed] = useState(false); const [readOnlyEditorReady, setReadOnlyEditorReady] = useState(false); const [sidePeekVisible, setSidePeekVisible] = useState(window.innerWidth >= 768); const [isVersionsOverlayOpen, setIsVersionsOverlayOpen] = useState(false); @@ -52,6 +53,7 @@ export const PageRoot = observer((props: TPageRootProps) => { if (!page.id) return; return await projectPageService.fetchDescriptionBinary(workspaceSlug, projectId, page.id); }, + hasConnectionFailed, updatePageDescription: async (data) => await updateDescription(data), }); // editor markings hook @@ -145,6 +147,7 @@ export const PageRoot = observer((props: TPageRootProps) => { /> setHasConnectionFailed(status)} handleEditorReady={(val) => setEditorReady(val)} handleReadOnlyEditorReady={() => setReadOnlyEditorReady(true)} markings={markings} diff --git a/web/core/hooks/use-page-fallback.ts b/web/core/hooks/use-page-fallback.ts index c2ce390b754..7b7b9fb8eec 100644 --- a/web/core/hooks/use-page-fallback.ts +++ b/web/core/hooks/use-page-fallback.ts @@ -12,20 +12,25 @@ 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, updatePageDescription } = args; - - const { error } = useSWR("LIVE_SERVER_HEALTH_CHECK", async () => await fetch(`${LIVE_URL}/collaboration/health`), { - errorRetryCount: 5, - revalidateOnFocus: true, - revalidateOnReconnect: true, - }); + const { editorRef, fetchPageDescription, hasConnectionFailed, updatePageDescription } = args; + + const { error } = useSWR( + hasConnectionFailed ? "LIVE_SERVER_HEALTH_CHECK" : null, + hasConnectionFailed ? async () => await fetch(`${LIVE_URL}/collaboration/health`) : null, + { + shouldRetryOnError: false, + revalidateOnFocus: true, + revalidateOnReconnect: true, + } + ); const handleUpdateDescription = useCallback(async () => { - if (!error) return; + if (!error || !hasConnectionFailed) return; const editor = editorRef.current; if (!editor) return; From ce351843f7f6647cd34688fdf990e5b7b059cd87 Mon Sep 17 00:00:00 2001 From: Aaryan Khandelwal Date: Tue, 17 Sep 2024 17:40:53 +0530 Subject: [PATCH 4/6] chore: update debounce interval --- live/src/core/hocuspocus-server.ts | 1 + 1 file changed, 1 insertion(+) 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 }); }; From bc0413486fa5cbf9a38738558d79517765770160 Mon Sep 17 00:00:00 2001 From: Aaryan Khandelwal Date: Fri, 20 Sep 2024 16:07:21 +0530 Subject: [PATCH 5/6] refactor: remove useSwr call for healtch check --- web/core/hooks/use-page-fallback.ts | 17 ++--------------- 1 file changed, 2 insertions(+), 15 deletions(-) diff --git a/web/core/hooks/use-page-fallback.ts b/web/core/hooks/use-page-fallback.ts index 7b7b9fb8eec..34159685181 100644 --- a/web/core/hooks/use-page-fallback.ts +++ b/web/core/hooks/use-page-fallback.ts @@ -1,11 +1,8 @@ import { useCallback } from "react"; -import useSWR from "swr"; // plane editor import { EditorRefApi } from "@plane/editor"; // plane types import { TDocumentPayload } from "@plane/types"; -// helpers -import { LIVE_URL } from "@/helpers/common.helper"; // hooks import useAutoSave from "@/hooks/use-auto-save"; @@ -19,18 +16,8 @@ type TArgs = { export const usePageFallback = (args: TArgs) => { const { editorRef, fetchPageDescription, hasConnectionFailed, updatePageDescription } = args; - const { error } = useSWR( - hasConnectionFailed ? "LIVE_SERVER_HEALTH_CHECK" : null, - hasConnectionFailed ? async () => await fetch(`${LIVE_URL}/collaboration/health`) : null, - { - shouldRetryOnError: false, - revalidateOnFocus: true, - revalidateOnReconnect: true, - } - ); - const handleUpdateDescription = useCallback(async () => { - if (!error || !hasConnectionFailed) return; + if (!hasConnectionFailed) return; const editor = editorRef.current; if (!editor) return; @@ -49,7 +36,7 @@ export const usePageFallback = (args: TArgs) => { description_html: html, description: json, }); - }, [error]); + }, [hasConnectionFailed]); useAutoSave(handleUpdateDescription); }; From 5a5fa5b33a49e608c35b7121e1ea5dd33a071114 Mon Sep 17 00:00:00 2001 From: Aaryan Khandelwal Date: Fri, 20 Sep 2024 18:51:01 +0530 Subject: [PATCH 6/6] fix: pages fallback init --- web/core/hooks/use-page-fallback.ts | 25 +++++++++---------------- 1 file changed, 9 insertions(+), 16 deletions(-) diff --git a/web/core/hooks/use-page-fallback.ts b/web/core/hooks/use-page-fallback.ts index 7b7b9fb8eec..9f5ef348293 100644 --- a/web/core/hooks/use-page-fallback.ts +++ b/web/core/hooks/use-page-fallback.ts @@ -1,11 +1,8 @@ -import { useCallback } from "react"; -import useSWR from "swr"; +import { useCallback, useEffect } from "react"; // plane editor import { EditorRefApi } from "@plane/editor"; // plane types import { TDocumentPayload } from "@plane/types"; -// helpers -import { LIVE_URL } from "@/helpers/common.helper"; // hooks import useAutoSave from "@/hooks/use-auto-save"; @@ -19,18 +16,8 @@ type TArgs = { export const usePageFallback = (args: TArgs) => { const { editorRef, fetchPageDescription, hasConnectionFailed, updatePageDescription } = args; - const { error } = useSWR( - hasConnectionFailed ? "LIVE_SERVER_HEALTH_CHECK" : null, - hasConnectionFailed ? async () => await fetch(`${LIVE_URL}/collaboration/health`) : null, - { - shouldRetryOnError: false, - revalidateOnFocus: true, - revalidateOnReconnect: true, - } - ); - const handleUpdateDescription = useCallback(async () => { - if (!error || !hasConnectionFailed) return; + if (!hasConnectionFailed) return; const editor = editorRef.current; if (!editor) return; @@ -49,7 +36,13 @@ export const usePageFallback = (args: TArgs) => { description_html: html, description: json, }); - }, [error]); + }, [hasConnectionFailed]); + + useEffect(() => { + if (hasConnectionFailed) { + handleUpdateDescription(); + } + }, [hasConnectionFailed]); useAutoSave(handleUpdateDescription); };