111 lines
3.6 KiB
TypeScript
111 lines
3.6 KiB
TypeScript
'use client'
|
|
|
|
import dynamic from 'next/dynamic'
|
|
import { useState, useEffect, useRef } from 'react'
|
|
import { toast } from 'sonner'
|
|
import type { ExcalidrawElement } from '@excalidraw/excalidraw/types/element/types'
|
|
import type { AppState, BinaryFiles } from '@excalidraw/excalidraw/types/types'
|
|
import '@excalidraw/excalidraw/index.css'
|
|
|
|
// Dynamic import with SSR disabled is REQUIRED for Excalidraw due to window dependencies
|
|
const Excalidraw = dynamic(
|
|
async () => (await import('@excalidraw/excalidraw')).Excalidraw,
|
|
{ ssr: false }
|
|
)
|
|
|
|
interface CanvasBoardProps {
|
|
initialData?: string
|
|
canvasId?: string
|
|
name: string
|
|
}
|
|
|
|
export function CanvasBoard({ initialData, canvasId, name }: CanvasBoardProps) {
|
|
const [isDarkMode, setIsDarkMode] = useState(false)
|
|
const [saveStatus, setSaveStatus] = useState<'saved' | 'saving' | 'error'>('saved')
|
|
const saveTimeoutRef = useRef<NodeJS.Timeout | null>(null)
|
|
const filesRef = useRef<BinaryFiles>({})
|
|
|
|
// Parse initial state safely (ONLY ON MOUNT to prevent Next.js revalidation infinite loops)
|
|
const [elements] = useState<readonly ExcalidrawElement[]>(() => {
|
|
if (initialData) {
|
|
try {
|
|
const parsed = JSON.parse(initialData)
|
|
if (parsed && Array.isArray(parsed)) {
|
|
return parsed
|
|
} else if (parsed && parsed.elements) {
|
|
// Restore binary files if present
|
|
if (parsed.files && typeof parsed.files === 'object') {
|
|
filesRef.current = parsed.files
|
|
}
|
|
return parsed.elements
|
|
}
|
|
} catch (e) {
|
|
console.error("[CanvasBoard] Failed to parse initial Excalidraw data:", e)
|
|
}
|
|
}
|
|
return []
|
|
})
|
|
|
|
// Detect dark mode from html class
|
|
useEffect(() => {
|
|
const checkDarkMode = () => {
|
|
const isDark = document.documentElement.classList.contains('dark')
|
|
setIsDarkMode(isDark)
|
|
}
|
|
|
|
checkDarkMode()
|
|
|
|
// Observer for theme changes
|
|
const observer = new MutationObserver(checkDarkMode)
|
|
observer.observe(document.documentElement, { attributes: true, attributeFilter: ['class'] })
|
|
|
|
return () => observer.disconnect()
|
|
}, [])
|
|
|
|
const handleChange = (
|
|
excalidrawElements: readonly ExcalidrawElement[],
|
|
appState: AppState,
|
|
files: BinaryFiles
|
|
) => {
|
|
// Keep files ref up to date so we can include them in the save payload
|
|
if (files) filesRef.current = files
|
|
|
|
if (saveTimeoutRef.current) clearTimeout(saveTimeoutRef.current)
|
|
|
|
setSaveStatus('saving')
|
|
saveTimeoutRef.current = setTimeout(async () => {
|
|
try {
|
|
// Save both elements and binary files so images persist across page changes
|
|
const snapshot = JSON.stringify({ elements: excalidrawElements, files: filesRef.current })
|
|
await fetch('/api/canvas', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ id: canvasId || null, name, data: snapshot })
|
|
})
|
|
setSaveStatus('saved')
|
|
} catch (e) {
|
|
console.error("[CanvasBoard] Save failure:", e)
|
|
setSaveStatus('error')
|
|
}
|
|
}, 2000)
|
|
}
|
|
|
|
return (
|
|
<div className="absolute inset-0 h-full w-full bg-slate-50 dark:bg-[#121212]" dir="ltr">
|
|
<Excalidraw
|
|
initialData={{ elements, files: filesRef.current }}
|
|
theme={isDarkMode ? "dark" : "light"}
|
|
onChange={handleChange}
|
|
libraryReturnUrl={typeof window !== 'undefined' ? window.location.origin + window.location.pathname + window.location.search : undefined}
|
|
UIOptions={{
|
|
canvasActions: {
|
|
loadScene: false,
|
|
saveToActiveFile: false,
|
|
clearCanvas: true
|
|
}
|
|
}}
|
|
/>
|
|
</div>
|
|
)
|
|
}
|