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==