import "@/features/editor/styles/index.css"; import React, { useCallback, useEffect, useMemo, useRef, useState, } from "react"; import { IndexeddbPersistence } from "y-indexeddb"; import * as Y from "yjs"; import { HocuspocusProvider, onStatusParameters, WebSocketStatus, HocuspocusProviderWebsocket, onSyncedParameters, } from "@hocuspocus/provider"; import { Editor, EditorContent, EditorProvider, useEditor, useEditorState, } 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 AudioMenu from "@/features/editor/components/audio/audio-menu.tsx"; import PdfMenu from "@/features/editor/components/pdf/pdf-menu.tsx"; import SubpagesMenu from "@/features/editor/components/subpages/subpages-menu.tsx"; import { handleFileDrop, handlePaste, } from "@/features/editor/components/common/editor-paste-handler.tsx"; import ExcalidrawMenu from "./components/excalidraw/excalidraw-menu"; import DrawioMenu from "./components/drawio/drawio-menu"; import { useCollabToken } from "@/features/auth/queries/auth-query.tsx"; import SearchAndReplaceDialog from "@/features/editor/components/search-and-replace/search-and-replace-dialog.tsx"; import { useDebouncedCallback, useDocumentVisibility } from "@mantine/hooks"; import { useIdle } from "@/hooks/use-idle.ts"; import { queryClient } from "@/main.tsx"; import { IPage } from "@/features/page/types/page.types.ts"; import { useParams } from "react-router-dom"; import { extractPageSlugId } from "@/lib"; import { FIVE_MINUTES } from "@/lib/constants.ts"; import { PageEditMode } from "@/features/user/types/user.types.ts"; import { jwtDecode } from "jwt-decode"; import { searchSpotlight } from "@/features/search/constants.ts"; import { useEditorScroll } from "./hooks/use-editor-scroll"; import { EditorAiMenu } from "@/ee/ai/components/editor/ai-menu/ai-menu"; import { EditorLinkMenu } from "@/features/editor/components/link/link-menu"; import ColumnsMenu from "@/features/editor/components/columns/columns-menu.tsx"; interface PageEditorProps { pageId: string; editable: boolean; content: any; } export default function PageEditor({ pageId, editable, content, }: PageEditorProps) { const collaborationURL = useCollaborationUrl(); const isComponentMounted = useRef(false); const editorRef = useRef(null); useEffect(() => { isComponentMounted.current = true; }, []); const [currentUser] = useAtom(currentUserAtom); const [, setEditor] = useAtom(pageEditorAtom); const [, setAsideState] = useAtom(asideStateAtom); const [, setActiveCommentId] = useAtom(activeCommentIdAtom); const [showCommentPopup, setShowCommentPopup] = useAtom(showCommentPopupAtom); const [isLocalSynced, setIsLocalSynced] = useState(false); const [isRemoteSynced, setIsRemoteSynced] = useState(false); const [yjsConnectionStatus, setYjsConnectionStatus] = useAtom( yjsConnectionStatusAtom, ); const menuContainerRef = useRef(null); const { data: collabQuery, refetch: refetchCollabToken } = useCollabToken(); const { isIdle, resetIdle } = useIdle(FIVE_MINUTES, { initialState: false }); const documentState = useDocumentVisibility(); const { pageSlug } = useParams(); const slugId = extractPageSlugId(pageSlug); const userPageEditMode = currentUser?.user?.settings?.preferences?.pageEditMode ?? PageEditMode.Edit; const canScroll = useCallback( () => Boolean(isComponentMounted.current && editorRef.current), [isComponentMounted], ); const { handleScrollTo } = useEditorScroll({ canScroll }); // Providers only created once per pageId const providersRef = useRef<{ local: IndexeddbPersistence; remote: HocuspocusProvider; socket: HocuspocusProviderWebsocket; } | null>(null); const [providersReady, setProvidersReady] = useState(false); useEffect(() => { if (!providersRef.current) { const documentName = `page.${pageId}`; const ydoc = new Y.Doc(); const local = new IndexeddbPersistence(documentName, ydoc); const socket = new HocuspocusProviderWebsocket({ url: collaborationURL, }); const onLocalSyncedHandler = () => { setIsLocalSynced(true); }; const onStatusHandler = (event: onStatusParameters) => { setYjsConnectionStatus(event.status); }; const onSyncedHandler = (event: onSyncedParameters) => { setIsRemoteSynced(event.state); }; const onAuthenticationFailedHandler = () => { const payload = jwtDecode(collabQuery?.token); const now = Date.now().valueOf() / 1000; const isTokenExpired = now >= payload.exp; if (isTokenExpired) { refetchCollabToken().then((result) => { if (result.data?.token) { socket.disconnect(); setTimeout(() => { remote.configuration.token = result.data.token; socket.connect(); }, 100); } }); } }; const remote = new HocuspocusProvider({ websocketProvider: socket, name: documentName, document: ydoc, token: collabQuery?.token, onAuthenticationFailed: onAuthenticationFailedHandler, onStatus: onStatusHandler, onSynced: onSyncedHandler, }); local.on("synced", onLocalSyncedHandler); providersRef.current = { socket, local, remote }; setProvidersReady(true); } else { setProvidersReady(true); } // Only destroy on final unmount return () => { providersRef.current?.socket.destroy(); providersRef.current?.remote.destroy(); providersRef.current?.local.destroy(); providersRef.current = null; }; }, [pageId]); // Only connect/disconnect on tab/idle, not destroy useEffect(() => { if (!providersReady || !providersRef.current) return; const socket = providersRef.current.socket; if ( isIdle && documentState === "hidden" && yjsConnectionStatus === WebSocketStatus.Connected ) { socket.disconnect(); return; } if ( documentState === "visible" && yjsConnectionStatus === WebSocketStatus.Disconnected ) { resetIdle(); socket.connect(); } }, [isIdle, documentState, providersReady, resetIdle]); // Attach here, to make sure the connection gets properly established providersRef.current?.remote.attach(); const extensions = useMemo(() => { if (!providersReady || !providersRef.current || !currentUser?.user) { return mainExtensions; } const remoteProvider = providersRef.current.remote; return [ ...mainExtensions, ...collabExtensions(remoteProvider, currentUser?.user), ]; }, [providersReady, currentUser?.user]); const editor = useEditor( { extensions, editable, immediatelyRender: true, shouldRerenderOnTransaction: false, editorProps: { scrollThreshold: 80, scrollMargin: 80, handleDOMEvents: { keydown: (_view, event) => { if ((event.ctrlKey || event.metaKey) && event.code === "KeyS") { event.preventDefault(); return true; } if ((event.ctrlKey || event.metaKey) && event.code === "KeyK") { searchSpotlight.open(); return true; } if (["ArrowUp", "ArrowDown", "Enter"].includes(event.key)) { const slashCommand = document.querySelector("#slash-command"); if (slashCommand) { return true; } } if ( [ "ArrowUp", "ArrowDown", "ArrowLeft", "ArrowRight", "Enter", ].includes(event.key) ) { const emojiCommand = document.querySelector("#emoji-command"); if (emojiCommand) { return true; } } }, }, handlePaste: (_view, event) => { if (!editorRef.current) return false; return handlePaste( editorRef.current, event, pageId, currentUser?.user.id, ); }, handleDrop: (_view, event, _slice, moved) => { if (!editorRef.current) return false; return handleFileDrop(editorRef.current, event, moved, pageId); }, }, onCreate({ editor }) { if (editor) { // @ts-ignore setEditor(editor); // @ts-ignore editor.storage.pageId = pageId; handleScrollTo(editor); editorRef.current = editor; } }, onUpdate({ editor }) { if (editor.isEmpty) return; const editorJson = editor.getJSON(); //update local page cache to reduce flickers debouncedUpdateContent(editorJson); }, }, [pageId, editable, extensions], ); const editorIsEditable = useEditorState({ editor, selector: (ctx) => { return ctx.editor?.isEditable ?? false; }, }); const debouncedUpdateContent = useDebouncedCallback((newContent: any) => { const pageData = queryClient.getQueryData(["pages", slugId]); if (pageData) { queryClient.setQueryData(["pages", slugId], { ...pageData, content: newContent, updatedAt: new Date(), }); } }, 3000); const handleActiveCommentEvent = (event) => { const { commentId, resolved } = event.detail; if (resolved) { return; } setActiveCommentId(commentId); setAsideState({ tab: "comments", isAsideOpen: true }); //wait if aside is closed setTimeout(() => { const selector = `div[data-comment-id="${commentId}"]`; const commentElement = document.querySelector(selector); commentElement?.scrollIntoView({ behavior: "smooth", block: "center" }); }, 400); }; useEffect(() => { document.addEventListener("ACTIVE_COMMENT_EVENT", handleActiveCommentEvent); return () => { document.removeEventListener( "ACTIVE_COMMENT_EVENT", handleActiveCommentEvent, ); }; }, []); useEffect(() => { setActiveCommentId(null); setShowCommentPopup(false); setAsideState({ tab: "", isAsideOpen: false }); }, [pageId]); const isSynced = isLocalSynced && isRemoteSynced; useEffect(() => { const timeout = setTimeout(() => { if (yjsConnectionStatus === WebSocketStatus.Connecting || !isSynced) { setYjsConnectionStatus(WebSocketStatus.Disconnected); } }, 7500); return () => clearTimeout(timeout); }, [yjsConnectionStatus, isSynced]); useEffect(() => { // Only honor user default page edit mode preference and permissions if (editor) { if (userPageEditMode && editable) { if (userPageEditMode === PageEditMode.Edit) { editor.setEditable(true); } else if (userPageEditMode === PageEditMode.Read) { editor.setEditable(false); } } else { editor.setEditable(false); } } }, [userPageEditMode, editor, editable]); const hasConnectedOnceRef = useRef(false); const [showStatic, setShowStatic] = useState(true); useEffect(() => { if ( !hasConnectedOnceRef.current && yjsConnectionStatus === WebSocketStatus.Connected && isSynced ) { hasConnectedOnceRef.current = true; setShowStatic(false); } }, [yjsConnectionStatus, isSynced]); if (showStatic) { return ( ); } return (
{editor && ( )} {editor && editorIsEditable && (
)} {showCommentPopup && }
editor.commands.focus("end")} style={{ paddingBottom: "20vh" }} >
); }