ultisuite-client/components/drive/richtext-document.tsx
R3D347HR4Y 8e420509a8
Some checks are pending
E2E / Playwright e2e (push) Waiting to run
imports docx 1
2026-06-10 00:27:44 +02:00

582 lines
19 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 { 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<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>
pageSetup?: DocPageSetup | null
}) => 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 [contentImportPending, setContentImportPending] = useState(session.importRequired)
const [importedContent, setImportedContent] = useState<Record<string, unknown> | null>(null)
const [documentPageSetup, setDocumentPageSetup] = useState<DocPageSetup | null>(
session.pageSetup ?? null
)
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 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<string, unknown> }
if (session.documentUrl) {
const res = await fetch(session.documentUrl)
if (!res.ok) return
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 || !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<string, unknown>)
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<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,
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<string, unknown>; 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<string, unknown>; pageSetup?: DocPageSetup }
} else {
const blob = await apiClient.getBlob(driveDownloadApiPath(session.canonicalPath))
parsed = JSON.parse(await blob.text()) as { content?: Record<string, unknown>; 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 (
<div className="flex h-full items-center justify-center p-6 text-sm text-destructive">
Collaboration indisponible : {collabError}
</div>
)
}
if (!editorEnabled || !editor) {
const statusText =
contentImportPending && !importDone
? "Import du document…"
: 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={activePageFormatId}
onPageFormatChange={handlePageFormatChange}
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={activePageFormatId}
onPageFormatChange={handlePageFormatChange}
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}
pageLayout={pageLayout}
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 pageLayout={pageLayout} pageCount={pageCount} />
) : null}
</div>
)
}