313 lines
9.0 KiB
TypeScript
313 lines
9.0 KiB
TypeScript
"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<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 () => {
|
|
await import("@excalidraw/excalidraw/index.css")
|
|
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'é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>
|
|
)
|
|
}
|