diff --git a/live/src/core/hocuspocus-server.ts b/live/src/core/hocuspocus-server.ts index 0aa411b9334..038bf30c3a2 100644 --- a/live/src/core/hocuspocus-server.ts +++ b/live/src/core/hocuspocus-server.ts @@ -4,6 +4,10 @@ import { v4 as uuidv4 } from "uuid"; import { handleAuthentication } from "@/core/lib/authentication.js"; // extensions import { getExtensions } from "@/core/extensions/index.js"; +import { + DocumentEventResponses, + TDocumentEventsServer, +} from "@plane/editor/lib"; export const getHocusPocusServer = async () => { const extensions = await getExtensions(); @@ -31,6 +35,12 @@ export const getHocusPocusServer = async () => { throw Error("Authentication unsuccessful!"); } }, + async onStateless({ payload, document }) { + const response = DocumentEventResponses[payload as TDocumentEventsServer]; + if (response) { + document.broadcastStateless(response); + } + }, extensions, debounce: 10000, }); diff --git a/packages/editor/src/core/components/editors/document/collaborative-editor.tsx b/packages/editor/src/core/components/editors/document/collaborative-editor.tsx index a008d5c60ba..1c09863f224 100644 --- a/packages/editor/src/core/components/editors/document/collaborative-editor.tsx +++ b/packages/editor/src/core/components/editors/document/collaborative-editor.tsx @@ -14,6 +14,7 @@ import { EditorRefApi, ICollaborativeDocumentEditor } from "@/types"; const CollaborativeDocumentEditor = (props: ICollaborativeDocumentEditor) => { const { + socket, aiHandler, containerClassName, disabledExtensions, @@ -47,6 +48,7 @@ const CollaborativeDocumentEditor = (props: ICollaborativeDocumentEditor) => { editorClassName, embedHandler, extensions, + socket, fileHandler, forwardedRef, handleEditorReady, diff --git a/packages/editor/src/core/extensions/custom-image/components/image-block.tsx b/packages/editor/src/core/extensions/custom-image/components/image-block.tsx index 4793d0cda9d..67647c1f656 100644 --- a/packages/editor/src/core/extensions/custom-image/components/image-block.tsx +++ b/packages/editor/src/core/extensions/custom-image/components/image-block.tsx @@ -116,7 +116,6 @@ export const CustomImageBlock: React.FC = (props) => { height: `${Math.round(initialHeight)}px` satisfies Pixel, aspectRatio: aspectRatioCalculated, }; - setSize(initialComputedSize); updateAttributesSafely( initialComputedSize, diff --git a/packages/editor/src/core/helpers/document-events.ts b/packages/editor/src/core/helpers/document-events.ts new file mode 100644 index 00000000000..649471f1cd5 --- /dev/null +++ b/packages/editor/src/core/helpers/document-events.ts @@ -0,0 +1,6 @@ +export enum DocumentEventResponses { + Lock = "locked", + Unlock = "unlocked", + Archive = "archived", + Unarchive = "unarchived", +} diff --git a/packages/editor/src/core/hooks/use-collaborative-editor.ts b/packages/editor/src/core/hooks/use-collaborative-editor.ts index 5a004bff284..ae24cbc07db 100644 --- a/packages/editor/src/core/hooks/use-collaborative-editor.ts +++ b/packages/editor/src/core/hooks/use-collaborative-editor.ts @@ -1,4 +1,4 @@ -import { useEffect, useLayoutEffect, useMemo, useState } from "react"; +import { useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"; import { HocuspocusProvider } from "@hocuspocus/provider"; import Collaboration from "@tiptap/extension-collaboration"; import { IndexeddbPersistence } from "y-indexeddb"; @@ -28,6 +28,7 @@ export const useCollaborativeEditor = (props: TCollaborativeEditorProps) => { serverHandler, tabIndex, user, + socket, } = props; // states const [hasServerConnectionFailed, setHasServerConnectionFailed] = useState(false); @@ -36,11 +37,12 @@ export const useCollaborativeEditor = (props: TCollaborativeEditorProps) => { const provider = useMemo( () => new HocuspocusProvider({ + websocketProvider: socket, name: id, - parameters: realtimeConfig.queryParams, + // parameters: realtimeConfig.queryParams, // using user id as a token to verify the user on the server token: user.id, - url: realtimeConfig.url, + // url: realtimeConfig.url, onAuthenticationFailed: () => { serverHandler?.onServerError?.(); setHasServerConnectionFailed(true); @@ -57,14 +59,15 @@ export const useCollaborativeEditor = (props: TCollaborativeEditorProps) => { [id, realtimeConfig, serverHandler, user.id] ); - // destroy and disconnect connection on unmount - useEffect( - () => () => { - provider.destroy(); - provider.disconnect(); - }, - [provider] - ); + // // destroy and disconnect connection on unmount + // useEffect( + // () => () => { + // provider.destroy(); + // provider.disconnect(); + // }, + // [provider] + // ); + // indexed db integration for offline support useLayoutEffect(() => { const localProvider = new IndexeddbPersistence(id, provider.document); diff --git a/packages/editor/src/core/hooks/use-editor.ts b/packages/editor/src/core/hooks/use-editor.ts index beee9c929d0..2c9f64d950b 100644 --- a/packages/editor/src/core/hooks/use-editor.ts +++ b/packages/editor/src/core/hooks/use-editor.ts @@ -16,7 +16,14 @@ import { IMarking, scrollSummary } from "@/helpers/scroll-to-node"; // props import { CoreEditorProps } from "@/props"; // types -import { EditorRefApi, IMentionHighlight, IMentionSuggestion, TEditorCommands, TFileHandler } from "@/types"; +import type { + TDocumentEventsServer, + EditorRefApi, + IMentionHighlight, + IMentionSuggestion, + TEditorCommands, + TFileHandler, +} from "@/types"; export interface CustomEditorProps { editorClassName: string; @@ -55,9 +62,9 @@ export const useEditor = (props: CustomEditorProps) => { mentionHandler, onChange, placeholder, - provider, tabIndex, value, + provider, } = props; // states @@ -236,7 +243,7 @@ export const useEditor = (props: CustomEditorProps) => { if (empty) return null; const nodesArray: string[] = []; - state.doc.nodesBetween(from, to, (node, pos, parent) => { + state.doc.nodesBetween(from, to, (node, _pos, parent) => { if (parent === state.doc && editorRef.current) { const serializer = DOMSerializer.fromSchema(editorRef.current?.schema); const dom = serializer.serializeNode(node); @@ -277,6 +284,8 @@ export const useEditor = (props: CustomEditorProps) => { if (!document) return; Y.applyUpdate(document, value); }, + emitRealTimeUpdate: (message: TDocumentEventsServer) => provider?.sendStateless(message), + listenToRealTimeUpdate: () => provider, }), [editorRef, savedSelection] ); diff --git a/packages/editor/src/core/hooks/use-read-only-collaborative-editor.ts b/packages/editor/src/core/hooks/use-read-only-collaborative-editor.ts index 9fa73c3ecb1..7e8de6bdfde 100644 --- a/packages/editor/src/core/hooks/use-read-only-collaborative-editor.ts +++ b/packages/editor/src/core/hooks/use-read-only-collaborative-editor.ts @@ -47,8 +47,9 @@ export const useReadOnlyCollaborativeEditor = (props: TReadOnlyCollaborativeEdit }, onSynced: () => setHasServerSynced(true), }), - [id, realtimeConfig, user.id] + [id, realtimeConfig, serverHandler, user.id] ); + // destroy and disconnect connection on unmount useEffect( () => () => { diff --git a/packages/editor/src/core/hooks/use-read-only-editor.ts b/packages/editor/src/core/hooks/use-read-only-editor.ts index 23ce023adcd..e79edc3a2c3 100644 --- a/packages/editor/src/core/hooks/use-read-only-editor.ts +++ b/packages/editor/src/core/hooks/use-read-only-editor.ts @@ -11,7 +11,7 @@ import { IMarking, scrollSummary } from "@/helpers/scroll-to-node"; // props import { CoreReadOnlyEditorProps } from "@/props"; // types -import { EditorReadOnlyRefApi, IMentionHighlight, TFileHandler } from "@/types"; +import { EditorReadOnlyRefApi, IMentionHighlight, TDocumentEventsServer, TFileHandler } from "@/types"; interface CustomReadOnlyEditorProps { initialValue?: string; @@ -117,6 +117,8 @@ export const useReadOnlyEditor = (props: CustomReadOnlyEditorProps) => { editorRef.current?.off("update"); }; }, + emitRealTimeUpdate: (message: TDocumentEventsServer) => provider?.sendStateless(message), + listenToRealTimeUpdate: () => provider, getHeadings: () => editorRef?.current?.storage.headingList.headings, })); diff --git a/packages/editor/src/core/types/collaboration.ts b/packages/editor/src/core/types/collaboration.ts index 60721a5a662..062c70dcee8 100644 --- a/packages/editor/src/core/types/collaboration.ts +++ b/packages/editor/src/core/types/collaboration.ts @@ -13,6 +13,7 @@ import { TRealtimeConfig, TUserDetails, } from "@/types"; +import { HocuspocusProviderWebsocket } from "@hocuspocus/provider"; export type TServerHandler = { onConnect?: () => void; @@ -41,6 +42,7 @@ export type TCollaborativeEditorProps = TCollaborativeEditorHookProps & { forwardedRef?: React.MutableRefObject; placeholder?: string | ((isFocused: boolean, value: string) => string); tabIndex?: number; + socket?: HocuspocusProviderWebsocket; }; export type TReadOnlyCollaborativeEditorProps = TCollaborativeEditorHookProps & { diff --git a/packages/editor/src/core/types/document-events.ts b/packages/editor/src/core/types/document-events.ts new file mode 100644 index 00000000000..8bfeaab3537 --- /dev/null +++ b/packages/editor/src/core/types/document-events.ts @@ -0,0 +1,4 @@ +import { DocumentEventResponses } from "@/helpers/document-events"; + +export type TDocumentEventsServer = keyof typeof DocumentEventResponses; +export type TDocumentEventsClient = `${DocumentEventResponses}`; diff --git a/packages/editor/src/core/types/editor.ts b/packages/editor/src/core/types/editor.ts index 31b315c1ca2..c7de2c14e42 100644 --- a/packages/editor/src/core/types/editor.ts +++ b/packages/editor/src/core/types/editor.ts @@ -14,7 +14,11 @@ import { TFileHandler, TNonColorEditorCommands, TServerHandler, + TDocumentEventsServer, } from "@/types"; + +import { HocuspocusProvider, HocuspocusProviderWebsocket } from "@hocuspocus/provider"; + // editor refs export type EditorReadOnlyRefApi = { getMarkDown: () => string; @@ -31,8 +35,8 @@ export type EditorReadOnlyRefApi = { paragraphs: number; words: number; }; - onHeadingChange: (callback: (headings: IMarking[]) => void) => () => void; - getHeadings: () => IMarking[]; + emitRealTimeUpdate: (message: TDocumentEventsServer) => void; + listenToRealTimeUpdate: () => HocuspocusProvider; }; export interface EditorRefApi extends EditorReadOnlyRefApi { @@ -101,6 +105,7 @@ export interface ICollaborativeDocumentEditor realtimeConfig: TRealtimeConfig; serverHandler?: TServerHandler; user: TUserDetails; + socket: HocuspocusProviderWebsocket; } // read only editor props diff --git a/packages/editor/src/core/types/index.ts b/packages/editor/src/core/types/index.ts index 8da9ed276e5..1e5a276463d 100644 --- a/packages/editor/src/core/types/index.ts +++ b/packages/editor/src/core/types/index.ts @@ -8,3 +8,4 @@ export * from "./image"; export * from "./mention-suggestion"; export * from "./slash-commands-suggestion"; export * from "@/plane-editor/types"; +export * from "./document-events"; diff --git a/packages/editor/src/lib.ts b/packages/editor/src/lib.ts index e14c40127fb..a2e3bb2328e 100644 --- a/packages/editor/src/lib.ts +++ b/packages/editor/src/lib.ts @@ -1 +1,3 @@ export * from "@/extensions/core-without-props"; +export * from "@/helpers/document-events"; +export * from "@/types/document-events"; diff --git a/packages/ui/src/icons/index.ts b/packages/ui/src/icons/index.ts index e857dc1a493..f22f8be181a 100644 --- a/packages/ui/src/icons/index.ts +++ b/packages/ui/src/icons/index.ts @@ -1,3 +1,4 @@ +export type { ISvgIcons } from "./type"; export * from "./cycle"; export * from "./module"; export * from "./state"; @@ -31,4 +32,4 @@ export * from "./favorite-folder-icon"; export * from "./planned-icon"; export * from "./in-progress-icon"; export * from "./done-icon"; -export * from "./pending-icon"; +export * from "./pending-icon"; \ No newline at end of file diff --git a/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/pages/(detail)/[pageId]/page.tsx b/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/pages/(detail)/[pageId]/page.tsx index e9debb2bcf4..2d9182d3831 100644 --- a/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/pages/(detail)/[pageId]/page.tsx +++ b/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/pages/(detail)/[pageId]/page.tsx @@ -1,5 +1,6 @@ "use client"; +import { HocuspocusProviderWebsocket } from "@hocuspocus/provider"; import { observer } from "mobx-react"; import Link from "next/link"; import { useParams } from "next/navigation"; diff --git a/web/core/components/pages/editor/editor-body.tsx b/web/core/components/pages/editor/editor-body.tsx index 37ce3ac48ec..3a48b62b7e7 100644 --- a/web/core/components/pages/editor/editor-body.tsx +++ b/web/core/components/pages/editor/editor-body.tsx @@ -1,4 +1,5 @@ -import { useCallback, useMemo } from "react"; +import { useCallback, useMemo, useState } from "react"; +import { HocuspocusProviderWebsocket } from "@hocuspocus/provider"; import { observer } from "mobx-react"; import { useParams } from "next/navigation"; // document-editor @@ -35,6 +36,7 @@ import { useIssueEmbed } from "@/plane-web/hooks/use-issue-embed"; import { FileService } from "@/services/file.service"; // store import { IPage } from "@/store/pages/page"; +import { getSocketConnection } from "./socket"; // services init const fileService = new FileService(); @@ -123,7 +125,7 @@ export const PageEditorBody: React.FC = observer((props) => { onConnect: handleServerConnect, onServerError: handleServerError, }), - [] + [handleServerConnect, handleServerError] ); const realtimeConfig: TRealtimeConfig | undefined = useMemo(() => { @@ -151,6 +153,9 @@ export const PageEditorBody: React.FC = observer((props) => { if (pageId === undefined || !realtimeConfig) return ; + const socket = useMemo(() => getSocketConnection(realtimeConfig, currentUser?.id), [realtimeConfig]); + console.log("socket connection", socket); + return (
= observer((props) => {
{isContentEditable ? ( = observer((props) => { } = page; // states const [isExportModalOpen, setIsExportModalOpen] = useState(false); + // currentUserAction local state to track if the current action is being processed, a + // local action is basically the action performed by the current user to avoid double operations + const [currentUserAction, setCurrentUserAction] = useState(null); // store hooks const { workspaceSlug, projectId } = useParams(); // page filters @@ -51,48 +65,138 @@ export const PageOptionsDropdown: React.FC = observer((props) => { // update query params const { updateQueryParams } = useQueryParams(); - const handleArchivePage = async () => - await archive().catch(() => - setToast({ - type: TOAST_TYPE.ERROR, - title: "Error!", - message: "Page could not be archived. Please try again later.", - }) - ); + const handleArchivePage = useCallback( + async (isPerformedByCurrentUser: boolean = true) => { + await archive() + .then(() => { + if (isPerformedByCurrentUser) { + setCurrentUserAction("archived"); + } + }) + .catch(() => { + setToast({ + type: TOAST_TYPE.ERROR, + title: "Error!", + message: "Page could not be archived. Please try again later.", + }); + }); + }, + [archive] + ); + + const handleRestorePage = useCallback( + async (isPerformedByCurrentUser: boolean = true) => { + await restore() + .then(() => { + if (isPerformedByCurrentUser) { + setCurrentUserAction("unarchived"); + } + }) + .catch(() => + setToast({ + type: TOAST_TYPE.ERROR, + title: "Error!", + message: "Page could not be restored. Please try again later.", + }) + ); + }, + [restore] + ); + + const handleLockPage = useCallback( + async (isPerformedByCurrentUser: boolean = true) => { + await lock() + .then(() => { + if (isPerformedByCurrentUser) { + setCurrentUserAction("locked"); + } + }) + .catch(() => + setToast({ + type: TOAST_TYPE.ERROR, + title: "Error!", + message: "Page could not be locked. Please try again later.", + }) + ); + }, + [lock] + ); + + const handleUnlockPage = useCallback( + async (isPerformedByCurrentUser: boolean = true) => { + await unlock() + .then(() => { + if (isPerformedByCurrentUser) { + setCurrentUserAction("unlocked"); + } + }) + .catch(() => + setToast({ + type: TOAST_TYPE.ERROR, + title: "Error!", + message: "Page could not be unlocked. Please try again later.", + }) + ); + }, + [unlock] + ); + + // this is for the emitting real time updates for the current user's action + useEffect(() => { + if (currentUserAction === "archived") { + editorRef?.emitRealTimeUpdate("Archive"); + } + if (currentUserAction === "unarchived") { + editorRef?.emitRealTimeUpdate("Unarchive"); + } + if (currentUserAction === "locked") { + editorRef?.emitRealTimeUpdate("Lock"); + } + if (currentUserAction === "unlocked") { + editorRef?.emitRealTimeUpdate("Unlock"); + } + }, [currentUserAction, editorRef]); + + // this is for listening to real time updates from the live server for remote + // users' actions + useEffect(() => { + const provider = editorRef?.listenToRealTimeUpdate(); + + const handleStatelessMessage = (message: { payload: TDocumentEventsClient }) => { + if (currentUserAction === message.payload) { + setCurrentUserAction(null); + return; + } - const handleRestorePage = async () => - await restore().catch(() => - setToast({ - type: TOAST_TYPE.ERROR, - title: "Error!", - message: "Page could not be restored. Please try again later.", - }) - ); + switch (message.payload) { + case "locked": + handleLockPage(false); + break; + case "unlocked": + handleUnlockPage(false); + break; + case "archived": + handleArchivePage(false); + break; + case "unarchived": + handleRestorePage(false); + break; + } + }; - const handleLockPage = async () => - await lock().catch(() => - setToast({ - type: TOAST_TYPE.ERROR, - title: "Error!", - message: "Page could not be locked. Please try again later.", - }) - ); + provider?.on("stateless", handleStatelessMessage); - const handleUnlockPage = async () => - await unlock().catch(() => - setToast({ - type: TOAST_TYPE.ERROR, - title: "Error!", - message: "Page could not be unlocked. Please try again later.", - }) - ); + return () => { + provider?.off("stateless", handleStatelessMessage); + }; + }, [editorRef, currentUserAction, handleArchivePage, handleRestorePage, handleLockPage, handleUnlockPage]); // menu items list const MENU_ITEMS: { key: string; action: () => void; label: string; - icon: React.FC; + icon: LucideIcon | React.FC; shouldRender: boolean; }[] = [ { diff --git a/web/core/components/pages/editor/socket.ts b/web/core/components/pages/editor/socket.ts new file mode 100644 index 00000000000..8d43cdfcaa1 --- /dev/null +++ b/web/core/components/pages/editor/socket.ts @@ -0,0 +1,27 @@ +import { HocuspocusProviderWebsocket } from "@hocuspocus/provider"; +import { TRealtimeConfig } from "@plane/editor"; + +function stringifyConfig(config: TRealtimeConfig) { + const sortedEntries = Object.entries(config).sort(([keyA], [keyB]) => keyA.localeCompare(keyB)); + return JSON.stringify(sortedEntries); +} + +const socketUrlMap = new Map(); + +export const getSocketConnection = (realtimeConfig: TRealtimeConfig) => { + const configKey = stringifyConfig(realtimeConfig); + + console.log("socketUrlMap", socketUrlMap); + if (socketUrlMap.has(configKey)) { + console.log("existing socket returned"); + return socketUrlMap.get(configKey); + } else { + console.log("new socket returned"); + const socket = new HocuspocusProviderWebsocket({ + url: realtimeConfig.url, + parameters: realtimeConfig.queryParams, + }); + socketUrlMap.set(configKey, socket); + return socket; + } +};