ultisuite-client/components/drive/richtext-document.tsx
R3D347HR4Y 79bb6193fc
Some checks are pending
E2E / Playwright e2e (push) Waiting to run
menus1
2026-06-09 18:27:10 +02:00

394 lines
13 KiB
TypeScript

"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 { 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 { importFileToTipTap } from "@/lib/drive/richtext-import"
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<void>
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<ArrayBuffer>
importApi?: (body: { source_path: string; content: Record<string, unknown> }) => Promise<void>
chrome?: RichTextDocsChromeProps
}) {
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 [saveStatus, setSaveStatus] = useState<RichTextSaveStatus>("idle")
const [pageCount, setPageCount] = useState(1)
const saveTimer = useRef<ReturnType<typeof setTimeout> | null>(null)
const saveIdleTimer = useRef<ReturnType<typeof setTimeout> | null>(null)
const saveStatusRef = useRef<RichTextSaveStatus>("idle")
const { settings, setPageFormatId, setZoom, toggleSpellcheck, toggleChromeCollapsed } =
useDocsViewSettings()
const presenceUsers = useCollabPresence(provider, { name: userName, color: userColor })
const reportSaveStatus = useCallback(
(status: RichTextSaveStatus) => {
saveStatusRef.current = status
setSaveStatus(status)
onSaveStatus?.(status)
},
[onSaveStatus]
)
const handlePageCountChange = useCallback((count: number) => {
setPageCount(count)
}, [])
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(() => {
if (!session.importRequired || 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 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)
reportSaveStatus("saved")
}
} catch {
if (!cancelled) reportSaveStatus("error")
}
})()
return () => {
cancelled = true
}
}, [session, importDone, 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<string, unknown>) => {
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<string, unknown>)
},
},
[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,
pageFormatId: settings.pageFormatId,
onPageFormatChange: setPageFormatId,
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 || 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 flex-col">
{chromeProps && !settings.chromeCollapsed ? (
<DocsChrome
{...chromeProps}
saveStatus={saveStatus}
presenceUsers={presenceUsers}
pageFormatId={settings.pageFormatId}
onPageFormatChange={setPageFormatId}
zoom={settings.zoom}
onZoomChange={setZoom}
/>
) : null}
<div className="flex flex-1 items-center justify-center text-sm text-muted-foreground">
{statusText}
</div>
</div>
)
}
return (
<div className="flex h-full min-h-0 flex-col bg-white dark:bg-background">
{chromeProps && !settings.chromeCollapsed ? (
<DocsChrome
{...chromeProps}
saveStatus={saveStatus}
presenceUsers={presenceUsers}
pageFormatId={settings.pageFormatId}
onPageFormatChange={setPageFormatId}
zoom={settings.zoom}
onZoomChange={setZoom}
/>
) : null}
{editable ? (
<DocsToolbar
editor={editor}
zoom={settings.zoom}
onZoomChange={setZoom}
spellcheck={settings.spellcheck}
onToggleSpellcheck={toggleSpellcheck}
showChromeToggle={Boolean(chrome)}
chromeCollapsed={settings.chromeCollapsed}
onToggleChromeCollapsed={toggleChromeCollapsed}
/>
) : null}
{chrome ? (
<DocsPageView
editor={editor}
pageFormatId={settings.pageFormatId}
zoom={settings.zoom}
editable={editable}
onPageCountChange={handlePageCountChange}
/>
) : (
<div className="min-h-0 flex-1 overflow-auto">
<EditorContent editor={editor} className="h-full" />
</div>
)}
{chrome ? (
<DocsStatusBar pageFormatId={settings.pageFormatId} pageCount={pageCount} />
) : null}
</div>
)
}