394 lines
13 KiB
TypeScript
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>
|
|
)
|
|
}
|