[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:
parent
c919435598
commit
99184371f7
16 changed files with 444 additions and 201 deletions
|
|
@ -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:
|
||||||
|
|
|
||||||
|
|
@ -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() {
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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 };
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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() ?? "",
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -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}
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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}
|
||||||
|
|
|
||||||
97
web/core/components/pages/editor/page-root.tsx
Normal file
97
web/core/components/pages/editor/page-root.tsx
Normal 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}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
});
|
||||||
73
web/core/hooks/use-auto-save.tsx
Normal file
73
web/core/hooks/use-auto-save.tsx
Normal 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;
|
||||||
|
|
@ -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,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue