mirror of
https://github.com/makeplane/plane
synced 2024-06-14 14:31:34 +00:00
192 lines
6.6 KiB
TypeScript
192 lines
6.6 KiB
TypeScript
import React, { useCallback, useEffect, useMemo, useState } from "react";
|
|
import useSWR from "swr";
|
|
// editor
|
|
import { applyUpdates, mergeUpdates, proseMirrorJSONToBinaryString } from "@plane/document-editor";
|
|
import { EditorRefApi, generateJSONfromHTML } from "@plane/editor-core";
|
|
// types
|
|
import { TIssueDescription } from "@plane/types";
|
|
// hooks
|
|
import useReloadConfirmations from "@/hooks/use-reload-confirmation";
|
|
// services
|
|
import { IssueService } from "@/services/issue";
|
|
import { useIssueDetail } from "./store";
|
|
const issueService = new IssueService();
|
|
|
|
type Props = {
|
|
canUpdateDescription: boolean;
|
|
editorRef: React.RefObject<EditorRefApi>;
|
|
isSubmitting: "submitting" | "submitted" | "saved";
|
|
issueId: string | string[] | undefined;
|
|
projectId: string | string[] | undefined;
|
|
setIsSubmitting: (initialValue: "submitting" | "submitted" | "saved") => void;
|
|
updateIssueDescription: (
|
|
workspaceSlug: string,
|
|
projectId: string,
|
|
issueId: string,
|
|
data: TIssueDescription
|
|
) => Promise<void>;
|
|
workspaceSlug: string | string[] | undefined;
|
|
};
|
|
|
|
const AUTO_SAVE_TIME = 10000;
|
|
|
|
export const useIssueDescription = (props: Props) => {
|
|
const {
|
|
canUpdateDescription,
|
|
editorRef,
|
|
isSubmitting,
|
|
issueId,
|
|
projectId,
|
|
setIsSubmitting,
|
|
updateIssueDescription,
|
|
workspaceSlug,
|
|
} = props;
|
|
// states
|
|
const [isDescriptionReady, setIsDescriptionReady] = useState(false);
|
|
const [descriptionUpdates, setDescriptionUpdates] = useState<Uint8Array[]>([]);
|
|
// store hooks
|
|
const {
|
|
issue: { getIssueById },
|
|
} = useIssueDetail();
|
|
// derived values
|
|
const issueDetails = issueId ? getIssueById(issueId.toString()) : undefined;
|
|
const issueDescription = issueDetails?.description_html;
|
|
|
|
const { data: descriptionBinary, mutate: mutateDescriptionBinary } = useSWR(
|
|
workspaceSlug && projectId && issueId ? `ISSUE_DESCRIPTION_BINARY_${workspaceSlug}_${projectId}_${issueId}` : null,
|
|
workspaceSlug && projectId && issueId
|
|
? () => issueService.fetchDescriptionBinary(workspaceSlug.toString(), projectId.toString(), issueId.toString())
|
|
: null,
|
|
{
|
|
revalidateOnFocus: false,
|
|
revalidateOnReconnect: false,
|
|
revalidateIfStale: false,
|
|
}
|
|
);
|
|
// description in Uint8Array format
|
|
const issueDescriptionYJS = useMemo(
|
|
() => (descriptionBinary ? new Uint8Array(descriptionBinary) : undefined),
|
|
[descriptionBinary]
|
|
);
|
|
|
|
// push the new updates to the updates array
|
|
const handleDescriptionChange = useCallback((updates: Uint8Array) => {
|
|
setDescriptionUpdates((prev) => [...prev, updates]);
|
|
}, []);
|
|
|
|
// if description_binary field is empty, convert description_html to yDoc and update the DB
|
|
// TODO: this is a one-time operation, and needs to be removed once all the issues are updated
|
|
useEffect(() => {
|
|
const changeHTMLToBinary = async () => {
|
|
if (!workspaceSlug || !projectId || !issueId) return;
|
|
if (!issueDescriptionYJS || !issueDescription) return;
|
|
if (issueDescriptionYJS.byteLength === 0) {
|
|
const { contentJSON, editorSchema } = generateJSONfromHTML(issueDescription ?? "<p></p>");
|
|
const yDocBinaryString = proseMirrorJSONToBinaryString(contentJSON, "default", editorSchema);
|
|
await updateIssueDescription(workspaceSlug.toString(), projectId.toString(), issueId.toString(), {
|
|
description_binary: yDocBinaryString,
|
|
description_html: issueDescription ?? "<p></p>",
|
|
});
|
|
await mutateDescriptionBinary();
|
|
setIsDescriptionReady(true);
|
|
} else setIsDescriptionReady(true);
|
|
};
|
|
changeHTMLToBinary();
|
|
}, [
|
|
issueDescription,
|
|
issueId,
|
|
mutateDescriptionBinary,
|
|
issueDescriptionYJS,
|
|
projectId,
|
|
updateIssueDescription,
|
|
workspaceSlug,
|
|
]);
|
|
|
|
const handleSaveDescription = useCallback(async () => {
|
|
if (!canUpdateDescription) return;
|
|
|
|
const applyUpdatesAndSave = async (latestDescription: any, updates: Uint8Array) => {
|
|
if (!workspaceSlug || !projectId || !issueId || !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 updateIssueDescription(workspaceSlug.toString(), projectId.toString(), issueId.toString(), {
|
|
description_binary: combinedBinaryString,
|
|
description_html: descriptionHTML,
|
|
}).finally(() => setIsSubmitting("saved"));
|
|
};
|
|
|
|
try {
|
|
setIsSubmitting("submitting");
|
|
// fetch the latest description
|
|
const latestDescription = await mutateDescriptionBinary();
|
|
// 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;
|
|
}
|
|
}, [
|
|
canUpdateDescription,
|
|
descriptionUpdates,
|
|
editorRef,
|
|
issueId,
|
|
mutateDescriptionBinary,
|
|
projectId,
|
|
setIsSubmitting,
|
|
updateIssueDescription,
|
|
workspaceSlug,
|
|
]);
|
|
|
|
// auto-save updates every 10 seconds
|
|
// handle ctrl/cmd + S to save the description
|
|
useEffect(() => {
|
|
const intervalId = setInterval(handleSaveDescription, AUTO_SAVE_TIME);
|
|
|
|
const handleSave = (e: KeyboardEvent) => {
|
|
const { ctrlKey, metaKey, key } = e;
|
|
const cmdClicked = ctrlKey || metaKey;
|
|
|
|
if (cmdClicked && key.toLowerCase() === "s") {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
handleSaveDescription();
|
|
|
|
// reset interval timer
|
|
clearInterval(intervalId);
|
|
}
|
|
};
|
|
window.addEventListener("keydown", handleSave);
|
|
|
|
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]);
|
|
|
|
return {
|
|
handleDescriptionChange,
|
|
isDescriptionReady,
|
|
issueDescriptionYJS,
|
|
};
|
|
};
|