diff --git a/packages/editor/src/ce/providers/collaboration-provider.ts b/packages/editor/src/ce/providers/collaboration-provider.ts index 9a9e6f78d7..edfb031da1 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 0684fb88cf..d39f1b99fc 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 7083354aca..606695e569 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 a7d167a3a7..1aa1dd0234 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 753b2ae58d..396248a9e6 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 @@ -21,6 +21,7 @@ import { cn } from "@/helpers/common.helper"; // hooks import { usePage, useProjectPages } from "@/hooks/store"; import { useAppRouter } from "@/hooks/use-app-router"; +import { usePageDescription } from "@/hooks/use-page-description"; const PageDetailsPage = observer(() => { // states @@ -50,6 +51,16 @@ const PageDetailsPage = observer(() => { } ); + // project-description + const { handleDescriptionChange, isDescriptionReady, pageDescriptionYJS, handleSaveDescription } = usePageDescription( + { + editorRef, + page, + projectId, + workspaceSlug, + } + ); + if ((!page || !id) && !pageDetailsError) return (
@@ -78,7 +89,7 @@ const PageDetailsPage = observer(() => { const handleDuplicatePage = async () => { const formData: Partial = { name: "Copy of " + name, - description_html: description_html ?? "

", + description_html: editorRef.current?.getHTML() ?? description_html ?? "

", access, }; @@ -104,6 +115,7 @@ const PageDetailsPage = observer(() => { editorReady={editorReady} readOnlyEditorReady={readOnlyEditorReady} handleDuplicatePage={handleDuplicatePage} + handleSaveDescription={handleSaveDescription} markings={markings} page={page} sidePeekVisible={sidePeekVisible} @@ -118,6 +130,9 @@ const PageDetailsPage = observer(() => { page={page} sidePeekVisible={sidePeekVisible} updateMarkings={updateMarkings} + handleDescriptionChange={handleDescriptionChange} + isDescriptionReady={isDescriptionReady} + pageDescriptionYJS={pageDescriptionYJS} />
diff --git a/web/core/components/pages/editor/editor-body.tsx b/web/core/components/pages/editor/editor-body.tsx index b0032c2a1d..2c45600044 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 bd022eed26..184b500cde 100644 --- a/web/core/components/pages/editor/header/extra-options.tsx +++ b/web/core/components/pages/editor/header/extra-options.tsx @@ -22,10 +22,11 @@ type Props = { handleDuplicatePage: () => void; page: IPage; readOnlyEditorRef: React.RefObject; + handleSaveDescription: (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 @@ -80,6 +81,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 166a4b7dcf..4677bb9c73 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: (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: (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 2b58e7c763..45e240399c 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: (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) => {
void) => { + const intervalIdRef = useRef(null); + + useEffect(() => { + intervalIdRef.current = setInterval(handleSaveDescription, AUTO_SAVE_TIME); + + return () => { + if (intervalIdRef.current) { + clearInterval(intervalIdRef.current); + } + }; + }, [handleSaveDescription]); + + useEffect(() => { + // debounce the function so that excesive calls to handleSaveDescription don't cause multiple calls to the server + const debouncedSave = debounce(() => { + handleSaveDescription(); + + if (intervalIdRef.current) { + // clear the interval after saving manually + clearInterval(intervalIdRef.current); + // then reset the interval for auto-save to keep working + intervalIdRef.current = setInterval(handleSaveDescription, 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); + }; + }, [handleSaveDescription]); +}; + +export default useAutoSave; diff --git a/web/core/hooks/use-page-description.ts b/web/core/hooks/use-page-description.ts index 3c824b00de..6dc4c178cb 100644 --- a/web/core/hooks/use-page-description.ts +++ b/web/core/hooks/use-page-description.ts @@ -1,19 +1,15 @@ -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"; + // hooks +import { EditorRefApi, generateJSONfromHTML, proseMirrorJSONToBinaryString, applyUpdates } from "@plane/editor"; +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 +19,27 @@ 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 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 +47,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(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 +67,87 @@ 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 ?? "

"); + 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 (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 (!editorRef.current?.hasUnsyncedChanges()) { + setIsSubmitting("saved"); + return; + } + + const combinedBinaryString = applyUpdates(latestDescription, update); + const descriptionHTML = editorRef.current?.getHTML() ?? "

"; + await updateDescription(combinedBinaryString, descriptionHTML).finally(() => { + editorRef.current?.setSynced(); + 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, + 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, }; };