mirror of
https://github.com/makeplane/plane.git
synced 2026-07-12 13:29:56 +02:00
Merge branch 'preview' of github.com:makeplane/plane-ee into preview
This commit is contained in:
@@ -15,7 +15,7 @@
|
||||
"root": ["./src"],
|
||||
"alias": {
|
||||
"@/core": "./src/core",
|
||||
"@/plane-live": "./src/ce"
|
||||
"@/plane-live": "./src/ee"
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
@@ -1 +1,40 @@
|
||||
export * from "../../ce/lib/authentication.js"
|
||||
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<void> => {
|
||||
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.`);
|
||||
}
|
||||
}
|
||||
@@ -1 +1,26 @@
|
||||
export * from "../../ce/lib/fetch-document.js"
|
||||
// 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<Uint8Array | null> => {
|
||||
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.`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1 +1,22 @@
|
||||
export * from "../../ce/lib/update-document.js"
|
||||
// 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<void> => {
|
||||
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.`);
|
||||
}
|
||||
}
|
||||
99
live/src/ee/lib/workspace-page.ts
Normal file
99
live/src/ee/lib/workspace-page.ts
Normal file
@@ -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 ?? "<p></p>")
|
||||
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;
|
||||
}
|
||||
};
|
||||
75
live/src/ee/services/workspace-page.service.ts
Normal file
75
live/src/ee/services/workspace-page.service.ts
Normal file
@@ -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<TPage> {
|
||||
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<any> {
|
||||
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<any> {
|
||||
return this.patch(
|
||||
`/api/workspaces/${workspaceSlug}/pages/${pageId}/description/`,
|
||||
data,
|
||||
{
|
||||
headers: {
|
||||
Cookie: cookie,
|
||||
},
|
||||
}
|
||||
)
|
||||
.then((response) => response?.data)
|
||||
.catch((error) => {
|
||||
throw error;
|
||||
});
|
||||
}
|
||||
}
|
||||
2
live/src/ee/types/common.d.ts
vendored
2
live/src/ee/types/common.d.ts
vendored
@@ -1 +1 @@
|
||||
export * from "../../ce/types/common.js"
|
||||
export type TAdditionalDocumentTypes = "workspace_page"
|
||||
@@ -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";
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/core/*": ["./src/core/*"],
|
||||
"@/plane-live/*": ["./src/ce/*"]
|
||||
"@/plane-live/*": ["./src/ee/*"]
|
||||
},
|
||||
"removeComments": true,
|
||||
"esModuleInterop": true,
|
||||
|
||||
@@ -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",
|
||||
|
||||
40
packages/editor/src/ee/extensions/collaboration-cursor.ts
Normal file
40
packages/editor/src/ee/extensions/collaboration-cursor.ts
Normal file
@@ -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,
|
||||
});
|
||||
};
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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<EditorRefApi>;
|
||||
readOnlyEditorRef: React.RefObject<EditorReadOnlyRefApi>;
|
||||
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<EditorReadOnlyRefApi>;
|
||||
sidePeekVisible: boolean;
|
||||
updateMarkings: (description_html: string) => void;
|
||||
isDescriptionReady: boolean;
|
||||
pageDescriptionYJS: Uint8Array | undefined;
|
||||
};
|
||||
|
||||
export const WorkspacePageEditorBody: React.FC<Props> = 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<Props> = observer((props) => {
|
||||
[editorRef]
|
||||
);
|
||||
|
||||
const handleServerConnect = useCallback(() => {
|
||||
handleConnectionStatus(false);
|
||||
}, []);
|
||||
const handleServerError = useCallback(() => {
|
||||
handleConnectionStatus(true);
|
||||
}, []);
|
||||
|
||||
const serverHandler: TServerHandler = useMemo(
|
||||
() => ({
|
||||
onConnect: handleServerConnect,
|
||||
onServerError: handleServerError,
|
||||
}),
|
||||
[]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
updateMarkings(pageDescription ?? "<p></p>");
|
||||
}, [pageDescription, updateMarkings]);
|
||||
|
||||
if (pageId === undefined || !pageDescriptionYJS || !isDescriptionReady) return <PageContentLoader />;
|
||||
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 <PageContentLoader />;
|
||||
|
||||
if (pageDescription === undefined) return <PageContentLoader />;
|
||||
|
||||
return (
|
||||
@@ -145,7 +170,7 @@ export const WorkspacePageEditorBody: React.FC<Props> = observer((props) => {
|
||||
/>
|
||||
</div>
|
||||
{isContentEditable ? (
|
||||
<DocumentEditorWithRef
|
||||
<CollaborativeDocumentEditorWithRef
|
||||
id={pageId}
|
||||
fileHandler={{
|
||||
cancel: fileService.cancelUpload,
|
||||
@@ -154,12 +179,10 @@ export const WorkspacePageEditorBody: React.FC<Props> = 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<Props> = observer((props) => {
|
||||
projectId: projectIdFromEmbed,
|
||||
workspaceSlug: workspaceSlugFromEmbed,
|
||||
}) => {
|
||||
const resolvedProjectId = projectIdFromEmbed ?? projectId?.toString() ?? "";
|
||||
const resolvedProjectId = projectIdFromEmbed ?? "";
|
||||
const resolvedWorkspaceSlug = workspaceSlugFromEmbed ?? workspaceSlug?.toString() ?? "";
|
||||
return (
|
||||
<IssueEmbedCard
|
||||
@@ -195,16 +218,22 @@ export const WorkspacePageEditorBody: React.FC<Props> = observer((props) => {
|
||||
},
|
||||
},
|
||||
}}
|
||||
realtimeConfig={realtimeConfig}
|
||||
serverHandler={serverHandler}
|
||||
user={{
|
||||
id: currentUser?.id ?? "",
|
||||
name: currentUser?.display_name ?? "",
|
||||
color: generateRandomColor(currentUser?.id ?? ""),
|
||||
}}
|
||||
disabledExtensions={documentEditor}
|
||||
aiHandler={{
|
||||
menu: getAIMenu,
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<DocumentReadOnlyEditorWithRef
|
||||
ref={readOnlyEditorRef}
|
||||
<CollaborativeDocumentReadOnlyEditorWithRef
|
||||
id={pageId}
|
||||
initialValue={pageDescription ?? "<p></p>"}
|
||||
ref={readOnlyEditorRef}
|
||||
handleEditorReady={handleReadOnlyEditorReady}
|
||||
containerClassName="p-0 pb-64 border-none"
|
||||
displayConfig={displayConfig}
|
||||
@@ -219,7 +248,7 @@ export const WorkspacePageEditorBody: React.FC<Props> = 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<Props> = observer((props) => {
|
||||
},
|
||||
},
|
||||
}}
|
||||
realtimeConfig={realtimeConfig}
|
||||
serverHandler={serverHandler}
|
||||
user={{
|
||||
id: currentUser?.id ?? "",
|
||||
name: currentUser?.display_name ?? "",
|
||||
color: generateRandomColor(currentUser?.id ?? ""),
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -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 (
|
||||
<>
|
||||
<PageVersionsOverlay
|
||||
@@ -112,36 +105,34 @@ export const WorkspacePageRoot = observer((props: TPageRootProps) => {
|
||||
if (!workspaceSlug) return;
|
||||
return await workspacePageVersionService.fetchVersionById(workspaceSlug.toString(), pageId, versionId);
|
||||
}}
|
||||
handleRestore={manuallyUpdateDescription}
|
||||
handleRestore={handleRestoreVersion}
|
||||
isOpen={isVersionsOverlayOpen}
|
||||
onClose={handleCloseVersionsOverlay}
|
||||
pageId={page.id ?? ""}
|
||||
restoreEnabled={isContentEditable}
|
||||
/>
|
||||
<PageEditorHeaderRoot
|
||||
editorRef={editorRef}
|
||||
readOnlyEditorRef={readOnlyEditorRef}
|
||||
editorReady={editorReady}
|
||||
readOnlyEditorReady={readOnlyEditorReady}
|
||||
editorRef={editorRef}
|
||||
handleDuplicatePage={handleDuplicatePage}
|
||||
handleSaveDescription={handleSaveDescription}
|
||||
hasConnectionFailed={hasConnectionFailed}
|
||||
markings={markings}
|
||||
page={page}
|
||||
sidePeekVisible={sidePeekVisible}
|
||||
readOnlyEditorReady={readOnlyEditorReady}
|
||||
readOnlyEditorRef={readOnlyEditorRef}
|
||||
setSidePeekVisible={(state) => setSidePeekVisible(state)}
|
||||
sidePeekVisible={sidePeekVisible}
|
||||
/>
|
||||
<WorkspacePageEditorBody
|
||||
editorRef={editorRef}
|
||||
handleConnectionStatus={(status) => 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}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<EditorRefApi>;
|
||||
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<Uint8Array>();
|
||||
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 ?? "<p></p>");
|
||||
const yDocBinaryString = proseMirrorJSONToBinaryString(contentJSON, "default", editorSchema);
|
||||
|
||||
try {
|
||||
await updateDescription(yDocBinaryString, pageDescription ?? "<p></p>");
|
||||
} 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() ?? "<p></p>";
|
||||
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 ?? "<p></p>");
|
||||
const yDocBinaryString = proseMirrorJSONToBinaryString(contentJSON, "default", editorSchema);
|
||||
|
||||
try {
|
||||
editorRef.current?.clearEditor(true);
|
||||
await updateDescription(yDocBinaryString, descriptionHTML ?? "<p></p>");
|
||||
await mutateDescriptionYJS();
|
||||
} catch (error) {
|
||||
console.log("error", error);
|
||||
}
|
||||
};
|
||||
|
||||
useAutoSave(handleSaveDescription);
|
||||
|
||||
return {
|
||||
handleDescriptionChange,
|
||||
isDescriptionReady,
|
||||
pageDescriptionYJS,
|
||||
handleSaveDescription,
|
||||
manuallyUpdateDescription,
|
||||
};
|
||||
};
|
||||
40
yarn.lock
40
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==
|
||||
|
||||
Reference in New Issue
Block a user