"use client" import { useCallback, useEffect, useMemo, useRef, useState } from "react" import type { ReactNode } from "react" import { useEditor, EditorContent } from "@tiptap/react" import { HocuspocusProvider } from "@hocuspocus/provider" import * as Y from "yjs" import { toast } from "sonner" import { DocsChrome } from "@/components/drive/richtext/docs-chrome" import { DocsPageView, DocsStatusBar } from "@/components/drive/richtext/docs-page-view" import { DocsToolbar } from "@/components/drive/richtext/docs-toolbar" import { buildRichTextExtensions, RICHTEXT_EDITOR_CLASS } from "@/lib/drive/richtext-extensions" import type { RichTextSaveStatus, RichTextSessionResponse } from "@/lib/drive/richtext-types" import type { DriveShare, DriveFileInfo } from "@/lib/api/types" import { useDocsEditMenu } from "@/lib/drive/use-docs-edit-menu" import { useDocsFileMenu } from "@/lib/drive/use-docs-file-menu" import { useDocsViewSettings } from "@/lib/drive/docs-view-settings" import { useCollabPresence } from "@/lib/drive/use-collab-presence" import { apiClient } from "@/lib/api/client" import { driveDownloadApiPath } from "@/lib/api/drive-download" import { buildPageSetupForFormat, buildPageSetupFromDraft, resolveDocumentPageLayout, type DocPageSetup, } from "@/lib/drive/doc-page-setup" import { readUserPageSetupDefaults } from "@/lib/drive/docs-page-defaults" import { isEmptyTipTapDoc } from "@/lib/drive/richtext-content" import { importFileToTipTap } from "@/lib/drive/richtext-import" import { isUltidocPath } from "@/lib/drive/richtext-formats" const SAVE_DEBOUNCE_MS = 2000 /** Align with Hocuspocus store debounce + buffer */ const COLLAB_SAVE_IDLE_MS = 2000 export type RichTextDocsChromeProps = { title: string onRename?: (next: string) => Promise renameDisabled?: boolean backHref?: string backLabel?: string showBack?: boolean shares?: DriveShare[] onShareClick?: () => void showShare?: boolean showAccount?: boolean trailing?: ReactNode moveFile?: DriveFileInfo onFileMoved?: (newPath: string) => void file?: DriveFileInfo onRenameRequest?: () => void renameSignal?: number } export function RichTextDocumentEditor({ session, mode, userName, userColor, onSaveStatus, fetchSourceBytes, importApi, chrome, }: { session: RichTextSessionResponse mode: "edit" | "view" userName: string userColor: string onSaveStatus?: (status: RichTextSaveStatus) => void fetchSourceBytes?: (path: string) => Promise importApi?: (body: { source_path: string content: Record pageSetup?: DocPageSetup | null }) => Promise chrome?: RichTextDocsChromeProps }) { 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 [contentImportPending, setContentImportPending] = useState(session.importRequired) const [importedContent, setImportedContent] = useState | null>(null) const [documentPageSetup, setDocumentPageSetup] = useState( session.pageSetup ?? null ) const [saveStatus, setSaveStatus] = useState("idle") const [pageCount, setPageCount] = useState(1) const saveTimer = useRef | null>(null) const saveIdleTimer = useRef | null>(null) const saveStatusRef = useRef("idle") const reloadAfterReimportRef = useRef(false) const { settings, setPageFormatId, setZoom, toggleSpellcheck, toggleChromeCollapsed } = useDocsViewSettings() const presenceUsers = useCollabPresence(provider, { name: userName, color: userColor }) const pageLayout = useMemo( () => resolveDocumentPageLayout(documentPageSetup, settings.pageFormatId), [documentPageSetup, settings.pageFormatId] ) const activePageFormatId = pageLayout.format.id const reportSaveStatus = useCallback( (status: RichTextSaveStatus) => { saveStatusRef.current = status setSaveStatus(status) onSaveStatus?.(status) }, [onSaveStatus] ) const persistPageSetup = useCallback( async (setup: DocPageSetup) => { if (!editable) return setDocumentPageSetup(setup) if (setup.formatId) setPageFormatId(setup.formatId) reportSaveStatus("saving") try { const body = JSON.stringify({ pageSetup: setup }) if (session.saveUrl) { const res = await fetch(session.saveUrl, { method: "PUT", headers: { "Content-Type": "application/json" }, body, }) if (!res.ok) throw new Error("save failed") } else { await apiClient.put("/richtext/save", { path: session.canonicalPath, pageSetup: setup, }) } reportSaveStatus("saved") } catch { reportSaveStatus("error") } }, [editable, reportSaveStatus, session.canonicalPath, session.saveUrl, setPageFormatId] ) const handlePageFormatChange = useCallback( (formatId: typeof settings.pageFormatId) => { void persistPageSetup(buildPageSetupForFormat(formatId, documentPageSetup)) }, [documentPageSetup, persistPageSetup, settings.pageFormatId] ) useEffect(() => { if (session.pageSetup) setDocumentPageSetup(session.pageSetup) }, [session.pageSetup]) useEffect(() => { if (session.pageSetup) return setDocumentPageSetup((current) => { if (current) return current const setup = buildPageSetupFromDraft( readUserPageSetupDefaults(settings.pageFormatId), null ) if (setup.formatId) setPageFormatId(setup.formatId) return setup }) }, [session.pageSetup, settings.pageFormatId, setPageFormatId]) const handlePageCountChange = useCallback((count: number) => { setPageCount(count) }, []) const handlePurgeSidecarAndReimport = useCallback(async () => { if (!editable) return const source = session.sourcePath || session.canonicalPath if (!source || isUltidocPath(source)) { toast.error("Aucun fichier source à réimporter") return } if ( !window.confirm( "Supprimer le sidecar (.ultidoc.json) et réimporter le document source ? Cette action est temporaire (dev)." ) ) { return } reportSaveStatus("saving") try { await apiClient.delete(`/drive/files${session.canonicalPath}`) setDocumentPageSetup(null) setImportedContent(null) reloadAfterReimportRef.current = collaboration setImportDone(false) setContentImportPending(true) toast.success("Sidecar purgé — réimport en cours…") } catch { reportSaveStatus("error") toast.error("Impossible de purger le sidecar") } }, [collaboration, editable, reportSaveStatus, session.canonicalPath, session.sourcePath]) const markCollabDirty = useCallback(() => { if (saveStatusRef.current !== "saving") { reportSaveStatus("saving") } if (saveIdleTimer.current) clearTimeout(saveIdleTimer.current) saveIdleTimer.current = setTimeout(() => { reportSaveStatus("saved") }, COLLAB_SAVE_IDLE_MS) }, [reportSaveStatus]) useEffect(() => { return () => { if (saveTimer.current) clearTimeout(saveTimer.current) if (saveIdleTimer.current) clearTimeout(saveIdleTimer.current) } }, []) useEffect(() => { setContentImportPending(session.importRequired) setImportDone(!session.importRequired) }, [session.importRequired, session.canonicalPath]) useEffect(() => { if (session.importRequired || !session.sourcePath) return let cancelled = false void (async () => { try { let parsed: { content?: Record } if (session.documentUrl) { const res = await fetch(session.documentUrl) if (!res.ok) return 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 || !isEmptyTipTapDoc(parsed.content)) return setContentImportPending(true) setImportDone(false) } catch { /* keep current import flags */ } })() return () => { cancelled = true } }, [session.canonicalPath, session.documentUrl, session.importRequired, session.sourcePath]) useEffect(() => { if (!contentImportPending || importDone) return let cancelled = false void (async () => { reportSaveStatus("saving") try { const source = session.sourcePath || session.canonicalPath const buf = fetchSourceBytes ? await fetchSourceBytes(source) : await (await apiClient.getBlob(driveDownloadApiPath(source))).arrayBuffer() const imported = await importFileToTipTap(source.split("/").pop() ?? "file.docx", buf) if (cancelled) return const payload = { source_path: source, content: imported.content, pageSetup: imported.pageSetup ?? undefined, } if (importApi) { await importApi(payload) } else { await apiClient.post("/richtext/import", payload) } if (!cancelled) { setImportedContent(imported.content as Record) if (imported.pageSetup) setDocumentPageSetup(imported.pageSetup) setContentImportPending(false) setImportDone(true) reportSaveStatus("saved") if (reloadAfterReimportRef.current) { reloadAfterReimportRef.current = false window.location.reload() return } } } catch { if (!cancelled) reportSaveStatus("error") } })() return () => { cancelled = true } }, [contentImportPending, importDone, session, fetchSourceBytes, importApi, reportSaveStatus]) 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) if (saveStatusRef.current !== "saving") { reportSaveStatus("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(() => reportSaveStatus("saved")) .catch(() => reportSaveStatus("error")) }, SAVE_DEBOUNCE_MS) }, [collaboration, editable, reportSaveStatus, 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 || editor.isDestroyed) return const syncSpellcheck = () => { if (editor.isDestroyed || !editor.isInitialized) return const dom = editor.view.dom dom.spellcheck = settings.spellcheck if (settings.spellcheck) { dom.setAttribute("spellcheck", "true") dom.removeAttribute("autocorrect") dom.removeAttribute("autocapitalize") } else { dom.setAttribute("spellcheck", "false") dom.setAttribute("autocorrect", "off") dom.setAttribute("autocapitalize", "off") } } syncSpellcheck() editor.on("create", syncSpellcheck) return () => { editor.off("create", syncSpellcheck) } }, [editor, settings.spellcheck]) const fileMenu = useDocsFileMenu({ file: chrome?.file, editor, pageSetup: documentPageSetup, fallbackFormatId: settings.pageFormatId, onPageSetupApply: (setup) => void persistPageSetup(setup), onPurgeSidecarAndReimport: () => void handlePurgeSidecarAndReimport(), onShareClick: chrome?.onShareClick, onRenameRequest: chrome?.onRenameRequest, onFileMoved: chrome?.onFileMoved, disabled: !editable, }) const editMenu = useDocsEditMenu({ editor, disabled: !editable, }) const chromeProps = chrome ? { ...chrome, fileMenuActions: fileMenu.actions, fileMenuDialogs: fileMenu.dialogs, fileMenuDisabled: fileMenu.disabled, editMenuActions: editMenu.actions, editMenuState: editMenu.state, editMenuDisabled: editMenu.disabled, } : undefined useEffect(() => { if (!editor || collaboration || !importDone) return if (importedContent) { editor.commands.setContent(importedContent) setImportedContent(null) return } if (contentImportPending) return let cancelled = false void (async () => { try { let parsed: { content?: Record; pageSetup?: DocPageSetup } 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; pageSetup?: DocPageSetup } } else { const blob = await apiClient.getBlob(driveDownloadApiPath(session.canonicalPath)) parsed = JSON.parse(await blob.text()) as { content?: Record; pageSetup?: DocPageSetup } } if (!cancelled && parsed.pageSetup) { setDocumentPageSetup(parsed.pageSetup) } else if (!cancelled) { setDocumentPageSetup( (current) => current ?? buildPageSetupFromDraft(readUserPageSetupDefaults(settings.pageFormatId), null) ) } if (!cancelled && parsed.content && !isEmptyTipTapDoc(parsed.content)) { editor.commands.setContent(parsed.content) } else if (!cancelled && session.sourcePath) { setContentImportPending(true) setImportDone(false) } } catch { /* blank */ } })() return () => { cancelled = true } }, [ editor, collaboration, importDone, importedContent, contentImportPending, session.canonicalPath, session.documentUrl, session.sourcePath, settings.pageFormatId, ]) if (collabError) { return (
Collaboration indisponible : {collabError}
) } if (!editorEnabled || !editor) { const statusText = contentImportPending && !importDone ? "Import du document…" : session.importRequired && !importDone ? "Import du document…" : collaboration && !collabSynced ? "Connexion à la collaboration…" : "Connexion…" return (
{chromeProps && !settings.chromeCollapsed ? ( ) : null}
{statusText}
) } return (
{chromeProps && !settings.chromeCollapsed ? ( ) : null} {editable ? ( ) : null} {chrome ? ( ) : (
)} {chrome ? ( ) : null}
) }