From e50264e6477d40c4ec3b5ad3c925b34095f4133e Mon Sep 17 00:00:00 2001 From: Aaryan Khandelwal <65252264+aaryan610@users.noreply.github.com> Date: Tue, 3 Sep 2024 18:51:24 +0530 Subject: [PATCH] feat: realtime collaboration for workspace pages (#1024) --- live/.babelrc | 2 +- live/src/ee/lib/authentication.ts | 41 +++- live/src/ee/lib/fetch-document.ts | 27 ++- live/src/ee/lib/update-document.ts | 23 +- live/src/ee/lib/workspace-page.ts | 99 +++++++++ .../src/ee/services/workspace-page.service.ts | 75 +++++++ live/src/ee/types/common.d.ts | 2 +- live/src/server.ts | 1 - live/tsconfig.json | 2 +- packages/editor/package.json | 1 + .../src/ee/extensions/collaboration-cursor.ts | 40 ++++ .../src/ee/extensions/document-extensions.tsx | 21 +- .../components/pages/editor/editor-body.tsx | 90 +++++--- web/ee/components/pages/editor/page-root.tsx | 37 ++-- web/ee/hooks/store/use-flag.ts | 1 + web/ee/hooks/use-editor-flagging.ts | 2 + .../hooks/use-workspace-page-description.ts | 203 ------------------ yarn.lock | 40 +--- 18 files changed, 414 insertions(+), 293 deletions(-) create mode 100644 live/src/ee/lib/workspace-page.ts create mode 100644 live/src/ee/services/workspace-page.service.ts create mode 100644 packages/editor/src/ee/extensions/collaboration-cursor.ts delete mode 100644 web/ee/hooks/use-workspace-page-description.ts diff --git a/live/.babelrc b/live/.babelrc index fa0ee452ae..6cd90fe490 100644 --- a/live/.babelrc +++ b/live/.babelrc @@ -15,7 +15,7 @@ "root": ["./src"], "alias": { "@/core": "./src/core", - "@/plane-live": "./src/ce" + "@/plane-live": "./src/ee" } } ] diff --git a/live/src/ee/lib/authentication.ts b/live/src/ee/lib/authentication.ts index 16a4fb11eb..f674b150fd 100644 --- a/live/src/ee/lib/authentication.ts +++ b/live/src/ee/lib/authentication.ts @@ -1 +1,40 @@ -export * from "../../ce/lib/authentication.js" \ No newline at end of file +import { ConnectionConfiguration } from "@hocuspocus/server"; +// services +import { UserService } from "@/core/services/user.service.js"; +// types +import { TDocumentTypes } from "@/core/types/common.js"; + +const userService = new UserService(); + +type TArgs = { + connection: ConnectionConfiguration + cookie: string; + documentType: TDocumentTypes | undefined; + params: URLSearchParams; +} + +export const authenticateUser = async (args: TArgs): Promise => { + const { connection, cookie, documentType, params } = args; + + if (documentType === "workspace_page") { + // params + const workspaceSlug = params.get("workspaceSlug")?.toString(); + if (!workspaceSlug) { + throw Error( + "Authentication failed: Incomplete query params. workspaceSlug is missing." + ); + } + // fetch current user's workspace membership info + const workspaceMembershipInfo = await userService.getUserWorkspaceMembership( + workspaceSlug, + cookie + ); + const workspaceRole = workspaceMembershipInfo.role; + // make the connection read only for roles lower than a member + if (workspaceRole < 15) { + connection.readOnly = true; + } + } else { + throw Error(`Authentication failed: Invalid document type ${documentType} provided.`); + } +} \ No newline at end of file diff --git a/live/src/ee/lib/fetch-document.ts b/live/src/ee/lib/fetch-document.ts index 9c9516937c..9138975590 100644 --- a/live/src/ee/lib/fetch-document.ts +++ b/live/src/ee/lib/fetch-document.ts @@ -1 +1,26 @@ -export * from "../../ce/lib/fetch-document.js" \ No newline at end of file +// types +import { TDocumentTypes } from "@/core/types/common.js"; +// lib +import { fetchWorkspacePageDescriptionBinary } from "./workspace-page.js"; + +type TArgs = { + cookie: string | undefined; + documentType: TDocumentTypes | undefined; + pageId: string; + params: URLSearchParams; +} + +export const fetchDocument = async (args: TArgs): Promise => { + const { cookie, documentType, pageId, params } = args; + + if (documentType === "workspace_page") { + const fetchedData = await fetchWorkspacePageDescriptionBinary( + params, + pageId, + cookie, + ); + return fetchedData; + } else { + throw Error(`Fetch failed: Invalid document type ${documentType} provided.`); + } +} diff --git a/live/src/ee/lib/update-document.ts b/live/src/ee/lib/update-document.ts index 20aed8fd89..bb17f42bc1 100644 --- a/live/src/ee/lib/update-document.ts +++ b/live/src/ee/lib/update-document.ts @@ -1 +1,22 @@ -export * from "../../ce/lib/update-document.js" \ No newline at end of file +// types +import { TDocumentTypes } from "@/core/types/common.js"; +// plane live lib +import { updateWorkspacePageDescription } from "@/plane-live/lib/workspace-page.js"; + +type TArgs = { + cookie: string | undefined; + documentType: TDocumentTypes | undefined; + pageId: string; + params: URLSearchParams; + updatedDescription: Uint8Array; +} + +export const updateDocument = async (args: TArgs): Promise => { + const { cookie, documentType, pageId, params, updatedDescription } = args; + + if (documentType === "workspace_page") { + await updateWorkspacePageDescription(params, pageId, updatedDescription, cookie); + } else { + throw Error(`Update failed: Invalid document type ${documentType} provided.`); + } +} \ No newline at end of file diff --git a/live/src/ee/lib/workspace-page.ts b/live/src/ee/lib/workspace-page.ts new file mode 100644 index 0000000000..9c6eb8e28d --- /dev/null +++ b/live/src/ee/lib/workspace-page.ts @@ -0,0 +1,99 @@ +// helpers +import { getAllDocumentFormatsFromBinaryData, getBinaryDataFromHTMLString } from "@/core/helpers/page.js"; +// services +import { WorkspacePageService } from "../services/workspace-page.service.js"; +const workspacePageService = new WorkspacePageService(); + +export const updateWorkspacePageDescription = async ( + params: URLSearchParams, + pageId: string, + updatedDescription: Uint8Array, + cookie: string | undefined +) => { + if (!(updatedDescription instanceof Uint8Array)) { + throw new Error( + "Invalid updatedDescription: must be an instance of Uint8Array" + ); + } + + const workspaceSlug = params.get("workspaceSlug")?.toString(); + if (!workspaceSlug || !cookie) return; + + const { + contentBinaryEncoded, + contentHTML, + contentJSON + } = getAllDocumentFormatsFromBinaryData(updatedDescription); + try { + const payload = { + description_binary: contentBinaryEncoded, + description_html: contentHTML, + description: contentJSON, + }; + + await workspacePageService.updateDescription( + workspaceSlug, + pageId, + payload, + cookie + ); + } catch (error) { + console.error("Update error:", error); + throw error; + } +}; + +const fetchDescriptionHTMLAndTransform = async ( + workspaceSlug: string, + pageId: string, + cookie: string +) => { + if (!workspaceSlug || !cookie) return; + + try { + const pageDetails = await workspacePageService.fetchDetails( + workspaceSlug, + pageId, + cookie + ); + const { contentBinary } = getBinaryDataFromHTMLString(pageDetails.description_html ?? "

") + return contentBinary; + } catch (error) { + console.error("Error while transforming from HTML to Uint8Array", error); + throw error; + } +}; + +export const fetchWorkspacePageDescriptionBinary = async ( + params: URLSearchParams, + pageId: string, + cookie: string | undefined +) => { + const workspaceSlug = params.get("workspaceSlug")?.toString(); + if (!workspaceSlug || !cookie) return null; + + try { + const response = await workspacePageService.fetchDescriptionBinary( + workspaceSlug, + pageId, + cookie + ); + const binaryData = new Uint8Array(response); + + if (binaryData.byteLength === 0) { + const binary = await fetchDescriptionHTMLAndTransform( + workspaceSlug, + pageId, + cookie + ); + if (binary) { + return binary; + } + } + + return binaryData; + } catch (error) { + console.error("Fetch error:", error); + throw error; + } +}; diff --git a/live/src/ee/services/workspace-page.service.ts b/live/src/ee/services/workspace-page.service.ts new file mode 100644 index 0000000000..dbeea20be0 --- /dev/null +++ b/live/src/ee/services/workspace-page.service.ts @@ -0,0 +1,75 @@ +// types +import { TPage } from "@plane/types"; +// services +import { API_BASE_URL, APIService } from "../../core/services/api.service.js"; + +export class WorkspacePageService extends APIService { + constructor() { + super(API_BASE_URL); + } + + async fetchDetails( + workspaceSlug: string, + pageId: string, + cookie: string + ): Promise { + return this.get( + `/api/workspaces/${workspaceSlug}/pages/${pageId}/`, + { + headers: { + Cookie: cookie, + }, + } + ) + .then((response) => response?.data) + .catch((error) => { + throw error?.response?.data; + }); + } + + async fetchDescriptionBinary( + workspaceSlug: string, + pageId: string, + cookie: string + ): Promise { + return this.get( + `/api/workspaces/${workspaceSlug}/pages/${pageId}/description/`, + { + headers: { + "Content-Type": "application/octet-stream", + Cookie: cookie, + }, + responseType: "arraybuffer", + } + ) + .then((response) => response?.data) + .catch((error) => { + throw error?.response?.data; + }); + } + + async updateDescription( + workspaceSlug: string, + pageId: string, + data: { + description_binary: string; + description_html: string; + description: object; + }, + cookie: string + ): Promise { + return this.patch( + `/api/workspaces/${workspaceSlug}/pages/${pageId}/description/`, + data, + { + headers: { + Cookie: cookie, + }, + } + ) + .then((response) => response?.data) + .catch((error) => { + throw error; + }); + } +} diff --git a/live/src/ee/types/common.d.ts b/live/src/ee/types/common.d.ts index 8ebedf3814..b04fceea57 100644 --- a/live/src/ee/types/common.d.ts +++ b/live/src/ee/types/common.d.ts @@ -1 +1 @@ -export * from "../../ce/types/common.js" \ No newline at end of file +export type TAdditionalDocumentTypes = "workspace_page" \ No newline at end of file diff --git a/live/src/server.ts b/live/src/server.ts index a64a6878b2..60ff7b7287 100644 --- a/live/src/server.ts +++ b/live/src/server.ts @@ -1,6 +1,5 @@ import { Server } from "@hocuspocus/server"; import { Redis } from "@hocuspocus/extension-redis"; - import { Database } from "@hocuspocus/extension-database"; import { Logger } from "@hocuspocus/extension-logger"; import express from "express"; diff --git a/live/tsconfig.json b/live/tsconfig.json index a9f2b6f7b5..b0a7a38590 100644 --- a/live/tsconfig.json +++ b/live/tsconfig.json @@ -9,7 +9,7 @@ "baseUrl": ".", "paths": { "@/core/*": ["./src/core/*"], - "@/plane-live/*": ["./src/ce/*"] + "@/plane-live/*": ["./src/ee/*"] }, "removeComments": true, "esModuleInterop": true, diff --git a/packages/editor/package.json b/packages/editor/package.json index bb4e50ffdd..2e7cccf50d 100644 --- a/packages/editor/package.json +++ b/packages/editor/package.json @@ -41,6 +41,7 @@ "@tiptap/extension-blockquote": "^2.1.13", "@tiptap/extension-character-count": "^2.6.5", "@tiptap/extension-collaboration": "^2.3.2", + "@tiptap/extension-collaboration-cursor": "^2.6.6", "@tiptap/extension-image": "^2.1.13", "@tiptap/extension-list-item": "^2.1.13", "@tiptap/extension-mention": "^2.1.13", diff --git a/packages/editor/src/ee/extensions/collaboration-cursor.ts b/packages/editor/src/ee/extensions/collaboration-cursor.ts new file mode 100644 index 0000000000..ee511c8247 --- /dev/null +++ b/packages/editor/src/ee/extensions/collaboration-cursor.ts @@ -0,0 +1,40 @@ +import { HocuspocusProvider } from "@hocuspocus/provider"; +import CollaborationCursor from "@tiptap/extension-collaboration-cursor"; +// types +import { TUserDetails } from "@/types"; + +type Props = { + provider: HocuspocusProvider; + userDetails: TUserDetails; +}; + +const renderUserCursor = (user: { color: string; name: string }): HTMLSpanElement => { + const cursor = document.createElement("span"); + + cursor.classList.value = "relative border-x -mx-[1px] pointer-events-none break-normal"; + cursor.setAttribute("style", `border-color: ${user.color}`); + + const label = document.createElement("span"); + + label.classList.value = + "absolute rounded-[3px_3px_3px_0] text-[#0d0d0d] text-xs font-semibold leading-normal -top-[1.3rem] -left-[1px] py-0.5 px-1.5 select-none whitespace-nowrap"; + label.setAttribute("style", `background-color: ${user.color}`); + label.insertBefore(document.createTextNode(user.name), null); + + const nonBreakingSpace1 = document.createTextNode("\u2060"); + const nonBreakingSpace2 = document.createTextNode("\u2060"); + cursor.insertBefore(nonBreakingSpace1, null); + cursor.insertBefore(label, null); + cursor.insertBefore(nonBreakingSpace2, null); + return cursor; +}; + +export const CustomCollaborationCursor = (props: Props) => { + const { provider, userDetails } = props; + + return CollaborationCursor.configure({ + user: userDetails, + render: renderUserCursor, + provider, + }); +}; diff --git a/packages/editor/src/ee/extensions/document-extensions.tsx b/packages/editor/src/ee/extensions/document-extensions.tsx index fe777d8ea2..f731ce6e19 100644 --- a/packages/editor/src/ee/extensions/document-extensions.tsx +++ b/packages/editor/src/ee/extensions/document-extensions.tsx @@ -1,27 +1,31 @@ +import { HocuspocusProvider } from "@hocuspocus/provider"; import { Extensions } from "@tiptap/core"; // ui import { LayersIcon } from "@plane/ui"; // extensions import { SlashCommand } from "@/extensions"; -// hooks -import { TFileHandler } from "@/hooks/use-editor"; // plane editor extensions import { IssueEmbedSuggestions, IssueListRenderer } from "@/plane-editor/extensions"; // plane editor types import { TIssueEmbedConfig } from "@/plane-editor/types"; // types -import { ISlashCommandItem, TExtensions } from "@/types"; +import { ISlashCommandItem, TExtensions, TFileHandler, TUserDetails } from "@/types"; +// local extensions +import { CustomCollaborationCursor } from "./collaboration-cursor"; type Props = { disabledExtensions?: TExtensions[]; fileHandler: TFileHandler; issueEmbedConfig: TIssueEmbedConfig | undefined; + provider: HocuspocusProvider; + userDetails: TUserDetails; }; export const DocumentEditorAdditionalExtensions = (props: Props) => { - const { disabledExtensions, fileHandler, issueEmbedConfig } = props; + const { disabledExtensions, fileHandler, issueEmbedConfig, provider, userDetails } = props; const isIssueEmbedDisabled = !!disabledExtensions?.includes("issue-embed"); + const isCollaborationCursorDisabled = !!disabledExtensions?.includes("collaboration-cursor"); const extensions: Extensions = []; @@ -53,5 +57,14 @@ export const DocumentEditorAdditionalExtensions = (props: Props) => { ); } + if (!isCollaborationCursorDisabled) { + extensions.push( + CustomCollaborationCursor({ + provider, + userDetails, + }) + ); + } + return extensions; }; diff --git a/web/ee/components/pages/editor/editor-body.tsx b/web/ee/components/pages/editor/editor-body.tsx index 32b7519b0e..a6509fa254 100644 --- a/web/ee/components/pages/editor/editor-body.tsx +++ b/web/ee/components/pages/editor/editor-body.tsx @@ -1,22 +1,25 @@ -import { useCallback, useEffect } from "react"; +import { useCallback, useEffect, useMemo } from "react"; import { observer } from "mobx-react"; import { useParams } from "next/navigation"; // document-editor import { - DocumentEditorWithRef, - DocumentReadOnlyEditorWithRef, + CollaborativeDocumentEditorWithRef, + CollaborativeDocumentReadOnlyEditorWithRef, EditorReadOnlyRefApi, EditorRefApi, IMarking, TAIMenuProps, TDisplayConfig, + TRealtimeConfig, + TServerHandler, } from "@plane/editor"; // types import { IUserLite } from "@plane/types"; // components import { PageContentBrowser, PageEditorTitle, PageContentLoader } from "@/components/pages"; // helpers -import { cn } from "@/helpers/common.helper"; +import { cn, LIVE_URL } from "@/helpers/common.helper"; +import { generateRandomColor } from "@/helpers/string.helper"; // hooks import { useMember, useUser, useWorkspace } from "@/hooks/store"; import { usePageFilters } from "@/hooks/use-page-filters"; @@ -35,34 +38,30 @@ const fileService = new FileService(); type Props = { editorRef: React.RefObject; - readOnlyEditorRef: React.RefObject; - markings: IMarking[]; - page: IWorkspacePageDetails; - sidePeekVisible: boolean; - handleDescriptionChange: (update: Uint8Array, source?: string | undefined) => void; + handleConnectionStatus: (status: boolean) => void; handleEditorReady: (value: boolean) => void; handleReadOnlyEditorReady: (value: boolean) => void; + markings: IMarking[]; + page: IWorkspacePageDetails; + readOnlyEditorRef: React.RefObject; + sidePeekVisible: boolean; updateMarkings: (description_html: string) => void; - isDescriptionReady: boolean; - pageDescriptionYJS: Uint8Array | undefined; }; export const WorkspacePageEditorBody: React.FC = observer((props) => { const { - handleReadOnlyEditorReady, - handleEditorReady, editorRef, + handleConnectionStatus, + handleEditorReady, + handleReadOnlyEditorReady, markings, - readOnlyEditorRef, page, + readOnlyEditorRef, sidePeekVisible, updateMarkings, - handleDescriptionChange, - isDescriptionReady, - pageDescriptionYJS, } = props; // router - const { workspaceSlug, projectId } = useParams(); + const { workspaceSlug } = useParams(); // store hooks const { data: currentUser } = useUser(); const { getWorkspaceBySlug } = useWorkspace(); @@ -100,17 +99,43 @@ export const WorkspacePageEditorBody: React.FC = observer((props) => { [editorRef] ); + const handleServerConnect = useCallback(() => { + handleConnectionStatus(false); + }, []); + const handleServerError = useCallback(() => { + handleConnectionStatus(true); + }, []); + + const serverHandler: TServerHandler = useMemo( + () => ({ + onConnect: handleServerConnect, + onServerError: handleServerError, + }), + [] + ); + useEffect(() => { updateMarkings(pageDescription ?? "

"); }, [pageDescription, updateMarkings]); - if (pageId === undefined || !pageDescriptionYJS || !isDescriptionReady) return ; + const realtimeConfig: TRealtimeConfig = useMemo( + () => ({ + url: `${LIVE_URL}/collaboration`, + queryParams: { + workspaceSlug: workspaceSlug?.toString(), + documentType: "workspace_page", + }, + }), + [workspaceSlug] + ); const handleIssueSearch = async (searchQuery: string) => { const response = await fetchIssues(searchQuery); return response; }; + if (pageId === undefined) return ; + if (pageDescription === undefined) return ; return ( @@ -145,7 +170,7 @@ export const WorkspacePageEditorBody: React.FC = observer((props) => { /> {isContentEditable ? ( - = observer((props) => { upload: fileService.getUploadFileFunction(workspaceSlug as string, setIsSubmitting), }} handleEditorReady={handleEditorReady} - value={pageDescriptionYJS} ref={editorRef} containerClassName="p-0 pb-64" displayConfig={displayConfig} editorClassName="pl-10" - onChange={handleDescriptionChange} mentionHandler={{ highlights: mentionHighlights, suggestions: mentionSuggestions, @@ -183,7 +206,7 @@ export const WorkspacePageEditorBody: React.FC = observer((props) => { projectId: projectIdFromEmbed, workspaceSlug: workspaceSlugFromEmbed, }) => { - const resolvedProjectId = projectIdFromEmbed ?? projectId?.toString() ?? ""; + const resolvedProjectId = projectIdFromEmbed ?? ""; const resolvedWorkspaceSlug = workspaceSlugFromEmbed ?? workspaceSlug?.toString() ?? ""; return ( = observer((props) => { }, }, }} + realtimeConfig={realtimeConfig} + serverHandler={serverHandler} + user={{ + id: currentUser?.id ?? "", + name: currentUser?.display_name ?? "", + color: generateRandomColor(currentUser?.id ?? ""), + }} disabledExtensions={documentEditor} aiHandler={{ menu: getAIMenu, }} /> ) : ( -

"} + ref={readOnlyEditorRef} handleEditorReady={handleReadOnlyEditorReady} containerClassName="p-0 pb-64 border-none" displayConfig={displayConfig} @@ -219,7 +248,7 @@ export const WorkspacePageEditorBody: React.FC = observer((props) => { projectId: projectIdFromEmbed, workspaceSlug: workspaceSlugFromEmbed, }) => { - const resolvedProjectId = projectIdFromEmbed ?? projectId?.toString() ?? ""; + const resolvedProjectId = projectIdFromEmbed ?? ""; const resolvedWorkspaceSlug = workspaceSlugFromEmbed ?? workspaceSlug?.toString() ?? ""; return ( @@ -232,6 +261,13 @@ export const WorkspacePageEditorBody: React.FC = observer((props) => { }, }, }} + realtimeConfig={realtimeConfig} + serverHandler={serverHandler} + user={{ + id: currentUser?.id ?? "", + name: currentUser?.display_name ?? "", + color: generateRandomColor(currentUser?.id ?? ""), + }} /> )} diff --git a/web/ee/components/pages/editor/page-root.tsx b/web/ee/components/pages/editor/page-root.tsx index b9d8b1399a..bd526611ba 100644 --- a/web/ee/components/pages/editor/page-root.tsx +++ b/web/ee/components/pages/editor/page-root.tsx @@ -16,7 +16,6 @@ import { useQueryParams } from "@/hooks/use-query-params"; import { WorkspacePageEditorBody, WorkspacePagesVersionEditor } from "@/plane-web/components/pages"; // plane web hooks import { useWorkspacePages } from "@/plane-web/hooks/store"; -import { useWorkspacePageDescription } from "@/plane-web/hooks/use-workspace-page-description"; // plane web services import { WorkspacePageVersionService } from "@/plane-web/services/page"; const workspacePageVersionService = new WorkspacePageVersionService(); @@ -33,6 +32,7 @@ export const WorkspacePageRoot = observer((props: TPageRootProps) => { // states const [editorReady, setEditorReady] = useState(false); const [readOnlyEditorReady, setReadOnlyEditorReady] = useState(false); + const [hasConnectionFailed, setHasConnectionFailed] = useState(false); const [sidePeekVisible, setSidePeekVisible] = useState(window.innerWidth >= 768); const [isVersionsOverlayOpen, setIsVersionsOverlayOpen] = useState(false); // refs @@ -48,18 +48,6 @@ export const WorkspacePageRoot = observer((props: TPageRootProps) => { const { access, description_html, name, isContentEditable } = page; // editor markings hook const { markings, updateMarkings } = useEditorMarkings(); - // page description - const { - handleDescriptionChange, - isDescriptionReady, - pageDescriptionYJS, - handleSaveDescription, - manuallyUpdateDescription, - } = useWorkspacePageDescription({ - editorRef, - page, - workspaceSlug, - }); // update query params const { updateQueryParams } = useQueryParams(); @@ -99,6 +87,11 @@ export const WorkspacePageRoot = observer((props: TPageRootProps) => { router.push(updatedRoute); }; + const handleRestoreVersion = async (descriptionHTML: string) => { + editorRef.current?.clearEditor(); + editorRef.current?.setEditorValue(descriptionHTML); + }; + return ( <> { if (!workspaceSlug) return; return await workspacePageVersionService.fetchVersionById(workspaceSlug.toString(), pageId, versionId); }} - handleRestore={manuallyUpdateDescription} + handleRestore={handleRestoreVersion} isOpen={isVersionsOverlayOpen} onClose={handleCloseVersionsOverlay} pageId={page.id ?? ""} restoreEnabled={isContentEditable} /> setSidePeekVisible(state)} + sidePeekVisible={sidePeekVisible} /> setHasConnectionFailed(status)} handleEditorReady={(val) => setEditorReady(val)} - readOnlyEditorRef={readOnlyEditorRef} handleReadOnlyEditorReady={() => setReadOnlyEditorReady(true)} markings={markings} page={page} + readOnlyEditorRef={readOnlyEditorRef} sidePeekVisible={sidePeekVisible} updateMarkings={updateMarkings} - handleDescriptionChange={handleDescriptionChange} - isDescriptionReady={isDescriptionReady} - pageDescriptionYJS={pageDescriptionYJS} /> ); diff --git a/web/ee/hooks/store/use-flag.ts b/web/ee/hooks/store/use-flag.ts index c921372ee4..b88bbdda4d 100644 --- a/web/ee/hooks/store/use-flag.ts +++ b/web/ee/hooks/store/use-flag.ts @@ -5,6 +5,7 @@ import { StoreContext } from "@/lib/store-context"; export enum E_FEATURE_FLAGS { BULK_OPS = "BULK_OPS", BULK_OPS_ADVANCED = "BULK_OPS_ADVANCED", + COLLABORATION_CURSOR = "COLLABORATION_CURSOR", EDITOR_AI_OPS = "EDITOR_AI_OPS", ESTIMATE_WITH_TIME = "ESTIMATE_WITH_TIME", ISSUE_TYPE_DISPLAY = "ISSUE_TYPE_DISPLAY", diff --git a/web/ee/hooks/use-editor-flagging.ts b/web/ee/hooks/use-editor-flagging.ts index b07b93fbfc..8245ea555d 100644 --- a/web/ee/hooks/use-editor-flagging.ts +++ b/web/ee/hooks/use-editor-flagging.ts @@ -14,10 +14,12 @@ export const useEditorFlagging = ( } => { const isIssueEmbedEnabled = useFlag(workspaceSlug, "PAGE_ISSUE_EMBEDS"); const isEditorAIOpsEnabled = useFlag(workspaceSlug, "EDITOR_AI_OPS"); + const isCollaborationCursorEnabled = useFlag(workspaceSlug, "COLLABORATION_CURSOR"); // extensions disabled in the document editor const documentEditor: TExtensions[] = []; if (!isIssueEmbedEnabled) documentEditor.push("issue-embed"); if (!isEditorAIOpsEnabled) documentEditor.push("ai"); + if (!isCollaborationCursorEnabled) documentEditor.push("collaboration-cursor"); return { documentEditor, diff --git a/web/ee/hooks/use-workspace-page-description.ts b/web/ee/hooks/use-workspace-page-description.ts deleted file mode 100644 index 2b027b7f6e..0000000000 --- a/web/ee/hooks/use-workspace-page-description.ts +++ /dev/null @@ -1,203 +0,0 @@ -import React, { useCallback, useEffect, useState } from "react"; -import useSWR from "swr"; -// editor -import { - EditorRefApi, - applyUpdates, - generateJSONfromHTMLForDocumentEditor, - proseMirrorJSONToBinaryString, -} from "@plane/editor"; -import { setToast, TOAST_TYPE } from "@plane/ui"; -// hooks -import useAutoSave from "@/hooks/use-auto-save"; -import useReloadConfirmations from "@/hooks/use-reload-confirmation"; -// services -import { WorkspacePageService } from "@/plane-web/services/page"; -// plane web store -import { IWorkspacePageDetails } from "@/plane-web/store/pages/page"; -const workspacePageService = new WorkspacePageService(); - -type Props = { - editorRef: React.RefObject; - page: IWorkspacePageDetails; - workspaceSlug: string | string[] | undefined; -}; - -export const useWorkspacePageDescription = (props: Props) => { - const { editorRef, page, workspaceSlug } = props; - const [isDescriptionReady, setIsDescriptionReady] = useState(false); - const [localDescriptionYJS, setLocalDescriptionYJS] = useState(); - const { isContentEditable, isSubmitting, updateDescription, setIsSubmitting } = page; - const [hasShownOfflineToast, setHasShownOfflineToast] = useState(false); - - const pageDescription = page.description_html; - const pageId = page.id; - - const { data: pageDescriptionYJS, mutate: mutateDescriptionYJS } = useSWR( - workspaceSlug && pageId ? `PAGE_DESCRIPTION_${workspaceSlug}_${pageId}` : null, - workspaceSlug && pageId - ? async () => { - const encodedDescription = await workspacePageService.fetchDescriptionYJS( - workspaceSlug.toString(), - pageId.toString() - ); - const decodedDescription = new Uint8Array(encodedDescription); - return decodedDescription; - } - : null, - { - revalidateOnFocus: false, - revalidateOnReconnect: false, - revalidateIfStale: false, - } - ); - - // 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(true, 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 - // TODO: this is a one-time operation, and needs to be removed once all the pages are updated - useEffect(() => { - const changeHTMLToBinary = async () => { - if (!pageDescriptionYJS || !pageDescription) return; - if (pageDescriptionYJS.length === 0) { - const { contentJSON, editorSchema } = generateJSONfromHTMLForDocumentEditor(pageDescription ?? "

"); - const yDocBinaryString = proseMirrorJSONToBinaryString(contentJSON, "default", editorSchema); - - try { - await updateDescription(yDocBinaryString, pageDescription ?? "

"); - } catch (error) { - console.log("error", error); - } - - await mutateDescriptionYJS(); - - setIsDescriptionReady(true); - } else setIsDescriptionReady(true); - }; - changeHTMLToBinary(); - }, [mutateDescriptionYJS, pageDescription, pageDescriptionYJS, updateDescription]); - - const { setShowAlert } = useReloadConfirmations(true); - - useEffect(() => { - if (editorRef?.current?.hasUnsyncedChanges() || isSubmitting === "submitting") { - setShowAlert(true); - } else { - setShowAlert(false); - } - }, [setShowAlert, isSubmitting, editorRef, localDescriptionYJS]); - - // merge the description from remote to local state and only save if there are local changes - const handleSaveDescription = useCallback( - async (forceSync?: boolean, initSyncVectorAsUpdate?: Uint8Array) => { - const update = localDescriptionYJS ?? initSyncVectorAsUpdate; - - if (update == null) return; - - if (!isContentEditable) return; - - const applyUpdatesAndSave = async (latestDescription: Uint8Array, update: Uint8Array | undefined) => { - if (!workspaceSlug || !pageId || !latestDescription || !update) return; - - if (!forceSync && !editorRef.current?.hasUnsyncedChanges()) { - setIsSubmitting("saved"); - return; - } - - const combinedBinaryString = applyUpdates(latestDescription, update); - const descriptionHTML = editorRef.current?.getHTML() ?? "

"; - await updateDescription(combinedBinaryString, descriptionHTML) - .then(() => { - editorRef.current?.setSynced(); - setHasShownOfflineToast(false); - }) - .catch((e) => { - if (e.message === "Network Error" && !hasShownOfflineToast) { - setToast({ - type: TOAST_TYPE.INFO, - title: "Info!", - message: "You seem to be offline, your changes will remain saved on this device", - }); - setHasShownOfflineToast(true); - } - if (e.response?.status === 471) { - setToast({ - type: TOAST_TYPE.ERROR, - title: "Error!", - message: "Failed to save your changes, the page was locked, your changes will be lost", - }); - } - if (e.response?.status === 472) { - setToast({ - type: TOAST_TYPE.ERROR, - title: "Error!", - message: "Failed to save your changes, the page was archived, your changes will be lost", - }); - } - }) - .finally(() => { - setShowAlert(false); - setIsSubmitting("saved"); - }); - }; - - try { - setIsSubmitting("submitting"); - const latestDescription = await mutateDescriptionYJS(); - if (latestDescription) { - await applyUpdatesAndSave(latestDescription, update); - } - } catch (error) { - setIsSubmitting("saved"); - throw error; - } - }, - [ - localDescriptionYJS, - setShowAlert, - editorRef, - hasShownOfflineToast, - isContentEditable, - mutateDescriptionYJS, - pageId, - setIsSubmitting, - updateDescription, - workspaceSlug, - ] - ); - - const manuallyUpdateDescription = async (descriptionHTML: string) => { - const { contentJSON, editorSchema } = generateJSONfromHTMLForDocumentEditor(descriptionHTML ?? "

"); - const yDocBinaryString = proseMirrorJSONToBinaryString(contentJSON, "default", editorSchema); - - try { - editorRef.current?.clearEditor(true); - await updateDescription(yDocBinaryString, descriptionHTML ?? "

"); - await mutateDescriptionYJS(); - } catch (error) { - console.log("error", error); - } - }; - - useAutoSave(handleSaveDescription); - - return { - handleDescriptionChange, - isDescriptionReady, - pageDescriptionYJS, - handleSaveDescription, - manuallyUpdateDescription, - }; -}; diff --git a/yarn.lock b/yarn.lock index 4af7038ba5..d07a28d668 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3606,6 +3606,11 @@ resolved "https://registry.yarnpkg.com/@tiptap/extension-code/-/extension-code-2.6.6.tgz#587eb08d09684fecc1c0f4d755e8b37ea65ff461" integrity sha512-JrEFKsZiLvfvOFhOnnrpA0TzCuJjDeysfbMeuKUZNV4+DhYOL28d39H1++rEtJAX0LcbBU60oC5/PrlU9SpvRQ== +"@tiptap/extension-collaboration-cursor@^2.6.6": + version "2.6.6" + resolved "https://registry.yarnpkg.com/@tiptap/extension-collaboration-cursor/-/extension-collaboration-cursor-2.6.6.tgz#858421f71df27d8cac985d1ee774133502ccc63f" + integrity sha512-KNGmRT6FxuSta8srK8Q13n35RZ079ySSNcOIARmJaBMQulgVXQc0wBViiEESUiV1EqvGd1FcIBJ1tzcl71g1Yw== + "@tiptap/extension-collaboration@^2.3.2": version "2.6.6" resolved "https://registry.yarnpkg.com/@tiptap/extension-collaboration/-/extension-collaboration-2.6.6.tgz#bc8f00de1c688d83de1171e98669cd919c90eaab" @@ -5355,7 +5360,7 @@ check-error@^1.0.3: dependencies: get-func-name "^2.0.2" -chokidar@^3.3.0, chokidar@^3.5.1, chokidar@^3.5.2, chokidar@^3.5.3: +chokidar@^3.3.0, chokidar@^3.5.1, chokidar@^3.5.2, chokidar@^3.5.3, chokidar@^3.6.0: version "3.6.0" resolved "https://registry.yarnpkg.com/chokidar/-/chokidar-3.6.0.tgz#197c6cc669ef2a8dc5e7b4d97ee4e092c3eb0d5b" integrity sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw== @@ -11279,16 +11284,8 @@ string-argv@~0.3.2: resolved "https://registry.yarnpkg.com/string-argv/-/string-argv-0.3.2.tgz#2b6d0ef24b656274d957d54e0a4bbf6153dc02b6" integrity sha512-aqD2Q0144Z+/RqG52NeHEkZauTAUWJO8c6yTftGJKO3Tja5tUgIfmIl6kExvhtxSDP7fXB6DvzkfMpCd/F3G+Q== -"string-width-cjs@npm:string-width@^4.2.0": - version "4.2.3" - resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010" - integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g== - dependencies: - emoji-regex "^8.0.0" - is-fullwidth-code-point "^3.0.0" - strip-ansi "^6.0.1" - -string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2.3: +"string-width-cjs@npm:string-width@^4.2.0", string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2.3: + name string-width-cjs version "4.2.3" resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010" integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g== @@ -11384,14 +11381,7 @@ string_decoder@^1.1.1: dependencies: safe-buffer "~5.2.0" -"strip-ansi-cjs@npm:strip-ansi@^6.0.1": - version "6.0.1" - resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9" - integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A== - dependencies: - ansi-regex "^5.0.1" - -strip-ansi@^6.0.0, strip-ansi@^6.0.1: +"strip-ansi-cjs@npm:strip-ansi@^6.0.1", strip-ansi@^6.0.0, strip-ansi@^6.0.1: version "6.0.1" resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9" integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A== @@ -12599,16 +12589,8 @@ word-wrap@^1.2.5: resolved "https://registry.yarnpkg.com/word-wrap/-/word-wrap-1.2.5.tgz#d2c45c6dd4fbce621a66f136cbe328afd0410b34" integrity sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA== -"wrap-ansi-cjs@npm:wrap-ansi@^7.0.0": - version "7.0.0" - resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43" - integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q== - dependencies: - ansi-styles "^4.0.0" - string-width "^4.1.0" - strip-ansi "^6.0.0" - -wrap-ansi@^7.0.0: +"wrap-ansi-cjs@npm:wrap-ansi@^7.0.0", wrap-ansi@^7.0.0: + name wrap-ansi-cjs version "7.0.0" resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43" integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==