mirror of
https://github.com/makeplane/plane
synced 2025-08-07 19:59:33 +00:00
Compare commits
2 Commits
fix/issue-
...
feat/confl
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
36fccd25ca | ||
|
|
f5487097aa |
@@ -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<Pick<CompleteCollabora
|
||||
export class CollaborationProvider {
|
||||
public configuration: CompleteCollaboratorProviderConfiguration = {
|
||||
name: "",
|
||||
// @ts-expect-error cannot be undefined
|
||||
document: undefined,
|
||||
document: new Y.Doc(),
|
||||
onChange: () => {},
|
||||
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<CompleteCollaboratorProviderConfiguration> = {}): 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() {
|
||||
|
||||
@@ -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 (
|
||||
<PageRenderer
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { useEffect, useLayoutEffect, useMemo } from "react";
|
||||
import { useLayoutEffect, useMemo, useState } from "react";
|
||||
import Collaboration from "@tiptap/extension-collaboration";
|
||||
import { EditorProps } from "@tiptap/pm/view";
|
||||
import { IndexeddbPersistence } from "y-indexeddb";
|
||||
import * as Y from "yjs";
|
||||
// extensions
|
||||
import { DragAndDrop, IssueWidget } from "@/extensions";
|
||||
@@ -62,21 +61,27 @@ export const useDocumentEditor = (props: DocumentEditorProps) => {
|
||||
[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 };
|
||||
};
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 (
|
||||
<div className="size-full grid place-items-center">
|
||||
@@ -78,7 +89,7 @@ const PageDetailsPage = observer(() => {
|
||||
const handleDuplicatePage = async () => {
|
||||
const formData: Partial<TPage> = {
|
||||
name: "Copy of " + name,
|
||||
description_html: description_html ?? "<p></p>",
|
||||
description_html: editorRef.current?.getHTML() ?? description_html ?? "<p></p>",
|
||||
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}
|
||||
/>
|
||||
<IssuePeekOverview />
|
||||
</div>
|
||||
|
||||
@@ -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<Props> = observer((props) => {
|
||||
@@ -49,6 +51,9 @@ export const PageEditorBody: React.FC<Props> = observer((props) => {
|
||||
page,
|
||||
sidePeekVisible,
|
||||
updateMarkings,
|
||||
handleDescriptionChange,
|
||||
isDescriptionReady,
|
||||
pageDescriptionYJS,
|
||||
} = props;
|
||||
// router
|
||||
const { workspaceSlug, projectId } = useParams();
|
||||
@@ -67,13 +72,7 @@ export const PageEditorBody: React.FC<Props> = 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() ?? "",
|
||||
|
||||
@@ -22,10 +22,11 @@ type Props = {
|
||||
handleDuplicatePage: () => void;
|
||||
page: IPage;
|
||||
readOnlyEditorRef: React.RefObject<EditorReadOnlyRefApi>;
|
||||
handleSaveDescription: (initSyncVectorAsUpdate?: Uint8Array | undefined) => Promise<void>;
|
||||
};
|
||||
|
||||
export const PageExtraOptions: React.FC<Props> = 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<Props> = observer((props) => {
|
||||
editorRef={isContentEditable ? editorRef.current : readOnlyEditorRef.current}
|
||||
handleDuplicatePage={handleDuplicatePage}
|
||||
page={page}
|
||||
handleSaveDescription={handleSaveDescription}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -17,6 +17,7 @@ type Props = {
|
||||
setSidePeekVisible: (sidePeekState: boolean) => void;
|
||||
editorReady: boolean;
|
||||
readOnlyEditorReady: boolean;
|
||||
handleSaveDescription: (initSyncVectorAsUpdate?: Uint8Array | undefined) => Promise<void>;
|
||||
};
|
||||
|
||||
export const PageEditorMobileHeaderRoot: React.FC<Props> = observer((props) => {
|
||||
@@ -30,6 +31,7 @@ export const PageEditorMobileHeaderRoot: React.FC<Props> = observer((props) => {
|
||||
page,
|
||||
sidePeekVisible,
|
||||
setSidePeekVisible,
|
||||
handleSaveDescription,
|
||||
} = props;
|
||||
// derived values
|
||||
const { isContentEditable } = page;
|
||||
@@ -52,6 +54,7 @@ export const PageEditorMobileHeaderRoot: React.FC<Props> = observer((props) => {
|
||||
</div>
|
||||
<PageExtraOptions
|
||||
editorRef={editorRef}
|
||||
handleSaveDescription={handleSaveDescription}
|
||||
handleDuplicatePage={handleDuplicatePage}
|
||||
page={page}
|
||||
readOnlyEditorRef={readOnlyEditorRef}
|
||||
|
||||
@@ -18,10 +18,11 @@ type Props = {
|
||||
editorRef: EditorRefApi | EditorReadOnlyRefApi | null;
|
||||
handleDuplicatePage: () => void;
|
||||
page: IPage;
|
||||
handleSaveDescription: (initSyncVectorAsUpdate?: Uint8Array | undefined) => Promise<void>;
|
||||
};
|
||||
|
||||
export const PageOptionsDropdown: React.FC<Props> = 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<Props> = 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<Props> = 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,
|
||||
|
||||
@@ -19,6 +19,7 @@ type Props = {
|
||||
setSidePeekVisible: (sidePeekState: boolean) => void;
|
||||
editorReady: boolean;
|
||||
readOnlyEditorReady: boolean;
|
||||
handleSaveDescription: (initSyncVectorAsUpdate?: Uint8Array | undefined) => Promise<void>;
|
||||
};
|
||||
|
||||
export const PageEditorHeaderRoot: React.FC<Props> = observer((props) => {
|
||||
@@ -32,6 +33,7 @@ export const PageEditorHeaderRoot: React.FC<Props> = observer((props) => {
|
||||
page,
|
||||
sidePeekVisible,
|
||||
setSidePeekVisible,
|
||||
handleSaveDescription,
|
||||
} = props;
|
||||
// derived values
|
||||
const { isContentEditable } = page;
|
||||
@@ -63,12 +65,14 @@ export const PageEditorHeaderRoot: React.FC<Props> = observer((props) => {
|
||||
<PageExtraOptions
|
||||
editorRef={editorRef}
|
||||
handleDuplicatePage={handleDuplicatePage}
|
||||
handleSaveDescription={handleSaveDescription}
|
||||
page={page}
|
||||
readOnlyEditorRef={readOnlyEditorRef}
|
||||
/>
|
||||
</div>
|
||||
<div className="md:hidden">
|
||||
<PageEditorMobileHeaderRoot
|
||||
handleSaveDescription={handleSaveDescription}
|
||||
editorRef={editorRef}
|
||||
readOnlyEditorRef={readOnlyEditorRef}
|
||||
editorReady={editorReady}
|
||||
|
||||
60
web/core/hooks/use-auto-save.tsx
Normal file
60
web/core/hooks/use-auto-save.tsx
Normal file
@@ -0,0 +1,60 @@
|
||||
import { useEffect, useRef } from "react";
|
||||
import { debounce } from "lodash"; // You can use lodash or implement your own debounce function
|
||||
|
||||
const AUTO_SAVE_TIME = 10000;
|
||||
|
||||
const useAutoSave = (handleSaveDescription: () => 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(() => {
|
||||
handleSaveDescriptionRef.current();
|
||||
}, AUTO_SAVE_TIME);
|
||||
|
||||
return () => {
|
||||
if (intervalIdRef.current) {
|
||||
clearInterval(intervalIdRef.current);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Debounced save function for manual save (Ctrl+S or Cmd+S)
|
||||
useEffect(() => {
|
||||
const debouncedSave = debounce(() => {
|
||||
handleSaveDescriptionRef.current();
|
||||
|
||||
if (intervalIdRef.current) {
|
||||
clearInterval(intervalIdRef.current);
|
||||
intervalIdRef.current = setInterval(() => {
|
||||
handleSaveDescriptionRef.current();
|
||||
}, 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,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<Uint8Array[]>([]);
|
||||
// derived values
|
||||
const [localDescriptionYJS, setLocalDescriptionYJS] = useState<Uint8Array>();
|
||||
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 ?? "<p></p>");
|
||||
const yDocBinaryString = proseMirrorJSONToBinaryString(contentJSON, "default", editorSchema);
|
||||
|
||||
await updateDescription(yDocBinaryString, pageDescription ?? "<p></p>");
|
||||
|
||||
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() ?? "<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;
|
||||
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() ?? "<p></p>";
|
||||
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,
|
||||
};
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user