Compare commits

...

17 Commits

Author SHA1 Message Date
Palanikannan M
3f8898d2fa fix: setContent for locking pages etc now properly preserves whitespaces in the content 2024-06-19 18:40:25 +05:30
Palanikannan M
e118ae3e73 fix: locking, copying and archiving page now properly saves changes 2024-06-19 17:26:30 +05:30
Palanikannan M
cabc7d9b2b fix: slow sync while copying a page 2024-06-19 15:35:58 +05:30
Palanikannan M
97949a96ec fix: case when there's no binary 2024-06-19 14:02:30 +05:30
Palanikannan M
544e6a45a0 fix: removed has local changes logic to be created from web app 2024-06-19 13:54:51 +05:30
Palanikannan M
ba0b9f4ef9 fix: some runtime type errors 2024-06-19 11:48:04 +05:30
Palanikannan M
73d2956afb fix: no sync on reload 2024-06-19 11:43:18 +05:30
Palanikannan M
2cdb0cd4a3 chore: remove unnecessary stuff 2024-06-19 10:53:03 +05:30
Palanikannan M
bd808221f2 fix: added initial sync logic 2024-06-18 19:24:19 +05:30
Palanikannan M
547661d094 fix: removed any kindoff dependency on localstate for merging updates 2024-06-18 15:28:34 +05:30
Palanikannan M
a6f43e9e90 enable undo and redo commands post sync 2024-06-18 12:07:23 +05:30
Palanikannan M
b2f6c62774 fix: multi page sync 2024-06-17 18:03:59 +05:30
Palanikannan M
ba037e7c1e fix: having initial update post sync to save automatically for initial sync 2024-06-17 13:54:26 +05:30
Palanikannan M
c42f31372c indexeddb inside provider itself 2024-06-17 09:50:47 +05:30
Palanikannan M
8c95828567 temp 2024-06-14 17:32:44 +05:30
Palanikannan M
8f0704b3fa revert: old stuff 2024-06-13 20:05:43 +05:30
Palanikannan M
f0ef3202b8 fix: extra indexed db update on mount causing repeated popups on unload 2024-06-13 17:22:58 +05:30
15 changed files with 294 additions and 128 deletions

View File

