"use client" import { useCallback, useEffect, useMemo, useRef, useState } from "react" import { useEditor, EditorContent } from "@tiptap/react" import { HocuspocusProvider } from "@hocuspocus/provider" import * as Y from "yjs" import { buildRichTextExtensions, RICHTEXT_EDITOR_CLASS } from "@/lib/drive/richtext-extensions" import type { RichTextSaveStatus, RichTextSessionResponse } from "@/lib/drive/richtext-types" import { apiClient } from "@/lib/api/client" import { driveDownloadApiPath } from "@/lib/api/drive-download" import { importFileToTipTap } from "@/lib/drive/richtext-import" import { RichTextToolbar } from "@/components/drive/richtext-toolbar" const SAVE_DEBOUNCE_MS = 2000 /** Align with Hocuspocus store debounce + buffer */ const COLLAB_SAVE_IDLE_MS = 2000 export function RichTextDocumentEditor({ session, mode, userName, userColor, onSaveStatus, fetchSourceBytes, importApi, }: { session: RichTextSessionResponse mode: "edit" | "view" userName: string userColor: string onSaveStatus?: (status: RichTextSaveStatus) => void fetchSourceBytes?: (path: string) => Promise importApi?: (body: { source_path: string; content: Record }) => Promise }) { const editable = mode === "edit" const collaboration = session.collaboration && Boolean(session.wsUrl && session.token) const ydocRef = useRef(null) if (collaboration && !ydocRef.current) { ydocRef.current = new Y.Doc() } const ydoc = collaboration ? ydocRef.current : null const [provider, setProvider] = useState(null) const [collabSynced, setCollabSynced] = useState(false) const [collabError, setCollabError] = useState(null) const [importDone, setImportDone] = useState(!session.importRequired) const saveTimer = useRef | null>(null) const saveIdleTimer = useRef | null>(null) const markCollabDirty = useCallback(() => { onSaveStatus?.("saving") if (saveIdleTimer.current) clearTimeout(saveIdleTimer.current) saveIdleTimer.current = setTimeout(() => { onSaveStatus?.("saved") }, COLLAB_SAVE_IDLE_MS) }, [onSaveStatus]) useEffect(() => { return () => { if (saveTimer.current) clearTimeout(saveTimer.current) if (saveIdleTimer.current) clearTimeout(saveIdleTimer.current) } }, []) useEffect(() => { if (!session.importRequired || importDone) return let cancelled = false void (async () => { onSaveStatus?.("saving") try { const source = session.sourcePath || session.canonicalPath const buf = fetchSourceBytes ? await fetchSourceBytes(source) : await (await apiClient.getBlob(driveDownloadApiPath(source))).arrayBuffer() const content = await importFileToTipTap(source.split("/").pop() ?? "file.docx", buf) if (cancelled) return const payload = { source_path: source, content } if (importApi) { await importApi(payload) } else { await apiClient.post("/richtext/import", payload) } if (!cancelled) { setImportDone(true) onSaveStatus?.("saved") } } catch { if (!cancelled) onSaveStatus?.("error") } })() return () => { cancelled = true } }, [session, importDone, fetchSourceBytes, importApi, onSaveStatus]) useEffect(() => { if (!collaboration || !ydoc || !importDone) return setCollabSynced(false) setCollabError(null) const p = new HocuspocusProvider({ url: session.wsUrl, name: session.roomId, token: session.token, document: ydoc, sessionAwareness: false, onSynced: () => setCollabSynced(true), onAuthenticationFailed: ({ reason }) => { setCollabError(reason ?? "Authentification collaboration refusée") setCollabSynced(false) }, }) setProvider(p) return () => { p.destroy() setProvider(null) setCollabSynced(false) } }, [collaboration, importDone, session.roomId, session.token, session.wsUrl, ydoc]) const scheduleSave = useCallback( (json: Record) => { if (!editable || collaboration) return if (saveTimer.current) clearTimeout(saveTimer.current) onSaveStatus?.("saving") saveTimer.current = setTimeout(() => { const doc = { schemaVersion: 1, editor: "tiptap", content: json } const body = JSON.stringify(doc) const savePromise = session.saveUrl ? fetch(session.saveUrl, { method: "PUT", headers: { "Content-Type": "application/json" }, body, }).then((res) => { if (!res.ok) throw new Error("save failed") }) : apiClient.put("/richtext/save", { path: session.canonicalPath, document: json }) void savePromise .then(() => onSaveStatus?.("saved")) .catch(() => onSaveStatus?.("error")) }, SAVE_DEBOUNCE_MS) }, [collaboration, editable, onSaveStatus, session.canonicalPath, session.saveUrl] ) const collabReady = !collaboration || (Boolean(provider) && collabSynced) const editorEnabled = importDone && collabReady const extensions = useMemo( () => buildRichTextExtensions({ collaboration: collaboration && ydoc ? { document: ydoc } : undefined, collaborationCaret: collaboration && provider ? { provider, user: { name: userName, color: userColor } } : undefined, editable, }), [collaboration, ydoc, provider, userName, userColor, editable] ) const editor = useEditor( { immediatelyRender: false, editable, extensions, editorProps: { attributes: { class: RICHTEXT_EDITOR_CLASS } }, onUpdate: ({ editor: ed }) => { if (collaboration) { markCollabDirty() return } scheduleSave(ed.getJSON() as Record) }, }, [editorEnabled, extensions, collaboration, markCollabDirty, scheduleSave] ) useEffect(() => { if (!editor || collaboration || !importDone || session.importRequired) return let cancelled = false void (async () => { try { let parsed: { content?: Record } if (session.documentUrl) { const res = await fetch(session.documentUrl) if (!res.ok) throw new Error("load failed") parsed = JSON.parse(await res.text()) as { content?: Record } } else { const blob = await apiClient.getBlob(driveDownloadApiPath(session.canonicalPath)) parsed = JSON.parse(await blob.text()) as { content?: Record } } if (!cancelled && parsed.content) editor.commands.setContent(parsed.content) } catch { /* blank */ } })() return () => { cancelled = true } }, [editor, collaboration, importDone, session.canonicalPath, session.documentUrl, session.importRequired]) if (collabError) { return (
Collaboration indisponible : {collabError}
) } if (!editorEnabled || !editor) { const statusText = session.importRequired && !importDone ? "Import du document…" : collaboration && !collabSynced ? "Connexion à la collaboration…" : "Connexion…" return (
{statusText}
) } return (
{editable ? : null}
) }