[WEB-1727] refactor: pages editor sync logic solidified (#4926)

* feat: pages editor sync logic solidified

* chore: added validation for archive and lock in a page

* feat: pages editor sync logic solidified

* fix: updated the auto save hook to run every 10s instead of 10s after the user stops typing!!

* chore: custom status code for pages

* fix: forceSync in case of auto save

* fix: modifying a locked and archived page shows a toast for now!

* fix: build errors and better error messages

* chore: page root moved

---------

Co-authored-by: NarayanBavisetti <narayan3119@gmail.com>
This commit is contained in:
M. Palanikannan 2024-06-25 18:58:57 +05:30 committed by GitHub
parent c919435598
commit 99184371f7
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
16 changed files with 444 additions and 201 deletions

View file

@ -431,8 +431,12 @@ class PagesDescriptionViewSet(BaseViewSet):
] ]
def retrieve(self, request, slug, project_id, pk): def retrieve(self, request, slug, project_id, pk):
page = Page.objects.get( page = (
pk=pk, workspace__slug=slug, projects__id=project_id 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 binary_data = page.description_binary
@ -451,10 +455,26 @@ class PagesDescriptionViewSet(BaseViewSet):
return response return response
def partial_update(self, request, slug, project_id, pk): def partial_update(self, request, slug, project_id, pk):
page = Page.objects.get( page = (
pk=pk, workspace__slug=slug, projects__id=project_id 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") base64_data = request.data.get("description_binary")
if base64_data: if base64_data:

View file

@ -1,3 +1,4 @@
import { IndexeddbPersistence } from "y-indexeddb";
import * as Y from "yjs"; import * as Y from "yjs";
export interface CompleteCollaboratorProviderConfiguration { export interface CompleteCollaboratorProviderConfiguration {
@ -12,7 +13,7 @@ export interface CompleteCollaboratorProviderConfiguration {
/** /**
* onChange callback * 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. * 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<Pick<CompleteCollabora
export class CollaborationProvider { export class CollaborationProvider {
public configuration: CompleteCollaboratorProviderConfiguration = { public configuration: CompleteCollaboratorProviderConfiguration = {
name: "", name: "",
// @ts-expect-error cannot be undefined document: new Y.Doc(),
document: undefined,
onChange: () => {}, onChange: () => {},
hasIndexedDBSynced: false, hasIndexedDBSynced: false,
}; };
unsyncedChanges = 0;
private initialSync = false;
constructor(configuration: CollaborationProviderConfiguration) { constructor(configuration: CollaborationProviderConfiguration) {
this.setConfiguration(configuration); 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("update", this.documentUpdateHandler.bind(this));
this.document.on("destroy", this.documentDestroyHandler.bind(this)); this.document.on("destroy", this.documentDestroyHandler.bind(this));
} }
private indexeddbProvider: IndexeddbPersistence;
public setConfiguration(configuration: Partial<CompleteCollaboratorProviderConfiguration> = {}): void { public setConfiguration(configuration: Partial<CompleteCollaboratorProviderConfiguration> = {}): void {
this.configuration = { this.configuration = {
...this.configuration, ...this.configuration,
@ -50,17 +59,49 @@ export class CollaborationProvider {
return this.configuration.document; return this.configuration.document;
} }
setHasIndexedDBSynced(hasIndexedDBSynced: boolean) { public hasUnsyncedChanges(): boolean {
this.configuration.hasIndexedDBSynced = hasIndexedDBSynced; return this.unsyncedChanges > 0;
} }
documentUpdateHandler(update: Uint8Array, origin: any) { private resetUnsyncedChanges() {
if (!this.configuration.hasIndexedDBSynced) return; 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 // return if the update is from the provider itself
if (origin === this) return; if (origin === this) return;
// call onChange with the update // 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() { documentDestroyHandler() {

View file

@ -53,7 +53,7 @@ const DocumentEditor = (props: IDocumentEditor) => {
}; };
// use document editor // use document editor
const editor = useDocumentEditor({ const { editor, isIndexedDbSynced } = useDocumentEditor({
id, id,
editorClassName, editorClassName,
embedHandler, embedHandler,
@ -74,7 +74,7 @@ const DocumentEditor = (props: IDocumentEditor) => {
containerClassName, containerClassName,
}); });
if (!editor) return null; if (!editor || !isIndexedDbSynced) return null;
return ( return (
<PageRenderer <PageRenderer

View file

@ -1,7 +1,6 @@
import { useEffect, useLayoutEffect, useMemo } from "react"; import { useLayoutEffect, useMemo, useState } from "react";
import Collaboration from "@tiptap/extension-collaboration"; import Collaboration from "@tiptap/extension-collaboration";
import { EditorProps } from "@tiptap/pm/view"; import { EditorProps } from "@tiptap/pm/view";
import { IndexeddbPersistence } from "y-indexeddb";
import * as Y from "yjs"; import * as Y from "yjs";
// extensions // extensions
import { DragAndDrop, IssueWidget } from "@/extensions"; import { DragAndDrop, IssueWidget } from "@/extensions";
@ -62,21 +61,27 @@ export const useDocumentEditor = (props: DocumentEditorProps) => {
[id] [id]
); );
// update document on value change const [isIndexedDbSynced, setIndexedDbIsSynced] = useState(false);
useEffect(() => {
if (value.byteLength > 0) Y.applyUpdate(provider.document, value);
}, [value, provider.document]);
// indexedDB provider // update document on value change from server
useLayoutEffect(() => { useLayoutEffect(() => {
const localProvider = new IndexeddbPersistence(id, provider.document); if (value.length > 0) {
localProvider.on("synced", () => { Y.applyUpdate(provider.document, value);
provider.setHasIndexedDBSynced(true); }
}); }, [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 () => { return () => {
localProvider?.destroy(); setIndexedDbIsSynced(false);
}; };
}, [provider, id]); }, [provider]);
const editor = useEditor({ const editor = useEditor({
id, id,
@ -101,8 +106,9 @@ export const useDocumentEditor = (props: DocumentEditorProps) => {
}), }),
], ],
placeholder, placeholder,
provider,
tabIndex, tabIndex,
}); });
return editor; return { editor, isIndexedDbSynced };
}; };

View file

@ -13,6 +13,7 @@ import { IMarking, scrollSummary } from "@/helpers/scroll-to-node";
import { CoreEditorProps } from "@/props"; import { CoreEditorProps } from "@/props";
// types // types
import { DeleteImage, EditorRefApi, IMentionHighlight, IMentionSuggestion, RestoreImage, UploadImage } from "@/types"; import { DeleteImage, EditorRefApi, IMentionHighlight, IMentionSuggestion, RestoreImage, UploadImage } from "@/types";
import { CollaborationProvider } from "@/plane-editor/providers";
export type TFileHandler = { export type TFileHandler = {
cancel: () => void; cancel: () => void;
@ -29,6 +30,7 @@ export interface CustomEditorProps {
// undefined when prop is not passed, null if intentionally passed to stop // undefined when prop is not passed, null if intentionally passed to stop
// swr syncing // swr syncing
value?: string | null | undefined; value?: string | null | undefined;
provider?: CollaborationProvider;
onChange?: (json: object, html: string) => void; onChange?: (json: object, html: string) => void;
extensions?: any; extensions?: any;
editorProps?: EditorProps; editorProps?: EditorProps;
@ -54,6 +56,7 @@ export const useEditor = ({
forwardedRef, forwardedRef,
tabIndex, tabIndex,
handleEditorReady, handleEditorReady,
provider,
mentionHandler, mentionHandler,
placeholder, placeholder,
}: CustomEditorProps) => { }: CustomEditorProps) => {
@ -187,6 +190,18 @@ export const useEditor = ({
if (!editorRef.current) return; if (!editorRef.current) return;
scrollSummary(editorRef.current, marking); 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, isEditorReadyToDiscard: () => editorRef.current?.storage.image.uploadInProgress === false,
setFocusAtPosition: (position: number) => { setFocusAtPosition: (position: number) => {
if (!editorRef.current || editorRef.current.isDestroyed) { if (!editorRef.current || editorRef.current.isDestroyed) {

View file

@ -19,6 +19,8 @@ export interface EditorRefApi extends EditorReadOnlyRefApi {
onStateChange: (callback: () => void) => () => void; onStateChange: (callback: () => void) => () => void;
setFocusAtPosition: (position: number) => void; setFocusAtPosition: (position: number) => void;
isEditorReadyToDiscard: () => boolean; isEditorReadyToDiscard: () => boolean;
setSynced: () => void;
hasUnsyncedChanges: () => boolean;
} }
export interface IEditorProps { export interface IEditorProps {

View file

@ -1,44 +1,29 @@
"use client"; "use client";
import { useRef, useState } from "react";
import { observer } from "mobx-react"; import { observer } from "mobx-react";
import Link from "next/link"; import Link from "next/link";
import { useParams } from "next/navigation"; import { useParams } from "next/navigation";
import useSWR from "swr"; import useSWR from "swr";
// document-editor
import { EditorRefApi, useEditorMarkings } from "@plane/editor";
// types
import { TPage } from "@plane/types";
// ui // ui
import { TOAST_TYPE, getButtonStyling, setToast } from "@plane/ui"; import { getButtonStyling } from "@plane/ui";
// components // components
import { LogoSpinner } from "@/components/common"; import { LogoSpinner } from "@/components/common";
import { PageHead } from "@/components/core"; import { PageHead } from "@/components/core";
import { IssuePeekOverview } from "@/components/issues"; import { IssuePeekOverview } from "@/components/issues";
import { PageEditorBody, PageEditorHeaderRoot } from "@/components/pages"; import { PageRoot } from "@/components/pages/editor/page-root";
// helpers // helpers
import { cn } from "@/helpers/common.helper"; import { cn } from "@/helpers/common.helper";
// hooks // hooks
import { usePage, useProjectPages } from "@/hooks/store"; import { usePage, useProjectPages } from "@/hooks/store";
import { useAppRouter } from "@/hooks/use-app-router";
const PageDetailsPage = observer(() => { 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<EditorRefApi>(null);
const readOnlyEditorRef = useRef<EditorRefApi>(null);
// router
const router = useAppRouter();
const { workspaceSlug, projectId, pageId } = useParams(); const { workspaceSlug, projectId, pageId } = useParams();
// store hooks // store hooks
const { createPage, getPageById } = useProjectPages(); const { getPageById } = useProjectPages();
const page = usePage(pageId?.toString() ?? ""); const page = usePage(pageId?.toString() ?? "");
const { access, description_html, id, name } = page; const { id, name } = page;
// editor markings hook
const { markings, updateMarkings } = useEditorMarkings();
// fetch page details // fetch page details
const { error: pageDetailsError } = useSWR( const { error: pageDetailsError } = useSWR(
pageId ? `PAGE_DETAILS_${pageId}` : null, pageId ? `PAGE_DETAILS_${pageId}` : null,
@ -73,52 +58,12 @@ const PageDetailsPage = observer(() => {
</div> </div>
); );
const handleCreatePage = async (payload: Partial<TPage>) => await createPage(payload);
const handleDuplicatePage = async () => {
const formData: Partial<TPage> = {
name: "Copy of " + name,
description_html: description_html ?? "<p></p>",
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 ( return (
<> <>
<PageHead title={name} /> <PageHead title={name} />
<div className="flex h-full flex-col justify-between"> <div className="flex h-full flex-col justify-between">
<div className="h-full w-full flex-shrink-0 flex flex-col overflow-hidden"> <div className="h-full w-full flex-shrink-0 flex flex-col overflow-hidden">
<PageEditorHeaderRoot <PageRoot page={page} projectId={projectId.toString()} workspaceSlug={workspaceSlug.toString()} />
editorRef={editorRef}
readOnlyEditorRef={readOnlyEditorRef}
editorReady={editorReady}
readOnlyEditorReady={readOnlyEditorReady}
handleDuplicatePage={handleDuplicatePage}
markings={markings}
page={page}
sidePeekVisible={sidePeekVisible}
setSidePeekVisible={(state) => setSidePeekVisible(state)}
/>
<PageEditorBody
editorRef={editorRef}
handleEditorReady={(val) => setEditorReady(val)}
readOnlyEditorRef={readOnlyEditorRef}
handleReadOnlyEditorReady={() => setReadOnlyEditorReady(true)}
markings={markings}
page={page}
sidePeekVisible={sidePeekVisible}
updateMarkings={updateMarkings}
/>
<IssuePeekOverview /> <IssuePeekOverview />
</div> </div>
</div> </div>

View file

@ -17,7 +17,6 @@ import { PageContentBrowser, PageContentLoader, PageEditorTitle } from "@/compon
import { cn } from "@/helpers/common.helper"; import { cn } from "@/helpers/common.helper";
// hooks // hooks
import { useMember, useMention, useUser, useWorkspace } from "@/hooks/store"; import { useMember, useMention, useUser, useWorkspace } from "@/hooks/store";
import { usePageDescription } from "@/hooks/use-page-description";
import { usePageFilters } from "@/hooks/use-page-filters"; import { usePageFilters } from "@/hooks/use-page-filters";
// plane web components // plane web components
import { IssueEmbedCard } from "@/plane-web/components/pages"; import { IssueEmbedCard } from "@/plane-web/components/pages";
@ -37,6 +36,9 @@ type Props = {
handleEditorReady: (value: boolean) => void; handleEditorReady: (value: boolean) => void;
handleReadOnlyEditorReady: (value: boolean) => void; handleReadOnlyEditorReady: (value: boolean) => void;
updateMarkings: (description_html: string) => void; updateMarkings: (description_html: string) => void;
handleDescriptionChange: (update: Uint8Array, source?: string | undefined) => void;
isDescriptionReady: boolean;
pageDescriptionYJS: Uint8Array | undefined;
}; };
export const PageEditorBody: React.FC<Props> = observer((props) => { export const PageEditorBody: React.FC<Props> = observer((props) => {
@ -49,6 +51,9 @@ export const PageEditorBody: React.FC<Props> = observer((props) => {
page, page,
sidePeekVisible, sidePeekVisible,
updateMarkings, updateMarkings,
handleDescriptionChange,
isDescriptionReady,
pageDescriptionYJS,
} = props; } = props;
// router // router
const { workspaceSlug, projectId } = useParams(); const { workspaceSlug, projectId } = useParams();
@ -67,13 +72,7 @@ export const PageEditorBody: React.FC<Props> = observer((props) => {
const { isContentEditable, updateTitle, setIsSubmitting } = page; const { isContentEditable, updateTitle, setIsSubmitting } = page;
const projectMemberIds = projectId ? getProjectMemberIds(projectId.toString()) : []; const projectMemberIds = projectId ? getProjectMemberIds(projectId.toString()) : [];
const projectMemberDetails = projectMemberIds?.map((id) => getUserDetails(id) as IUserLite); const projectMemberDetails = projectMemberIds?.map((id) => getUserDetails(id) as IUserLite);
// project-description
const { handleDescriptionChange, isDescriptionReady, pageDescriptionYJS } = usePageDescription({
editorRef,
page,
projectId,
workspaceSlug,
});
// use-mention // use-mention
const { mentionHighlights, mentionSuggestions } = useMention({ const { mentionHighlights, mentionSuggestions } = useMention({
workspaceSlug: workspaceSlug?.toString() ?? "", workspaceSlug: workspaceSlug?.toString() ?? "",

View file

@ -23,10 +23,11 @@ type Props = {
handleDuplicatePage: () => void; handleDuplicatePage: () => void;
page: IPage; page: IPage;
readOnlyEditorRef: React.RefObject<EditorReadOnlyRefApi>; readOnlyEditorRef: React.RefObject<EditorReadOnlyRefApi>;
handleSaveDescription: (forceSync?: boolean, initSyncVectorAsUpdate?: Uint8Array | undefined) => Promise<void>;
}; };
export const PageExtraOptions: React.FC<Props> = observer((props) => { export const PageExtraOptions: React.FC<Props> = observer((props) => {
const { editorRef, handleDuplicatePage, page, readOnlyEditorRef } = props; const { editorRef, handleDuplicatePage, page, readOnlyEditorRef, handleSaveDescription } = props;
// states // states
const [gptModalOpen, setGptModal] = useState(false); const [gptModalOpen, setGptModal] = useState(false);
// store hooks // store hooks
@ -76,6 +77,7 @@ export const PageExtraOptions: React.FC<Props> = observer((props) => {
editorRef={isContentEditable ? editorRef.current : readOnlyEditorRef.current} editorRef={isContentEditable ? editorRef.current : readOnlyEditorRef.current}
handleDuplicatePage={handleDuplicatePage} handleDuplicatePage={handleDuplicatePage}
page={page} page={page}
handleSaveDescription={handleSaveDescription}
/> />
</div> </div>
); );

View file

@ -17,6 +17,7 @@ type Props = {
setSidePeekVisible: (sidePeekState: boolean) => void; setSidePeekVisible: (sidePeekState: boolean) => void;
editorReady: boolean; editorReady: boolean;
readOnlyEditorReady: boolean; readOnlyEditorReady: boolean;
handleSaveDescription: (forceSync?: boolean, initSyncVectorAsUpdate?: Uint8Array | undefined) => Promise<void>;
}; };
export const PageEditorMobileHeaderRoot: React.FC<Props> = observer((props) => { export const PageEditorMobileHeaderRoot: React.FC<Props> = observer((props) => {
@ -30,6 +31,7 @@ export const PageEditorMobileHeaderRoot: React.FC<Props> = observer((props) => {
page, page,
sidePeekVisible, sidePeekVisible,
setSidePeekVisible, setSidePeekVisible,
handleSaveDescription,
} = props; } = props;
// derived values // derived values
const { isContentEditable } = page; const { isContentEditable } = page;
@ -52,6 +54,7 @@ export const PageEditorMobileHeaderRoot: React.FC<Props> = observer((props) => {
</div> </div>
<PageExtraOptions <PageExtraOptions
editorRef={editorRef} editorRef={editorRef}
handleSaveDescription={handleSaveDescription}
handleDuplicatePage={handleDuplicatePage} handleDuplicatePage={handleDuplicatePage}
page={page} page={page}
readOnlyEditorRef={readOnlyEditorRef} readOnlyEditorRef={readOnlyEditorRef}

View file

@ -18,10 +18,11 @@ type Props = {
editorRef: EditorRefApi | EditorReadOnlyRefApi | null; editorRef: EditorRefApi | EditorReadOnlyRefApi | null;
handleDuplicatePage: () => void; handleDuplicatePage: () => void;
page: IPage; page: IPage;
handleSaveDescription: (forceSync?: boolean, initSyncVectorAsUpdate?: Uint8Array | undefined) => Promise<void>;
}; };
export const PageOptionsDropdown: React.FC<Props> = observer((props) => { export const PageOptionsDropdown: React.FC<Props> = observer((props) => {
const { editorRef, handleDuplicatePage, page } = props; const { editorRef, handleDuplicatePage, page, handleSaveDescription } = props;
// store values // store values
const { const {
archived_at, archived_at,
@ -75,6 +76,11 @@ export const PageOptionsDropdown: React.FC<Props> = observer((props) => {
}) })
); );
const saveDescriptionYJSAndPerformAction = (action: () => void) => async () => {
await handleSaveDescription();
action();
};
// menu items list // menu items list
const MENU_ITEMS: { const MENU_ITEMS: {
key: string; key: string;
@ -116,21 +122,21 @@ export const PageOptionsDropdown: React.FC<Props> = observer((props) => {
}, },
{ {
key: "make-a-copy", key: "make-a-copy",
action: handleDuplicatePage, action: saveDescriptionYJSAndPerformAction(handleDuplicatePage),
label: "Make a copy", label: "Make a copy",
icon: Copy, icon: Copy,
shouldRender: canCurrentUserDuplicatePage, shouldRender: canCurrentUserDuplicatePage,
}, },
{ {
key: "lock-unlock-page", key: "lock-unlock-page",
action: is_locked ? handleUnlockPage : handleLockPage, action: is_locked ? handleUnlockPage : saveDescriptionYJSAndPerformAction(handleLockPage),
label: is_locked ? "Unlock page" : "Lock page", label: is_locked ? "Unlock page" : "Lock page",
icon: is_locked ? LockOpen : Lock, icon: is_locked ? LockOpen : Lock,
shouldRender: canCurrentUserLockPage, shouldRender: canCurrentUserLockPage,
}, },
{ {
key: "archive-restore-page", key: "archive-restore-page",
action: archived_at ? handleRestorePage : handleArchivePage, action: archived_at ? handleRestorePage : saveDescriptionYJSAndPerformAction(handleArchivePage),
label: archived_at ? "Restore page" : "Archive page", label: archived_at ? "Restore page" : "Archive page",
icon: archived_at ? ArchiveRestoreIcon : ArchiveIcon, icon: archived_at ? ArchiveRestoreIcon : ArchiveIcon,
shouldRender: canCurrentUserArchivePage, shouldRender: canCurrentUserArchivePage,

View file

@ -19,6 +19,7 @@ type Props = {
setSidePeekVisible: (sidePeekState: boolean) => void; setSidePeekVisible: (sidePeekState: boolean) => void;
editorReady: boolean; editorReady: boolean;
readOnlyEditorReady: boolean; readOnlyEditorReady: boolean;
handleSaveDescription: (forceSync?: boolean, initSyncVectorAsUpdate?: Uint8Array | undefined) => Promise<void>;
}; };
export const PageEditorHeaderRoot: React.FC<Props> = observer((props) => { export const PageEditorHeaderRoot: React.FC<Props> = observer((props) => {
@ -32,6 +33,7 @@ export const PageEditorHeaderRoot: React.FC<Props> = observer((props) => {
page, page,
sidePeekVisible, sidePeekVisible,
setSidePeekVisible, setSidePeekVisible,
handleSaveDescription,
} = props; } = props;
// derived values // derived values
const { isContentEditable } = page; const { isContentEditable } = page;
@ -63,12 +65,14 @@ export const PageEditorHeaderRoot: React.FC<Props> = observer((props) => {
<PageExtraOptions <PageExtraOptions
editorRef={editorRef} editorRef={editorRef}
handleDuplicatePage={handleDuplicatePage} handleDuplicatePage={handleDuplicatePage}
handleSaveDescription={handleSaveDescription}
page={page} page={page}
readOnlyEditorRef={readOnlyEditorRef} readOnlyEditorRef={readOnlyEditorRef}
/> />
</div> </div>
<div className="md:hidden"> <div className="md:hidden">
<PageEditorMobileHeaderRoot <PageEditorMobileHeaderRoot
handleSaveDescription={handleSaveDescription}
editorRef={editorRef} editorRef={editorRef}
readOnlyEditorRef={readOnlyEditorRef} readOnlyEditorRef={readOnlyEditorRef}
editorReady={editorReady} editorReady={editorReady}

View file

@ -0,0 +1,97 @@
import { useRef, useState } from "react";
import { observer } from "mobx-react";
import { EditorRefApi, useEditorMarkings } from "@plane/editor";
import { TPage } from "@plane/types";
import { setToast, TOAST_TYPE } from "@plane/ui";
import { PageEditorHeaderRoot, PageEditorBody } from "@/components/pages";
import { useProjectPages } from "@/hooks/store";
import { useAppRouter } from "@/hooks/use-app-router";
import { usePageDescription } from "@/hooks/use-page-description";
import { IPage } from "@/store/pages/page";
type TPageRootProps = {
page: IPage;
projectId: string;
workspaceSlug: string;
};
export const PageRoot = observer((props: TPageRootProps) => {
// 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<EditorRefApi>(null);
const readOnlyEditorRef = useRef<EditorRefApi>(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<TPage>) => await createPage(payload);
const handleDuplicatePage = async () => {
const formData: Partial<TPage> = {
name: "Copy of " + name,
description_html: editorRef.current?.getHTML() ?? description_html ?? "<p></p>",
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 (
<>
<PageEditorHeaderRoot
editorRef={editorRef}
readOnlyEditorRef={readOnlyEditorRef}
editorReady={editorReady}
readOnlyEditorReady={readOnlyEditorReady}
handleDuplicatePage={handleDuplicatePage}
handleSaveDescription={handleSaveDescription}
markings={markings}
page={page}
sidePeekVisible={sidePeekVisible}
setSidePeekVisible={(state) => setSidePeekVisible(state)}
/>
<PageEditorBody
editorRef={editorRef}
handleEditorReady={(val) => setEditorReady(val)}
readOnlyEditorRef={readOnlyEditorRef}
handleReadOnlyEditorReady={() => setReadOnlyEditorReady(true)}
markings={markings}
page={page}
sidePeekVisible={sidePeekVisible}
updateMarkings={updateMarkings}
handleDescriptionChange={handleDescriptionChange}
isDescriptionReady={isDescriptionReady}
pageDescriptionYJS={pageDescriptionYJS}
/>
</>
);
});

View file

@ -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<NodeJS.Timeout | null>(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;

View file

@ -1,19 +1,17 @@
import React, { useCallback, useEffect, useMemo, useState } from "react"; import React, { useCallback, useEffect, useState } from "react";
import useSWR from "swr"; import useSWR from "swr";
// editor
import { import { EditorRefApi, generateJSONfromHTML, proseMirrorJSONToBinaryString, applyUpdates } from "@plane/editor";
EditorRefApi,
applyUpdates,
generateJSONfromHTML,
mergeUpdates,
proseMirrorJSONToBinaryString,
} from "@plane/editor";
// hooks // hooks
import { setToast, TOAST_TYPE } from "@plane/ui";
import useAutoSave from "@/hooks/use-auto-save";
import useReloadConfirmations from "@/hooks/use-reload-confirmation"; import useReloadConfirmations from "@/hooks/use-reload-confirmation";
// services // services
import { ProjectPageService } from "@/services/page"; import { ProjectPageService } from "@/services/page";
// store
import { IPage } from "@/store/pages/page"; import { IPage } from "@/store/pages/page";
const projectPageService = new ProjectPageService(); const projectPageService = new ProjectPageService();
type Props = { type Props = {
@ -23,22 +21,28 @@ type Props = {
workspaceSlug: string | string[] | undefined; workspaceSlug: string | string[] | undefined;
}; };
const AUTO_SAVE_TIME = 10000;
export const usePageDescription = (props: Props) => { export const usePageDescription = (props: Props) => {
const { editorRef, page, projectId, workspaceSlug } = props; const { editorRef, page, projectId, workspaceSlug } = props;
// states
const [isDescriptionReady, setIsDescriptionReady] = useState(false); const [isDescriptionReady, setIsDescriptionReady] = useState(false);
const [descriptionUpdates, setDescriptionUpdates] = useState<Uint8Array[]>([]); const [localDescriptionYJS, setLocalDescriptionYJS] = useState<Uint8Array>();
// derived values
const { isContentEditable, isSubmitting, updateDescription, setIsSubmitting } = page; const { isContentEditable, isSubmitting, updateDescription, setIsSubmitting } = page;
const [hasShownOfflineToast, setHasShownOfflineToast] = useState(false);
const pageDescription = page.description_html; const pageDescription = page.description_html;
const pageId = page.id; 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 ? `PAGE_DESCRIPTION_${workspaceSlug}_${projectId}_${pageId}` : null,
workspaceSlug && projectId && pageId 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, : null,
{ {
revalidateOnFocus: false, revalidateOnFocus: false,
@ -46,15 +50,19 @@ export const usePageDescription = (props: Props) => {
revalidateIfStale: false, revalidateIfStale: false,
} }
); );
// description in Uint8Array format
const pageDescriptionYJS = useMemo(
() => (descriptionYJS ? new Uint8Array(descriptionYJS) : undefined),
[descriptionYJS]
);
// push the new updates to the updates array // set the merged local doc by the provider to the react local state
const handleDescriptionChange = useCallback((updates: Uint8Array) => { const handleDescriptionChange = useCallback((update: Uint8Array, source?: string) => {
setDescriptionUpdates((prev) => [...prev, updates]); 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 // 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(() => { useEffect(() => {
const changeHTMLToBinary = async () => { const changeHTMLToBinary = async () => {
if (!pageDescriptionYJS || !pageDescription) return; if (!pageDescriptionYJS || !pageDescription) return;
if (pageDescriptionYJS.byteLength === 0) { if (pageDescriptionYJS.length === 0) {
const { contentJSON, editorSchema } = generateJSONfromHTML(pageDescription ?? "<p></p>"); const { contentJSON, editorSchema } = generateJSONfromHTML(pageDescription ?? "<p></p>");
const yDocBinaryString = proseMirrorJSONToBinaryString(contentJSON, "default", editorSchema); const yDocBinaryString = proseMirrorJSONToBinaryString(contentJSON, "default", editorSchema);
await updateDescription(yDocBinaryString, pageDescription ?? "<p></p>");
try {
await updateDescription(yDocBinaryString, pageDescription ?? "<p></p>");
} catch (error) {
console.log("error", error);
}
await mutateDescriptionYJS(); await mutateDescriptionYJS();
setIsDescriptionReady(true); setIsDescriptionReady(true);
} else setIsDescriptionReady(true); } else setIsDescriptionReady(true);
}; };
changeHTMLToBinary(); changeHTMLToBinary();
}, [mutateDescriptionYJS, pageDescription, pageDescriptionYJS, updateDescription]); }, [mutateDescriptionYJS, pageDescription, pageDescriptionYJS, updateDescription]);
const handleSaveDescription = useCallback(async () => { const { setShowAlert } = useReloadConfirmations(true);
if (!isContentEditable) return;
const applyUpdatesAndSave = async (latestDescription: any, updates: Uint8Array) => { useEffect(() => {
if (!workspaceSlug || !projectId || !pageId || !latestDescription) return; if (editorRef?.current?.hasUnsyncedChanges() || isSubmitting === "submitting") {
// convert description to Uint8Array setShowAlert(true);
const descriptionArray = new Uint8Array(latestDescription); } else {
// apply the updates to the description setShowAlert(false);
const combinedBinaryString = applyUpdates(descriptionArray, updates);
// get the latest html content
const descriptionHTML = editorRef.current?.getHTML() ?? "<p></p>";
// 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;
} }
}, [ }, [setShowAlert, isSubmitting, editorRef, localDescriptionYJS]);
descriptionUpdates,
editorRef,
isContentEditable,
mutateDescriptionYJS,
pageId,
projectId,
setIsSubmitting,
updateDescription,
workspaceSlug,
]);
// auto-save updates every 10 seconds // merge the description from remote to local state and only save if there are local changes
// handle ctrl/cmd + S to save the description const handleSaveDescription = useCallback(
useEffect(() => { async (forceSync?: boolean, initSyncVectorAsUpdate?: Uint8Array) => {
const intervalId = setInterval(handleSaveDescription, AUTO_SAVE_TIME); const update = localDescriptionYJS ?? initSyncVectorAsUpdate;
const handleSave = (e: KeyboardEvent) => { if (update == null) return;
const { ctrlKey, metaKey, key } = e;
const cmdClicked = ctrlKey || metaKey;
if (cmdClicked && key.toLowerCase() === "s") { if (!isContentEditable) return;
e.preventDefault();
e.stopPropagation();
handleSaveDescription();
// reset interval timer const applyUpdatesAndSave = async (latestDescription: Uint8Array, update: Uint8Array | undefined) => {
clearInterval(intervalId); 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() ?? "<p></p>";
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 () => { useAutoSave(handleSaveDescription);
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]);
return { return {
handleDescriptionChange, handleDescriptionChange,
isDescriptionReady, isDescriptionReady,
pageDescriptionYJS, pageDescriptionYJS,
handleSaveDescription,
}; };
}; };

View file

@ -145,7 +145,7 @@ export class ProjectPageService extends APIService {
return this.patch(`/api/workspaces/${workspaceSlug}/projects/${projectId}/pages/${pageId}/description/`, data) return this.patch(`/api/workspaces/${workspaceSlug}/projects/${projectId}/pages/${pageId}/description/`, data)
.then((response) => response?.data) .then((response) => response?.data)
.catch((error) => { .catch((error) => {
throw error?.response?.data; throw error;
}); });
} }
} }