@@ -48,7 +48,7 @@ Meet [Plane](https://dub.sh/plane-website-readme), an open-source project manage
The easiest way to get started with Plane is by creating a [Plane Cloud](https://app.plane.so) account.
If you would like to self-host Plane, please see our [deployment guide](https://docs.plane.so/self-hosting/overview).
If you would like to self-host Plane, please see our [deployment guide](https://docs.plane.so/docker-compose).
| Installation methods | Docs link |
| -------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------ |

View File

@@ -28,6 +28,7 @@ export interface CustomEditorProps {
// undefined when prop is not passed, null if intentionally passed to stop
// swr syncing
value?: string | null | undefined;
provider?: any;
onChange?: (json: object, html: string) => void;
extensions?: any;
editorProps?: EditorProps;
@@ -53,6 +54,7 @@ export const useEditor = ({
forwardedRef,
tabIndex,
handleEditorReady,
provider,
mentionHandler,
placeholder,
}: CustomEditorProps) => {
@@ -132,7 +134,7 @@ export const useEditor = ({
editorRef.current?.commands.clearContent();
},
setEditorValue: (content: string) => {
editorRef.current?.commands.setContent(content);
editorRef.current?.commands.setContent(content, false, { preserveWhitespace: "full" });
},
setEditorValueAtCursorPosition: (content: string) => {
if (savedSelection) {
@@ -174,6 +176,16 @@ export const useEditor = ({
editorRef.current?.off("transaction");
};
},
setSynced: () => {
if (provider) {
provider.setSynced();
}
},
hasUnsyncedChanges: () => {
if (provider) {
return provider.hasUnsyncedChanges();
}
},
getMarkDown: (): string => {
const markdownOutput = editorRef.current?.storage.markdown.getMarkdown();
return markdownOutput;

View File

@@ -52,7 +52,7 @@ export const useReadOnlyEditor = ({
// for syncing swr data on tab refocus etc
useEffect(() => {
if (initialValue === null || initialValue === undefined) return;
if (editor && !editor.isDestroyed) editor?.commands.setContent(initialValue);
if (editor && !editor.isDestroyed) editor?.commands.setContent(initialValue, false, { preserveWhitespace: "full" });
}, [editor, initialValue]);
const editorRef: MutableRefObject<Editor | null> = useRef(null);
@@ -62,7 +62,7 @@ export const useReadOnlyEditor = ({
editorRef.current?.commands.clearContent();
},
setEditorValue: (content: string) => {
editorRef.current?.commands.setContent(content);
editorRef.current?.commands.setContent(content, false, { preserveWhitespace: "full" });
},
getMarkDown: (): string => {
const markdownOutput = editorRef.current?.storage.markdown.getMarkdown();

View File

@@ -11,6 +11,8 @@ export type EditorReadOnlyRefApi = {
export interface EditorRefApi extends EditorReadOnlyRefApi {
setEditorValueAtCursorPosition: (content: string) => void;
setSynced: () => void;
hasUnsyncedChanges: () => boolean;
executeMenuItemCommand: (itemName: EditorMenuItemNames) => void;
isMenuItemActive: (itemName: EditorMenuItemNames) => boolean;
onStateChange: (callback: () => void) => () => void;

View File

@@ -71,6 +71,7 @@ export const CoreEditorExtensions = ({
},
code: false,
codeBlock: false,
history: false,
horizontalRule: false,
blockquote: false,
dropcursor: {

View File

@@ -1,20 +1,20 @@
import { useEffect, useLayoutEffect, useMemo } from "react";
import { useEffect, useLayoutEffect, useMemo, useState } from "react";
import { EditorProps } from "@tiptap/pm/view";
import { IndexeddbPersistence } from "y-indexeddb";
import * as Y from "yjs";
// editor-core
import { EditorRefApi, IMentionHighlight, IMentionSuggestion, TFileHandler, useEditor } from "@plane/editor-core";
// custom provider
import { CollaborationProvider } from "src/providers/collaboration-provider";
// extensions
import { DocumentEditorExtensions } from "src/ui/extensions";
// yjs
import * as Y from "yjs";
type DocumentEditorProps = {
id: string;
fileHandler: TFileHandler;
value: Uint8Array;
editorClassName: string;
onChange: (updates: Uint8Array) => void;
onChange: (update: Uint8Array, source?: string) => void;
editorProps?: EditorProps;
forwardedRef?: React.MutableRefObject<EditorRefApi | null>;
mentionHandler: {
@@ -51,18 +51,27 @@ export const useDocumentEditor = ({
[id]
);
// update document on value change
const [isIndexedDbSynced, setIndexedDbIsSynced] = useState(false);
// update document on value change from server
useEffect(() => {
if (value.byteLength > 0) Y.applyUpdate(provider.document, value);
if (value.length > 0) {
Y.applyUpdate(provider.document, value);
}
}, [value, provider.document]);
// indexedDB provider
useLayoutEffect(() => {
const localProvider = new IndexeddbPersistence(id, provider.document);
// watch for indexedDb to complete syncing, only after which the editor is
// rendered
useEffect(() => {
async function checkIndexDbSynced() {
const hasSynced = await provider.hasIndexedDBSynced();
setIndexedDbIsSynced(hasSynced);
}
checkIndexDbSynced();
return () => {
localProvider?.destroy();
setIndexedDbIsSynced(false);
};
}, [provider, id]);
}, [provider]);
const editor = useEditor({
id,
@@ -77,9 +86,10 @@ export const useDocumentEditor = ({
setHideDragHandle: setHideDragHandleFunction,
provider,
}),
provider,
placeholder,
tabIndex,
});
return editor;
return { editor, isIndexedDbSynced };
};

View File

@@ -1,3 +1,4 @@
import { IndexeddbPersistence } from "y-indexeddb";
import * as Y from "yjs";
export interface CompleteCollaboratorProviderConfiguration {
@@ -12,7 +13,11 @@ 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.
*/
hasIndexedDBSynced: boolean;
}
export type CollaborationProviderConfiguration = Required<Pick<CompleteCollaboratorProviderConfiguration, "name">> &
@@ -21,19 +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,
@@ -45,12 +59,49 @@ export class CollaborationProvider {
return this.configuration.document;
}
documentUpdateHandler(update: Uint8Array, origin: any) {
public hasUnsyncedChanges(): boolean {
return this.unsyncedChanges > 0;
}
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() {

View File

@@ -19,7 +19,7 @@ interface IDocumentEditor {
handleEditorReady?: (value: boolean) => void;
containerClassName?: string;
editorClassName?: string;
onChange: (updates: Uint8Array) => void;
onChange: (update: Uint8Array, source?: string) => void;
forwardedRef?: React.MutableRefObject<EditorRefApi | null>;
mentionHandler: {
highlights: () => Promise<IMentionHighlight[]>;
@@ -52,7 +52,7 @@ const DocumentEditor = (props: IDocumentEditor) => {
};
// use document editor
const editor = useDocumentEditor({
const { editor, isIndexedDbSynced } = useDocumentEditor({
id,
editorClassName,
fileHandler,
@@ -72,7 +72,7 @@ const DocumentEditor = (props: IDocumentEditor) => {
containerClassName,
});
if (!editor) return null;
if (!editor || !isIndexedDbSynced) return null;
return (
<PageRenderer

View File

@@ -17,7 +17,7 @@ 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 { usePageDescription } from "@/hooks/use-page-description";
import { usePageFilters } from "@/hooks/use-page-filters";
// services
import { FileService } from "@/services/file.service";
@@ -35,6 +35,10 @@ 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;
handleSaveDescription: (initSyncVectorAsUpdate?: Uint8Array | undefined) => Promise<void>;
};
export const PageEditorBody: React.FC<Props> = observer((props) => {
@@ -47,6 +51,9 @@ export const PageEditorBody: React.FC<Props> = observer((props) => {
page,
sidePeekVisible,
updateMarkings,
isDescriptionReady,
pageDescriptionYJS,
handleDescriptionChange,
} = props;
// router
const router = useRouter();
@@ -66,13 +73,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() ?? "",

View File

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

View File

@@ -17,10 +17,11 @@ type Props = {
editorRef: EditorRefApi | EditorReadOnlyRefApi | null;
handleDuplicatePage: () => void;
page: IPageStore;
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,
@@ -74,10 +75,15 @@ export const PageOptionsDropdown: React.FC<Props> = observer((props) => {
})
);
const saveDescriptionYJSAndPerformAction = (action: () => void) => async () => {
await handleSaveDescription();
action();
};
// menu items list
const MENU_ITEMS: {
key: string;
action: () => void;
action: (() => void) | (() => Promise<void>);
label: string;
icon: React.FC<any>;
shouldRender: boolean;
@@ -115,21 +121,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,

View File

@@ -20,6 +20,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) => {
@@ -34,6 +35,7 @@ export const PageEditorHeaderRoot: React.FC<Props> = observer((props) => {
projectId,
sidePeekVisible,
setSidePeekVisible,
handleSaveDescription,
} = props;
// derived values
const { isContentEditable } = page;
@@ -65,6 +67,7 @@ export const PageEditorHeaderRoot: React.FC<Props> = observer((props) => {
<PageExtraOptions
editorRef={editorRef}
handleDuplicatePage={handleDuplicatePage}
handleSaveDescription={handleSaveDescription}
page={page}
projectId={projectId}
readOnlyEditorRef={readOnlyEditorRef}

View File

@@ -0,0 +1,63 @@
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 the latest handleSaveDescription function
useEffect(() => {
handleSaveDescriptionRef.current = handleSaveDescription;
}, [handleSaveDescription]);
useEffect(() => {
const intervalCallback = () => {
handleSaveDescriptionRef.current();
};
intervalIdRef.current = setInterval(intervalCallback, AUTO_SAVE_TIME);
return () => {
if (intervalIdRef.current) {
clearInterval(intervalIdRef.current);
}
};
}, []);
useEffect(() => {
// debounce the function so that excessive calls to handleSaveDescription don't cause multiple calls to the server
const debouncedSave = debounce(() => {
handleSaveDescriptionRef.current();
if (intervalIdRef.current) {
// clear the interval after saving manually
clearInterval(intervalIdRef.current);
// then reset the interval for auto-save to keep working
intervalIdRef.current = setInterval(() => {
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;

View File

@@ -1,13 +1,12 @@
import React, { useCallback, useEffect, useMemo, useState } from "react";
import React, { useCallback, useEffect, useState } from "react";
import useSWR from "swr";
// editor
import { applyUpdates, mergeUpdates, proseMirrorJSONToBinaryString } from "@plane/document-editor";
import { applyUpdates, proseMirrorJSONToBinaryString } from "@plane/document-editor";
import { EditorRefApi, generateJSONfromHTML } from "@plane/editor-core";
// hooks
import useAutoSave from "@/hooks/use-auto-save";
import useReloadConfirmations from "@/hooks/use-reload-confirmation";
// services
import { PageService } from "@/services/page.service";
import { IPageStore } from "@/store/pages/page.store";
const pageService = new PageService();
type Props = {
@@ -17,22 +16,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
? () => pageService.fetchDescriptionYJS(workspaceSlug.toString(), projectId.toString(), pageId.toString())
? async () => {
const encodedDescription = await pageService.fetchDescriptionYJS(
workspaceSlug.toString(),
projectId.toString(),
pageId.toString()
);
const decodedDescription = new Uint8Array(encodedDescription);
return decodedDescription;
}
: null,
{
revalidateOnFocus: false,
@@ -40,15 +44,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
@@ -56,98 +64,89 @@ 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;
useEffect(() => {}, [localDescriptionYJS, editorRef]);
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"));
};
const { setShowAlert } = useReloadConfirmations(true);
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,
};
};

View File

@@ -19,6 +19,7 @@ import { PageEditorBody, PageEditorHeaderRoot } from "@/components/pages";
import { cn } from "@/helpers/common.helper";
// hooks
import { usePage, useProjectPages } from "@/hooks/store";
import { usePageDescription } from "@/hooks/use-page-description";
// layouts
import { AppLayout } from "@/layouts/app-layout";
// lib
@@ -52,6 +53,16 @@ const PageDetailsPage: NextPageWithLayout = 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">
@@ -112,6 +123,7 @@ const PageDetailsPage: NextPageWithLayout = observer(() => {
projectId={projectId.toString()}
sidePeekVisible={sidePeekVisible}
setSidePeekVisible={(state) => setSidePeekVisible(state)}
handleSaveDescription={handleSaveDescription}
/>
)}
<PageEditorBody
@@ -123,6 +135,10 @@ const PageDetailsPage: NextPageWithLayout = observer(() => {
page={page}
sidePeekVisible={sidePeekVisible}
updateMarkings={updateMarkings}
handleDescriptionChange={handleDescriptionChange}
isDescriptionReady={isDescriptionReady}
pageDescriptionYJS={pageDescriptionYJS}
handleSaveDescription={handleSaveDescription}
/>
<IssuePeekOverview />
</div>