ultisuite-client/components/drive/ultidraw-document.tsx
R3D347HR4Y ad1370ea7e
Some checks are pending
E2E / Playwright e2e (push) Waiting to run
feat: enhance configuration and add new demo layouts
- Introduced turbopack alias for canvas in next.config.mjs.
- Updated package.json scripts for development and branding tasks.
- Added new dependencies for Tiptap extensions.
- Implemented new demo layouts for agenda, contacts, drive, and mail applications.
- Enhanced globals.css for improved theming and splash screen animations.
- Added OAuth callback handling for drive mounts.
- Updated layout components to integrate new demo shells and improve structure.
2026-06-12 19:10:24 +02:00

313 lines
9.0 KiB
TypeScript

"use client"
import "@/styles/excalidraw.css"
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<AppState>
files: BinaryFiles
}
function parseDrawFile(raw: string): ParsedDrawFile {
const data = JSON.parse(raw) as {
elements?: ExcalidrawElement[]
appState?: Partial<AppState>
files?: BinaryFiles
}
return {
elements: data.elements ?? [],
appState: data.appState ?? {},
files: data.files ?? {},
}
}
function seedYdocIfEmpty(ydoc: Y.Doc, parsed: ParsedDrawFile): void {
const yElements = ydoc.getArray<Y.Map<unknown>>("elements")
if (yElements.length > 0) return
if (parsed.elements.length === 0) return
seedYExcalidrawElements(ydoc, parsed.elements, parsed.files)
}
const ExcalidrawCanvas = dynamic(
async () => {
const { Excalidraw } = await import("@excalidraw/excalidraw")
return Excalidraw
},
{
ssr: false,
loading: () => (
<div className="flex h-full min-h-[360px] items-center justify-center text-sm text-muted-foreground">
Chargement de l&apos;éditeur
</div>
),
}
)
export type UltidrawChromeProps = {
title: string
onRename?: (next: string) => Promise<void>
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<string>
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<ParsedDrawFile | null>(null)
const [loadError, setLoadError] = useState<string | null>(null)
const [ydoc, setYdoc] = useState<Y.Doc | null>(null)
const [provider, setProvider] = useState<HocuspocusProvider | null>(null)
const [collabSynced, setCollabSynced] = useState(false)
const [collabError, setCollabError] = useState<string | null>(null)
const [api, setApi] = useState<ExcalidrawImperativeAPI | null>(null)
const [binding, setBinding] = useState<ExcalidrawBinding | null>(null)
const [saveStatus, setSaveStatus] = useState<UltidrawSaveStatus>("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<Y.Map<unknown>>("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 (
<div className="flex h-full flex-col items-center justify-center gap-4 p-8">
<p className="text-sm text-muted-foreground">{loadError}</p>
</div>
)
}
if (!deferSplash && documentLoading) {
return (
<DocsLoadingSplash
phase={loadingPhase}
title={chrome.title}
/>
)
}
if (deferSplash && documentLoading) {
return null
}
if (collabError) {
return (
<div className="flex h-full flex-col items-center justify-center gap-4 p-8">
<p className="text-sm text-muted-foreground">{collabError}</p>
</div>
)
}
return (
<div className="flex h-full min-h-0 flex-col">
<UltidrawChrome
{...chrome}
saveStatus={collaboration ? saveStatus : "idle"}
presenceUsers={collaboration ? presenceUsers : []}
/>
<div className="relative min-h-0 flex-1">
<ExcalidrawCanvas
key={session.roomId}
excalidrawAPI={handleExcalidrawApi}
initialData={excalidrawInitialData}
langCode="fr-FR"
viewModeEnabled={!editable}
onPointerUpdate={binding?.onPointerUpdate}
UIOptions={{
canvasActions: {
changeViewBackgroundColor: true,
clearCanvas: editable,
export: false,
loadScene: false,
saveToActiveFile: false,
toggleTheme: true,
},
}}
/>
</div>
</div>
)
}