mirror of
https://github.com/makeplane/plane
synced 2025-08-07 19:59:33 +00:00
Compare commits
17 Commits
v0.25.1
...
fix/extra-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3f8898d2fa | ||
|
|
e118ae3e73 | ||
|
|
cabc7d9b2b | ||
|
|
97949a96ec | ||
|
|
544e6a45a0 | ||
|
|
ba0b9f4ef9 | ||
|
|
73d2956afb | ||
|
|
2cdb0cd4a3 | ||
|
|
bd808221f2 | ||
|
|
547661d094 | ||
|
|
a6f43e9e90 | ||
|
|
b2f6c62774 | ||
|
|
ba037e7c1e | ||
|
|
c42f31372c | ||
|
|
8c95828567 | ||
|
|
8f0704b3fa | ||
|
|
f0ef3202b8 |
@@ -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 |
|
||||
| -------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -71,6 +71,7 @@ export const CoreEditorExtensions = ({
|
||||
},
|
||||
code: false,
|
||||
codeBlock: false,
|
||||
history: false,
|
||||
horizontalRule: false,
|
||||
blockquote: false,
|
||||
dropcursor: {
|
||||
|
||||
@@ -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 };
|
||||
};
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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() ?? "",
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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}
|
||||
|
||||
63
web/hooks/use-auto-save.tsx
Normal file
63
web/hooks/use-auto-save.tsx
Normal 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;
|
||||
@@ -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,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user