"use client" import dynamic from "next/dynamic" import { useCallback, useEffect, useMemo, useState } from "react" import { HocuspocusProvider } from "@hocuspocus/provider" import type { ExcalidrawImperativeAPI } from "@excalidraw/excalidraw/types" import type { ExcalidrawElement } from "@excalidraw/excalidraw/element/types" import type { AppState, BinaryFiles } from "@excalidraw/excalidraw/types" import { ExcalidrawBinding, yjsToExcalidraw } from "@mizuka-wu/y-excalidraw" import * as Y from "yjs" import { apiClient } from "@/lib/api/client" import { driveDownloadApiPath } from "@/lib/api/drive-download" import { UltidrawChrome } from "@/components/drive/ultidraw-chrome" import { DocsLoadingSplash } from "@/components/drive/richtext/docs-loading-splash" import { resolveUltidrawDocumentLoadingPhase, type DocsLoadingPhase, } from "@/lib/drive/docs-loading-phase" import { seedYExcalidrawElements } from "@/lib/drive/ultidraw-seed" import type { UltidrawSessionResponse, UltidrawSaveStatus } from "@/lib/drive/ultidraw-types" import { useCollabPresence } from "@/lib/drive/use-collab-presence" type ParsedDrawFile = { elements: ExcalidrawElement[] appState: Partial files: BinaryFiles } function parseDrawFile(raw: string): ParsedDrawFile { const data = JSON.parse(raw) as { elements?: ExcalidrawElement[] appState?: Partial files?: BinaryFiles } return { elements: data.elements ?? [], appState: data.appState ?? {}, files: data.files ?? {}, } } function seedYdocIfEmpty(ydoc: Y.Doc, parsed: ParsedDrawFile): void { const yElements = ydoc.getArray>("elements") if (yElements.length > 0) return if (parsed.elements.length === 0) return seedYExcalidrawElements(ydoc, parsed.elements, parsed.files) } const ExcalidrawCanvas = dynamic( async () => { await import("@excalidraw/excalidraw/index.css") const { Excalidraw } = await import("@excalidraw/excalidraw") return Excalidraw }, { ssr: false, loading: () => (
Chargement de l'éditeur…
), } ) export type UltidrawChromeProps = { title: string onRename?: (next: string) => Promise renameDisabled?: boolean backHref?: string backLabel?: string showBack?: boolean shares?: import("@/lib/api/types").DriveShare[] onShareClick?: () => void showShare?: boolean showAccount?: boolean } export function UltidrawDocumentEditor({ session, mode, userName, userColor, chrome, fetchDocument, deferSplash = false, onLoadingChange, }: { session: UltidrawSessionResponse mode: "edit" | "view" userName: string userColor: string chrome: UltidrawChromeProps fetchDocument?: (path: string) => Promise deferSplash?: boolean onLoadingChange?: (loading: boolean, phase: DocsLoadingPhase) => void }) { const editable = mode === "edit" && session.mode !== "view" const collaboration = session.collaboration && Boolean(session.wsUrl && session.token) const [parsed, setParsed] = useState(null) const [loadError, setLoadError] = useState(null) const [ydoc, setYdoc] = useState(null) const [provider, setProvider] = useState(null) const [collabSynced, setCollabSynced] = useState(false) const [collabError, setCollabError] = useState(null) const [api, setApi] = useState(null) const [binding, setBinding] = useState(null) const [saveStatus, setSaveStatus] = useState("idle") const handleExcalidrawApi = useCallback((next: ExcalidrawImperativeAPI) => { setApi(next) }, []) useEffect(() => { let cancelled = false setParsed(null) setLoadError(null) void (async () => { try { let text: string if (fetchDocument) { text = await fetchDocument(session.canonicalPath) } else if (session.documentUrl) { const res = await fetch(session.documentUrl) if (!res.ok) throw new Error("document introuvable") text = await res.text() } else { const blob = await apiClient.getBlob(driveDownloadApiPath(session.canonicalPath)) text = await blob.text() } if (!cancelled) setParsed(parseDrawFile(text)) } catch (e) { if (!cancelled) { setLoadError(e instanceof Error ? e.message : "Impossible de charger le dessin") } } })() return () => { cancelled = true } }, [fetchDocument, session.canonicalPath, session.documentUrl]) useEffect(() => { if (!collaboration) { setYdoc(null) return } const doc = new Y.Doc() setYdoc(doc) return () => { doc.destroy() setYdoc(null) } }, [collaboration, session.roomId]) useEffect(() => { if (!collaboration || !ydoc || !parsed) return setCollabSynced(false) setCollabError(null) setApi(null) setBinding(null) const p = new HocuspocusProvider({ url: session.wsUrl, name: session.roomId, token: session.token, document: ydoc, onSynced: () => { seedYdocIfEmpty(ydoc, parsed) setCollabSynced(true) setSaveStatus("saved") }, onAuthenticationFailed: ({ reason }) => { setCollabError(reason ?? "Authentification collaboration refusée") setCollabSynced(false) }, }) p.awareness?.setLocalStateField("user", { name: userName, color: userColor, }) p.on("status", (event: { status: string }) => { if (event.status === "connecting") setSaveStatus("saving") if (event.status === "connected") setSaveStatus("saved") }) setProvider(p) return () => { p.destroy() setProvider(null) setCollabSynced(false) setApi(null) setBinding(null) } }, [collaboration, parsed, session.roomId, session.token, session.wsUrl, userColor, userName, ydoc]) useEffect(() => { if (!api || !ydoc || !collaboration || !provider || !collabSynced) return const yElements = ydoc.getArray>("elements") const yAssets = ydoc.getMap("assets") const b = new ExcalidrawBinding( yElements, yAssets, api, provider.awareness ?? undefined ) setBinding(b) return () => { b.destroy() setBinding(null) } }, [api, collaboration, collabSynced, provider, ydoc]) const presenceUsers = useCollabPresence(provider, { name: userName, color: userColor }) const excalidrawInitialData = useMemo(() => { if (!parsed) return undefined if (collaboration && ydoc && collabSynced) { const elements = yjsToExcalidraw(ydoc.getArray("elements")) return { elements, appState: parsed.appState, files: parsed.files, } } if (!collaboration) { return { elements: parsed.elements, appState: parsed.appState, files: parsed.files, } } return undefined }, [collaboration, collabSynced, parsed, ydoc]) const collabReady = !collaboration || collabSynced const editorReady = parsed && collabReady && excalidrawInitialData const documentLoading = !editorReady const loadingPhase = resolveUltidrawDocumentLoadingPhase({ collaboration: Boolean(collaboration), collabSynced, }) useEffect(() => { if (!deferSplash) return onLoadingChange?.(documentLoading, loadingPhase) }, [deferSplash, documentLoading, loadingPhase, onLoadingChange]) if (loadError) { return (

{loadError}

) } if (!deferSplash && documentLoading) { return ( ) } if (deferSplash && documentLoading) { return null } if (collabError) { return (

{collabError}

) } return (
) }