diff --git a/apiserver/plane/app/views/page/base.py b/apiserver/plane/app/views/page/base.py index 9db2a4cf0..60fb81eeb 100644 --- a/apiserver/plane/app/views/page/base.py +++ b/apiserver/plane/app/views/page/base.py @@ -431,8 +431,12 @@ class PagesDescriptionViewSet(BaseViewSet): ] def retrieve(self, request, slug, project_id, pk): - page = Page.objects.get( - pk=pk, workspace__slug=slug, projects__id=project_id + page = ( + Page.objects.filter( + pk=pk, workspace__slug=slug, projects__id=project_id + ) + .filter(Q(owned_by=self.request.user) | Q(access=0)) + .first() ) binary_data = page.description_binary @@ -451,10 +455,26 @@ class PagesDescriptionViewSet(BaseViewSet): return response def partial_update(self, request, slug, project_id, pk): - page = Page.objects.get( - pk=pk, workspace__slug=slug, projects__id=project_id + page = ( + Page.objects.filter( + pk=pk, workspace__slug=slug, projects__id=project_id + ) + .filter(Q(owned_by=self.request.user) | Q(access=0)) + .first() ) + if page.is_locked: + return Response( + {"error": "Page is locked"}, + status=471, + ) + + if page.archived_at: + return Response( + {"error": "Page is archived"}, + status=472, + ) + base64_data = request.data.get("description_binary") if base64_data: diff --git a/packages/editor/src/ce/providers/collaboration-provider.ts b/packages/editor/src/ce/providers/collaboration-provider.ts index 9a9e6f78d..edfb031da 100644 --- a/packages/editor/src/ce/providers/collaboration-provider.ts +++ b/packages/editor/src/ce/providers/collaboration-provider.ts @@ -1,3 +1,4 @@ +import { IndexeddbPersistence } from "y-indexeddb"; import * as Y from "yjs"; export interface CompleteCollaboratorProviderConfiguration { @@ -12,7 +13,7 @@ export interface CompleteCollaboratorProviderConfiguration { /** * onChange callback */ - onChange: (updates: Uint8Array) => void; + onChange: (updates: Uint8Array, source?: string) => void; /** * Whether connection to the database has been established and all available content has been loaded or not. */ @@ -25,20 +26,28 @@ export type CollaborationProviderConfiguration = Required {}, hasIndexedDBSynced: false, }; + unsyncedChanges = 0; + + private initialSync = false; + constructor(configuration: CollaborationProviderConfiguration) { this.setConfiguration(configuration); - this.configuration.document = configuration.document ?? new Y.Doc(); + this.indexeddbProvider = new IndexeddbPersistence(`page-${this.configuration.name}`, this.document); + this.indexeddbProvider.on("synced", () => { + this.configuration.hasIndexedDBSynced = true; + }); this.document.on("update", this.documentUpdateHandler.bind(this)); this.document.on("destroy", this.documentDestroyHandler.bind(this)); } + private indexeddbProvider: IndexeddbPersistence; + public setConfiguration(configuration: Partial = {}): void { this.configuration = { ...this.configuration, @@ -50,17 +59,49 @@ export class CollaborationProvider { return this.configuration.document; } - setHasIndexedDBSynced(hasIndexedDBSynced: boolean) { - this.configuration.hasIndexedDBSynced = hasIndexedDBSynced; + public hasUnsyncedChanges(): boolean { + return this.unsyncedChanges > 0; } - documentUpdateHandler(update: Uint8Array, origin: any) { - if (!this.configuration.hasIndexedDBSynced) return; + private resetUnsyncedChanges() { + this.unsyncedChanges = 0; + } + + private incrementUnsyncedChanges() { + this.unsyncedChanges += 1; + } + + public setSynced() { + this.resetUnsyncedChanges(); + } + + public async hasIndexedDBSynced() { + await this.indexeddbProvider.whenSynced; + return this.configuration.hasIndexedDBSynced; + } + + async documentUpdateHandler(_update: Uint8Array, origin: any) { + await this.indexeddbProvider.whenSynced; + // return if the update is from the provider itself if (origin === this) return; // call onChange with the update - this.configuration.onChange?.(update); + const stateVector = Y.encodeStateAsUpdate(this.document); + + if (!this.initialSync) { + this.configuration.onChange?.(stateVector, "initialSync"); + this.initialSync = true; + return; + } + + this.configuration.onChange?.(stateVector); + this.incrementUnsyncedChanges(); + } + + getUpdateFromIndexedDB(): Uint8Array { + const update = Y.encodeStateAsUpdate(this.document); + return update; } documentDestroyHandler() { diff --git a/packages/editor/src/core/components/editors/document/editor.tsx b/packages/editor/src/core/components/editors/document/editor.tsx index 0684fb88c..d39f1b99f 100644 --- a/packages/editor/src/core/components/editors/document/editor.tsx +++ b/packages/editor/src/core/components/editors/document/editor.tsx @@ -53,7 +53,7 @@ const DocumentEditor = (props: IDocumentEditor) => { }; // use document editor - const editor = useDocumentEditor({ + const { editor, isIndexedDbSynced } = useDocumentEditor({ id, editorClassName, embedHandler, @@ -74,7 +74,7 @@ const DocumentEditor = (props: IDocumentEditor) => { containerClassName, }); - if (!editor) return null; + if (!editor || !isIndexedDbSynced) return null; return ( { [id] ); - // update document on value change - useEffect(() => { - if (value.byteLength > 0) Y.applyUpdate(provider.document, value); - }, [value, provider.document]); + const [isIndexedDbSynced, setIndexedDbIsSynced] = useState(false); - // indexedDB provider + // update document on value change from server useLayoutEffect(() => { - const localProvider = new IndexeddbPersistence(id, provider.document); - localProvider.on("synced", () => { - provider.setHasIndexedDBSynced(true); - }); + if (value.length > 0) { + Y.applyUpdate(provider.document, value); + } + }, [value, provider.document, id]); + + // watch for indexedDb to complete syncing, only after which the editor is + // rendered + useLayoutEffect(() => { + async function checkIndexDbSynced() { + const hasSynced = await provider.hasIndexedDBSynced(); + setIndexedDbIsSynced(hasSynced); + } + checkIndexDbSynced(); return () => { - localProvider?.destroy(); + setIndexedDbIsSynced(false); }; - }, [provider, id]); + }, [provider]); const editor = useEditor({ id, @@ -101,8 +106,9 @@ export const useDocumentEditor = (props: DocumentEditorProps) => { }), ], placeholder, + provider, tabIndex, }); - return editor; + return { editor, isIndexedDbSynced }; }; diff --git a/packages/editor/src/core/hooks/use-editor.ts b/packages/editor/src/core/hooks/use-editor.ts index 7083354ac..606695e56 100644 --- a/packages/editor/src/core/hooks/use-editor.ts +++ b/packages/editor/src/core/hooks/use-editor.ts @@ -13,6 +13,7 @@ import { IMarking, scrollSummary } from "@/helpers/scroll-to-node"; import { CoreEditorProps } from "@/props"; // types import { DeleteImage, EditorRefApi, IMentionHighlight, IMentionSuggestion, RestoreImage, UploadImage } from "@/types"; +import { CollaborationProvider } from "@/plane-editor/providers"; export type TFileHandler = { cancel: () => void; @@ -29,6 +30,7 @@ export interface CustomEditorProps { // undefined when prop is not passed, null if intentionally passed to stop // swr syncing value?: string | null | undefined; + provider?: CollaborationProvider; onChange?: (json: object, html: string) => void; extensions?: any; editorProps?: EditorProps; @@ -54,6 +56,7 @@ export const useEditor = ({ forwardedRef, tabIndex, handleEditorReady, + provider, mentionHandler, placeholder, }: CustomEditorProps) => { @@ -187,6 +190,18 @@ export const useEditor = ({ if (!editorRef.current) return; scrollSummary(editorRef.current, marking); }, + setSynced: () => { + if (provider) { + provider.setSynced(); + } + }, + hasUnsyncedChanges: () => { + if (provider) { + return provider.hasUnsyncedChanges(); + } else { + return false; + } + }, isEditorReadyToDiscard: () => editorRef.current?.storage.image.uploadInProgress === false, setFocusAtPosition: (position: number) => { if (!editorRef.current || editorRef.current.isDestroyed) { diff --git a/packages/editor/src/core/types/editor.ts b/packages/editor/src/core/types/editor.ts index a7d167a3a..1aa1dd023 100644 --- a/packages/editor/src/core/types/editor.ts +++ b/packages/editor/src/core/types/editor.ts @@ -19,6 +19,8 @@ export interface EditorRefApi extends EditorReadOnlyRefApi { onStateChange: (callback: () => void) => () => void; setFocusAtPosition: (position: number) => void; isEditorReadyToDiscard: () => boolean; + setSynced: () => void; + hasUnsyncedChanges: () => boolean; } export interface IEditorProps { diff --git a/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/pages/(detail)/[pageId]/page.tsx b/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/pages/(detail)/[pageId]/page.tsx index 753b2ae58..41b6b63c6 100644 --- a/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/pages/(detail)/[pageId]/page.tsx +++ b/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/pages/(detail)/[pageId]/page.tsx @@ -1,44 +1,29 @@ "use client"; -import { useRef, useState } from "react"; import { observer } from "mobx-react"; import Link from "next/link"; import { useParams } from "next/navigation"; import useSWR from "swr"; -// document-editor -import { EditorRefApi, useEditorMarkings } from "@plane/editor"; -// types -import { TPage } from "@plane/types"; // ui -import { TOAST_TYPE, getButtonStyling, setToast } from "@plane/ui"; +import { getButtonStyling } from "@plane/ui"; // components import { LogoSpinner } from "@/components/common"; import { PageHead } from "@/components/core"; import { IssuePeekOverview } from "@/components/issues"; -import { PageEditorBody, PageEditorHeaderRoot } from "@/components/pages"; +import { PageRoot } from "@/components/pages/editor/page-root"; // helpers import { cn } from "@/helpers/common.helper"; // hooks import { usePage, useProjectPages } from "@/hooks/store"; -import { useAppRouter } from "@/hooks/use-app-router"; const PageDetailsPage = observer(() => { - // states - const [sidePeekVisible, setSidePeekVisible] = useState(window.innerWidth >= 768 ? true : false); - const [editorReady, setEditorReady] = useState(false); - const [readOnlyEditorReady, setReadOnlyEditorReady] = useState(false); - // refs - const editorRef = useRef(null); - const readOnlyEditorRef = useRef(null); - // router - const router = useAppRouter(); const { workspaceSlug, projectId, pageId } = useParams(); + // store hooks - const { createPage, getPageById } = useProjectPages(); + const { getPageById } = useProjectPages(); const page = usePage(pageId?.toString() ?? ""); - const { access, description_html, id, name } = page; - // editor markings hook - const { markings, updateMarkings } = useEditorMarkings(); + const { id, name } = page; + // fetch page details const { error: pageDetailsError } = useSWR( pageId ? `PAGE_DETAILS_${pageId}` : null, @@ -73,52 +58,12 @@ const PageDetailsPage = observer(() => { ); - const handleCreatePage = async (payload: Partial) => await createPage(payload); - - const handleDuplicatePage = async () => { - const formData: Partial = { - name: "Copy of " + name, - description_html: description_html ?? "

", - access, - }; - - await handleCreatePage(formData) - .then((res) => router.push(`/${workspaceSlug}/projects/${projectId}/pages/${res?.id}`)) - .catch(() => - setToast({ - type: TOAST_TYPE.ERROR, - title: "Error!", - message: "Page could not be duplicated. Please try again later.", - }) - ); - }; - return ( <>
- setSidePeekVisible(state)} - /> - setEditorReady(val)} - readOnlyEditorRef={readOnlyEditorRef} - handleReadOnlyEditorReady={() => setReadOnlyEditorReady(true)} - markings={markings} - page={page} - sidePeekVisible={sidePeekVisible} - updateMarkings={updateMarkings} - /> +
diff --git a/web/core/components/pages/editor/editor-body.tsx b/web/core/components/pages/editor/editor-body.tsx index b0032c2a1..2c4560004 100644 --- a/web/core/components/pages/editor/editor-body.tsx +++ b/web/core/components/pages/editor/editor-body.tsx @@ -17,7 +17,6 @@ import { PageContentBrowser, PageContentLoader, PageEditorTitle } from "@/compon import { cn } from "@/helpers/common.helper"; // hooks import { useMember, useMention, useUser, useWorkspace } from "@/hooks/store"; -import { usePageDescription } from "@/hooks/use-page-description"; import { usePageFilters } from "@/hooks/use-page-filters"; // plane web components import { IssueEmbedCard } from "@/plane-web/components/pages"; @@ -37,6 +36,9 @@ type Props = { handleEditorReady: (value: boolean) => void; handleReadOnlyEditorReady: (value: boolean) => void; updateMarkings: (description_html: string) => void; + handleDescriptionChange: (update: Uint8Array, source?: string | undefined) => void; + isDescriptionReady: boolean; + pageDescriptionYJS: Uint8Array | undefined; }; export const PageEditorBody: React.FC = observer((props) => { @@ -49,6 +51,9 @@ export const PageEditorBody: React.FC = observer((props) => { page, sidePeekVisible, updateMarkings, + handleDescriptionChange, + isDescriptionReady, + pageDescriptionYJS, } = props; // router const { workspaceSlug, projectId } = useParams(); @@ -67,13 +72,7 @@ export const PageEditorBody: React.FC = observer((props) => { const { isContentEditable, updateTitle, setIsSubmitting } = page; const projectMemberIds = projectId ? getProjectMemberIds(projectId.toString()) : []; const projectMemberDetails = projectMemberIds?.map((id) => getUserDetails(id) as IUserLite); - // project-description - const { handleDescriptionChange, isDescriptionReady, pageDescriptionYJS } = usePageDescription({ - editorRef, - page, - projectId, - workspaceSlug, - }); + // use-mention const { mentionHighlights, mentionSuggestions } = useMention({ workspaceSlug: workspaceSlug?.toString() ?? "", diff --git a/web/core/components/pages/editor/header/extra-options.tsx b/web/core/components/pages/editor/header/extra-options.tsx index 06c0579e4..5807b53b7 100644 --- a/web/core/components/pages/editor/header/extra-options.tsx +++ b/web/core/components/pages/editor/header/extra-options.tsx @@ -23,10 +23,11 @@ type Props = { handleDuplicatePage: () => void; page: IPage; readOnlyEditorRef: React.RefObject; + handleSaveDescription: (forceSync?: boolean, initSyncVectorAsUpdate?: Uint8Array | undefined) => Promise; }; export const PageExtraOptions: React.FC = observer((props) => { - const { editorRef, handleDuplicatePage, page, readOnlyEditorRef } = props; + const { editorRef, handleDuplicatePage, page, readOnlyEditorRef, handleSaveDescription } = props; // states const [gptModalOpen, setGptModal] = useState(false); // store hooks @@ -76,6 +77,7 @@ export const PageExtraOptions: React.FC = observer((props) => { editorRef={isContentEditable ? editorRef.current : readOnlyEditorRef.current} handleDuplicatePage={handleDuplicatePage} page={page} + handleSaveDescription={handleSaveDescription} /> ); diff --git a/web/core/components/pages/editor/header/mobile-root.tsx b/web/core/components/pages/editor/header/mobile-root.tsx index 166a4b7dc..570708c63 100644 --- a/web/core/components/pages/editor/header/mobile-root.tsx +++ b/web/core/components/pages/editor/header/mobile-root.tsx @@ -17,6 +17,7 @@ type Props = { setSidePeekVisible: (sidePeekState: boolean) => void; editorReady: boolean; readOnlyEditorReady: boolean; + handleSaveDescription: (forceSync?: boolean, initSyncVectorAsUpdate?: Uint8Array | undefined) => Promise; }; export const PageEditorMobileHeaderRoot: React.FC = observer((props) => { @@ -30,6 +31,7 @@ export const PageEditorMobileHeaderRoot: React.FC = observer((props) => { page, sidePeekVisible, setSidePeekVisible, + handleSaveDescription, } = props; // derived values const { isContentEditable } = page; @@ -52,6 +54,7 @@ export const PageEditorMobileHeaderRoot: React.FC = observer((props) => { void; page: IPage; + handleSaveDescription: (forceSync?: boolean, initSyncVectorAsUpdate?: Uint8Array | undefined) => Promise; }; export const PageOptionsDropdown: React.FC = observer((props) => { - const { editorRef, handleDuplicatePage, page } = props; + const { editorRef, handleDuplicatePage, page, handleSaveDescription } = props; // store values const { archived_at, @@ -75,6 +76,11 @@ export const PageOptionsDropdown: React.FC = observer((props) => { }) ); + const saveDescriptionYJSAndPerformAction = (action: () => void) => async () => { + await handleSaveDescription(); + action(); + }; + // menu items list const MENU_ITEMS: { key: string; @@ -116,21 +122,21 @@ export const PageOptionsDropdown: React.FC = observer((props) => { }, { key: "make-a-copy", - action: handleDuplicatePage, + action: saveDescriptionYJSAndPerformAction(handleDuplicatePage), label: "Make a copy", icon: Copy, shouldRender: canCurrentUserDuplicatePage, }, { key: "lock-unlock-page", - action: is_locked ? handleUnlockPage : handleLockPage, + action: is_locked ? handleUnlockPage : saveDescriptionYJSAndPerformAction(handleLockPage), label: is_locked ? "Unlock page" : "Lock page", icon: is_locked ? LockOpen : Lock, shouldRender: canCurrentUserLockPage, }, { key: "archive-restore-page", - action: archived_at ? handleRestorePage : handleArchivePage, + action: archived_at ? handleRestorePage : saveDescriptionYJSAndPerformAction(handleArchivePage), label: archived_at ? "Restore page" : "Archive page", icon: archived_at ? ArchiveRestoreIcon : ArchiveIcon, shouldRender: canCurrentUserArchivePage, diff --git a/web/core/components/pages/editor/header/root.tsx b/web/core/components/pages/editor/header/root.tsx index 2b58e7c76..26e33da79 100644 --- a/web/core/components/pages/editor/header/root.tsx +++ b/web/core/components/pages/editor/header/root.tsx @@ -19,6 +19,7 @@ type Props = { setSidePeekVisible: (sidePeekState: boolean) => void; editorReady: boolean; readOnlyEditorReady: boolean; + handleSaveDescription: (forceSync?: boolean, initSyncVectorAsUpdate?: Uint8Array | undefined) => Promise; }; export const PageEditorHeaderRoot: React.FC = observer((props) => { @@ -32,6 +33,7 @@ export const PageEditorHeaderRoot: React.FC = observer((props) => { page, sidePeekVisible, setSidePeekVisible, + handleSaveDescription, } = props; // derived values const { isContentEditable } = page; @@ -63,12 +65,14 @@ export const PageEditorHeaderRoot: React.FC = observer((props) => {
{ + // router + const router = useAppRouter(); + const { projectId, workspaceSlug, page } = props; + const { createPage } = useProjectPages(); + const { access, description_html, name } = page; + + // states + const [editorReady, setEditorReady] = useState(false); + const [readOnlyEditorReady, setReadOnlyEditorReady] = useState(false); + + // refs + const editorRef = useRef(null); + const readOnlyEditorRef = useRef(null); + + // editor markings hook + const { markings, updateMarkings } = useEditorMarkings(); + + const [sidePeekVisible, setSidePeekVisible] = useState(window.innerWidth >= 768 ? true : false); + + // project-description + const { handleDescriptionChange, isDescriptionReady, pageDescriptionYJS, handleSaveDescription } = usePageDescription( + { + editorRef, + page, + projectId, + workspaceSlug, + } + ); + + const handleCreatePage = async (payload: Partial) => await createPage(payload); + + const handleDuplicatePage = async () => { + const formData: Partial = { + name: "Copy of " + name, + description_html: editorRef.current?.getHTML() ?? description_html ?? "

", + access, + }; + + await handleCreatePage(formData) + .then((res) => router.push(`/${workspaceSlug}/projects/${projectId}/pages/${res?.id}`)) + .catch(() => + setToast({ + type: TOAST_TYPE.ERROR, + title: "Error!", + message: "Page could not be duplicated. Please try again later.", + }) + ); + }; + + return ( + <> + setSidePeekVisible(state)} + /> + setEditorReady(val)} + readOnlyEditorRef={readOnlyEditorRef} + handleReadOnlyEditorReady={() => setReadOnlyEditorReady(true)} + markings={markings} + page={page} + sidePeekVisible={sidePeekVisible} + updateMarkings={updateMarkings} + handleDescriptionChange={handleDescriptionChange} + isDescriptionReady={isDescriptionReady} + pageDescriptionYJS={pageDescriptionYJS} + /> + + ); +}); diff --git a/web/core/hooks/use-auto-save.tsx b/web/core/hooks/use-auto-save.tsx new file mode 100644 index 000000000..120c4d53b --- /dev/null +++ b/web/core/hooks/use-auto-save.tsx @@ -0,0 +1,73 @@ +import { useEffect, useRef } from "react"; +import { debounce } from "lodash"; + +const AUTO_SAVE_TIME = 10000; + +const useAutoSave = (handleSaveDescription: (forceSync?: boolean, yjsAsUpdate?: Uint8Array) => void) => { + const intervalIdRef = useRef(null); + const handleSaveDescriptionRef = useRef(handleSaveDescription); + + // Update the ref to always point to the latest handleSaveDescription + useEffect(() => { + handleSaveDescriptionRef.current = handleSaveDescription; + }, [handleSaveDescription]); + + // Set up the interval to run every 10 seconds + useEffect(() => { + intervalIdRef.current = setInterval(() => { + try { + handleSaveDescriptionRef.current(true); + } catch (error) { + console.error("Autosave before manual save failed:", error); + } + }, AUTO_SAVE_TIME); + + return () => { + if (intervalIdRef.current) { + clearInterval(intervalIdRef.current); + } + }; + }, []); + + // Debounced save function for manual save (Ctrl+S or Cmd+S) and clearing the + // interval for auto save and setting up the interval again + useEffect(() => { + const debouncedSave = debounce(() => { + try { + handleSaveDescriptionRef.current(); + } catch (error) { + console.error("Manual save failed:", error); + } + + if (intervalIdRef.current) { + clearInterval(intervalIdRef.current); + intervalIdRef.current = setInterval(() => { + try { + handleSaveDescriptionRef.current(true); + } catch (error) { + console.error("Autosave after manual save failed:", error); + } + }, AUTO_SAVE_TIME); + } + }, 500); + + const handleSave = (e: KeyboardEvent) => { + const { ctrlKey, metaKey, key } = e; + const cmdClicked = ctrlKey || metaKey; + + if (cmdClicked && key.toLowerCase() === "s") { + e.preventDefault(); + e.stopPropagation(); + debouncedSave(); + } + }; + + window.addEventListener("keydown", handleSave); + + return () => { + window.removeEventListener("keydown", handleSave); + }; + }, []); +}; + +export default useAutoSave; diff --git a/web/core/hooks/use-page-description.ts b/web/core/hooks/use-page-description.ts index 3c824b00d..49edb3d41 100644 --- a/web/core/hooks/use-page-description.ts +++ b/web/core/hooks/use-page-description.ts @@ -1,19 +1,17 @@ -import React, { useCallback, useEffect, useMemo, useState } from "react"; +import React, { useCallback, useEffect, useState } from "react"; import useSWR from "swr"; -// editor -import { - EditorRefApi, - applyUpdates, - generateJSONfromHTML, - mergeUpdates, - proseMirrorJSONToBinaryString, -} from "@plane/editor"; + +import { EditorRefApi, generateJSONfromHTML, proseMirrorJSONToBinaryString, applyUpdates } from "@plane/editor"; + // hooks +import { setToast, TOAST_TYPE } from "@plane/ui"; +import useAutoSave from "@/hooks/use-auto-save"; import useReloadConfirmations from "@/hooks/use-reload-confirmation"; + // services import { ProjectPageService } from "@/services/page"; -// store import { IPage } from "@/store/pages/page"; + const projectPageService = new ProjectPageService(); type Props = { @@ -23,22 +21,28 @@ type Props = { workspaceSlug: string | string[] | undefined; }; -const AUTO_SAVE_TIME = 10000; - export const usePageDescription = (props: Props) => { const { editorRef, page, projectId, workspaceSlug } = props; - // states const [isDescriptionReady, setIsDescriptionReady] = useState(false); - const [descriptionUpdates, setDescriptionUpdates] = useState([]); - // derived values + const [localDescriptionYJS, setLocalDescriptionYJS] = useState(); const { isContentEditable, isSubmitting, updateDescription, setIsSubmitting } = page; + const [hasShownOfflineToast, setHasShownOfflineToast] = useState(false); + const pageDescription = page.description_html; const pageId = page.id; - const { data: descriptionYJS, mutate: mutateDescriptionYJS } = useSWR( + const { data: pageDescriptionYJS, mutate: mutateDescriptionYJS } = useSWR( workspaceSlug && projectId && pageId ? `PAGE_DESCRIPTION_${workspaceSlug}_${projectId}_${pageId}` : null, workspaceSlug && projectId && pageId - ? () => projectPageService.fetchDescriptionYJS(workspaceSlug.toString(), projectId.toString(), pageId.toString()) + ? async () => { + const encodedDescription = await projectPageService.fetchDescriptionYJS( + workspaceSlug.toString(), + projectId.toString(), + pageId.toString() + ); + const decodedDescription = new Uint8Array(encodedDescription); + return decodedDescription; + } : null, { revalidateOnFocus: false, @@ -46,15 +50,19 @@ export const usePageDescription = (props: Props) => { revalidateIfStale: false, } ); - // description in Uint8Array format - const pageDescriptionYJS = useMemo( - () => (descriptionYJS ? new Uint8Array(descriptionYJS) : undefined), - [descriptionYJS] - ); - // push the new updates to the updates array - const handleDescriptionChange = useCallback((updates: Uint8Array) => { - setDescriptionUpdates((prev) => [...prev, updates]); + // set the merged local doc by the provider to the react local state + const handleDescriptionChange = useCallback((update: Uint8Array, source?: string) => { + setLocalDescriptionYJS(() => { + // handle the initial sync case where indexeddb gives extra update, in + // this case we need to save the update to the DB + if (source && source === "initialSync") { + handleSaveDescription(true, update); + } + + return update; + }); + // eslint-disable-next-line react-hooks/exhaustive-deps }, []); // if description_binary field is empty, convert description_html to yDoc and update the DB @@ -62,98 +70,120 @@ export const usePageDescription = (props: Props) => { useEffect(() => { const changeHTMLToBinary = async () => { if (!pageDescriptionYJS || !pageDescription) return; - if (pageDescriptionYJS.byteLength === 0) { + if (pageDescriptionYJS.length === 0) { const { contentJSON, editorSchema } = generateJSONfromHTML(pageDescription ?? "

"); const yDocBinaryString = proseMirrorJSONToBinaryString(contentJSON, "default", editorSchema); - await updateDescription(yDocBinaryString, pageDescription ?? "

"); + + try { + await updateDescription(yDocBinaryString, pageDescription ?? "

"); + } catch (error) { + console.log("error", error); + } + await mutateDescriptionYJS(); + setIsDescriptionReady(true); } else setIsDescriptionReady(true); }; changeHTMLToBinary(); }, [mutateDescriptionYJS, pageDescription, pageDescriptionYJS, updateDescription]); - const handleSaveDescription = useCallback(async () => { - if (!isContentEditable) return; + const { setShowAlert } = useReloadConfirmations(true); - const applyUpdatesAndSave = async (latestDescription: any, updates: Uint8Array) => { - if (!workspaceSlug || !projectId || !pageId || !latestDescription) return; - // convert description to Uint8Array - const descriptionArray = new Uint8Array(latestDescription); - // apply the updates to the description - const combinedBinaryString = applyUpdates(descriptionArray, updates); - // get the latest html content - const descriptionHTML = editorRef.current?.getHTML() ?? "

"; - // make a request to update the descriptions - await updateDescription(combinedBinaryString, descriptionHTML).finally(() => setIsSubmitting("saved")); - }; - - try { - setIsSubmitting("submitting"); - // fetch the latest description - const latestDescription = await mutateDescriptionYJS(); - // return if there are no updates - if (descriptionUpdates.length <= 0) { - setIsSubmitting("saved"); - return; - } - // merge the updates array into one single update - const mergedUpdates = mergeUpdates(descriptionUpdates); - await applyUpdatesAndSave(latestDescription, mergedUpdates); - // reset the updates array to empty - setDescriptionUpdates([]); - } catch (error) { - setIsSubmitting("saved"); - throw error; + useEffect(() => { + if (editorRef?.current?.hasUnsyncedChanges() || isSubmitting === "submitting") { + setShowAlert(true); + } else { + setShowAlert(false); } - }, [ - descriptionUpdates, - editorRef, - isContentEditable, - mutateDescriptionYJS, - pageId, - projectId, - setIsSubmitting, - updateDescription, - workspaceSlug, - ]); + }, [setShowAlert, isSubmitting, editorRef, localDescriptionYJS]); - // auto-save updates every 10 seconds - // handle ctrl/cmd + S to save the description - useEffect(() => { - const intervalId = setInterval(handleSaveDescription, AUTO_SAVE_TIME); + // merge the description from remote to local state and only save if there are local changes + const handleSaveDescription = useCallback( + async (forceSync?: boolean, initSyncVectorAsUpdate?: Uint8Array) => { + const update = localDescriptionYJS ?? initSyncVectorAsUpdate; - const handleSave = (e: KeyboardEvent) => { - const { ctrlKey, metaKey, key } = e; - const cmdClicked = ctrlKey || metaKey; + if (update == null) return; - if (cmdClicked && key.toLowerCase() === "s") { - e.preventDefault(); - e.stopPropagation(); - handleSaveDescription(); + if (!isContentEditable) return; - // reset interval timer - clearInterval(intervalId); + const applyUpdatesAndSave = async (latestDescription: Uint8Array, update: Uint8Array | undefined) => { + if (!workspaceSlug || !projectId || !pageId || !latestDescription || !update) return; + + if (!forceSync && !editorRef.current?.hasUnsyncedChanges()) { + setIsSubmitting("saved"); + return; + } + + const combinedBinaryString = applyUpdates(latestDescription, update); + const descriptionHTML = editorRef.current?.getHTML() ?? "

"; + await updateDescription(combinedBinaryString, descriptionHTML) + .then(() => { + editorRef.current?.setSynced(); + setHasShownOfflineToast(false); + }) + .catch((e) => { + if (e.message === "Network Error" && !hasShownOfflineToast) { + setToast({ + type: TOAST_TYPE.INFO, + title: "Info!", + message: "You seem to be offline, your changes will remain saved on this device", + }); + setHasShownOfflineToast(true); + } + if (e.response?.status === 471) { + setToast({ + type: TOAST_TYPE.ERROR, + title: "Error!", + message: "Failed to save your changes, the page was locked, your changes will be lost", + }); + } + if (e.response?.status === 472) { + setToast({ + type: TOAST_TYPE.ERROR, + title: "Error!", + message: "Failed to save your changes, the page was archived, your changes will be lost", + }); + } + }) + .finally(() => { + setShowAlert(false); + setIsSubmitting("saved"); + }); + }; + + try { + setIsSubmitting("submitting"); + const latestDescription = await mutateDescriptionYJS(); + if (latestDescription) { + await applyUpdatesAndSave(latestDescription, update); + } + } catch (error) { + setIsSubmitting("saved"); + throw error; } - }; - window.addEventListener("keydown", handleSave); + }, + [ + localDescriptionYJS, + setShowAlert, + editorRef, + hasShownOfflineToast, + isContentEditable, + mutateDescriptionYJS, + pageId, + projectId, + setIsSubmitting, + updateDescription, + workspaceSlug, + ] + ); - return () => { - clearInterval(intervalId); - window.removeEventListener("keydown", handleSave); - }; - }, [handleSaveDescription]); - - // show a confirm dialog if there are any unsaved changes, or saving is going on - const { setShowAlert } = useReloadConfirmations(descriptionUpdates.length > 0 || isSubmitting === "submitting"); - useEffect(() => { - if (descriptionUpdates.length > 0 || isSubmitting === "submitting") setShowAlert(true); - else setShowAlert(false); - }, [descriptionUpdates, isSubmitting, setShowAlert]); + useAutoSave(handleSaveDescription); return { handleDescriptionChange, isDescriptionReady, pageDescriptionYJS, + handleSaveDescription, }; }; diff --git a/web/core/services/page/project-page.service.ts b/web/core/services/page/project-page.service.ts index 67fe4525c..1d82b4eba 100644 --- a/web/core/services/page/project-page.service.ts +++ b/web/core/services/page/project-page.service.ts @@ -145,7 +145,7 @@ export class ProjectPageService extends APIService { return this.patch(`/api/workspaces/${workspaceSlug}/projects/${projectId}/pages/${pageId}/description/`, data) .then((response) => response?.data) .catch((error) => { - throw error?.response?.data; + throw error; }); } }