ultisuite-client/components/drive/richtext-document.tsx
R3D347HR4Y cdff12490a
Some checks are pending
E2E / Playwright e2e (push) Waiting to run
hocuspocus
2026-06-09 14:31:07 +02:00

236 lines
7.8 KiB
TypeScript

"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<ArrayBuffer>
importApi?: (body: { source_path: string; content: Record<string, unknown> }) => Promise<void>
}) {
const editable = mode === "edit"
const collaboration = session.collaboration && Boolean(session.wsUrl && session.token)
const ydocRef = useRef<Y.Doc | null>(null)
if (collaboration && !ydocRef.current) {
ydocRef.current = new Y.Doc()
}
const ydoc = collaboration ? ydocRef.current : null
const [provider, setProvider] = useState<HocuspocusProvider | null>(null)
const [collabSynced, setCollabSynced] = useState(false)
const [collabError, setCollabError] = useState<string | null>(null)
const [importDone, setImportDone] = useState(!session.importRequired)
const saveTimer = useRef<ReturnType<typeof setTimeout> | null>(null)
const saveIdleTimer = useRef<ReturnType<typeof setTimeout> | 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<string, unknown>) => {
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<string, unknown>)
},
},
[editorEnabled, extensions, collaboration, markCollabDirty, scheduleSave]
)
useEffect(() => {
if (!editor || collaboration || !importDone || session.importRequired) return
let cancelled = false
void (async () => {
try {
let parsed: { content?: Record<string, unknown> }
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<string, unknown> }
} else {
const blob = await apiClient.getBlob(driveDownloadApiPath(session.canonicalPath))
parsed = JSON.parse(await blob.text()) as { content?: Record<string, unknown> }
}
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 (
<div className="flex h-full items-center justify-center p-6 text-sm text-destructive">
Collaboration indisponible : {collabError}
</div>
)
}
if (!editorEnabled || !editor) {
const statusText =
session.importRequired && !importDone
? "Import du document…"
: collaboration && !collabSynced
? "Connexion à la collaboration…"
: "Connexion…"
return (
<div className="flex h-full items-center justify-center text-sm text-muted-foreground">
{statusText}
</div>
)
}
return (
<div className="flex h-full min-h-0 flex-col">
{editable ? <RichTextToolbar editor={editor} /> : null}
<div className="min-h-0 flex-1 overflow-auto">
<EditorContent editor={editor} className="h-full" />
</div>
</div>
)
}