Merge branch 'preview' of github.com:makeplane/plane-ee into preview

This commit is contained in:
sriram veeraghanta
2024-09-03 18:56:41 +05:30
18 changed files with 414 additions and 293 deletions

View File

@@ -15,7 +15,7 @@
"root": ["./src"],
"alias": {
"@/core": "./src/core",
"@/plane-live": "./src/ce"
"@/plane-live": "./src/ee"
}
}
]

View File

@@ -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.`);
}
}

View File

@@ -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.`);
}
}

View File

@@ -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.`);
}
}

View 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;
}
};

View 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;
});
}
}

View File

@@ -1 +1 @@
export * from "../../ce/types/common.js"
export type TAdditionalDocumentTypes = "workspace_page"

View File

@@ -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";

View File

@@ -9,7 +9,7 @@
"baseUrl": ".",
"paths": {
"@/core/*": ["./src/core/*"],
"@/plane-live/*": ["./src/ce/*"]
"@/plane-live/*": ["./src/ee/*"]
},
"removeComments": true,
"esModuleInterop": true,

View File

@@ -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",

View 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,
});
};

View File

@@ -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;
};

View File

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

View File

@@ -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}
/>
</>
);

View File

@@ -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",

View File

@@ -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,

View File

@@ -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,
};
};

View File

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