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; 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; 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([]); // 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 ?? "

"); const yDocBinaryString = proseMirrorJSONToBinaryString(contentJSON, "default", editorSchema); await updateIssueDescription(workspaceSlug.toString(), projectId.toString(), issueId.toString(), { description_binary: yDocBinaryString, description_html: issueDescription ?? "

", }); 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() ?? "

"; // 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, }; };