import "@/features/editor/styles/index.css"; import React, { useEffect, useLayoutEffect, useMemo, useRef, useState, } from "react"; import { IndexeddbPersistence } from "y-indexeddb"; import * as Y from "yjs"; import { HocuspocusProvider, WebSocketStatus } from "@hocuspocus/provider"; import { EditorContent, EditorProvider, useEditor } from "@tiptap/react"; import { collabExtensions, mainExtensions, } from "@/features/editor/extensions/extensions"; import { useAtom } from "jotai"; import useCollaborationUrl from "@/features/editor/hooks/use-collaboration-url"; import { currentUserAtom } from "@/features/user/atoms/current-user-atom"; import { pageEditorAtom, yjsConnectionStatusAtom, } from "@/features/editor/atoms/editor-atoms"; import { asideStateAtom } from "@/components/layouts/global/hooks/atoms/sidebar-atom"; import { activeCommentIdAtom, showCommentPopupAtom, } from "@/features/comment/atoms/comment-atom"; import CommentDialog from "@/features/comment/components/comment-dialog"; import { EditorBubbleMenu } from "@/features/editor/components/bubble-menu/bubble-menu"; import TableCellMenu from "@/features/editor/components/table/table-cell-menu.tsx"; import TableMenu from "@/features/editor/components/table/table-menu.tsx"; import ImageMenu from "@/features/editor/components/image/image-menu.tsx"; import CalloutMenu from "@/features/editor/components/callout/callout-menu.tsx"; import VideoMenu from "@/features/editor/components/video/video-menu.tsx"; import { handleFileDrop, handleFilePaste, } from "@/features/editor/components/common/file-upload-handler.tsx"; import LinkMenu from "@/features/editor/components/link/link-menu.tsx"; import ExcalidrawMenu from "./components/excalidraw/excalidraw-menu"; import DrawioMenu from "./components/drawio/drawio-menu"; import { useCollabToken } from "@/features/auth/queries/auth-query.tsx"; interface PageEditorProps { pageId: string; editable: boolean; content: any; } export default function PageEditor({ pageId, editable, content, }: PageEditorProps) { const collaborationURL = useCollaborationUrl(); const [currentUser] = useAtom(currentUserAtom); const [, setEditor] = useAtom(pageEditorAtom); const [, setAsideState] = useAtom(asideStateAtom); const [, setActiveCommentId] = useAtom(activeCommentIdAtom); const [showCommentPopup, setShowCommentPopup] = useAtom(showCommentPopupAtom); const ydoc = useMemo(() => new Y.Doc(), [pageId]); const [isLocalSynced, setLocalSynced] = useState(false); const [isRemoteSynced, setRemoteSynced] = useState(false); const [yjsConnectionStatus, setYjsConnectionStatus] = useAtom( yjsConnectionStatusAtom, ); const menuContainerRef = useRef(null); const documentName = `page.${pageId}`; const { data } = useCollabToken(); const localProvider = useMemo(() => { const provider = new IndexeddbPersistence(documentName, ydoc); provider.on("synced", () => { setLocalSynced(true); }); return provider; }, [pageId, ydoc, data?.token]); const remoteProvider = useMemo(() => { const provider = new HocuspocusProvider({ name: documentName, url: collaborationURL, document: ydoc, token: data?.token, connect: false, onStatus: (status) => { if (status.status === "connected") { setYjsConnectionStatus(status.status); } }, }); provider.on("synced", () => { setRemoteSynced(true); }); provider.on("disconnect", () => { setYjsConnectionStatus(WebSocketStatus.Disconnected); }); return provider; }, [ydoc, pageId, data?.token]); useLayoutEffect(() => { remoteProvider.connect(); return () => { setRemoteSynced(false); setLocalSynced(false); remoteProvider.destroy(); localProvider.destroy(); }; }, [remoteProvider, localProvider]); const extensions = [ ...mainExtensions, ...collabExtensions(remoteProvider, currentUser.user), ]; const editor = useEditor( { extensions, editable, immediatelyRender: true, editorProps: { scrollThreshold: 80, scrollMargin: 80, handleDOMEvents: { keydown: (_view, event) => { if (["ArrowUp", "ArrowDown", "Enter"].includes(event.key)) { const slashCommand = document.querySelector("#slash-command"); if (slashCommand) { return true; } } }, }, handlePaste: (view, event) => handleFilePaste(view, event, pageId), handleDrop: (view, event, _slice, moved) => handleFileDrop(view, event, moved, pageId), }, onCreate({ editor }) { if (editor) { // @ts-ignore setEditor(editor); editor.storage.pageId = pageId; } }, }, [pageId, editable, remoteProvider], ); const handleActiveCommentEvent = (event) => { const { commentId } = event.detail; setActiveCommentId(commentId); setAsideState({ tab: "comments", isAsideOpen: true }); const selector = `div[data-comment-id="${commentId}"]`; const commentElement = document.querySelector(selector); commentElement?.scrollIntoView(); }; useEffect(() => { document.addEventListener("ACTIVE_COMMENT_EVENT", handleActiveCommentEvent); return () => { document.removeEventListener( "ACTIVE_COMMENT_EVENT", handleActiveCommentEvent, ); }; }, []); useEffect(() => { setActiveCommentId(null); setShowCommentPopup(false); setAsideState({ tab: "", isAsideOpen: false }); }, [pageId]); useEffect(() => { if (editable) { if (yjsConnectionStatus === WebSocketStatus.Connected) { editor.setEditable(true); } else { // disable edits if connection fails editor.setEditable(false); } } }, [yjsConnectionStatus]); const isSynced = isLocalSynced && isRemoteSynced; return isSynced ? (