|
|
|
|
| import { useRef, useState } from 'react'; |
| import { motion, AnimatePresence } from 'motion/react'; |
| import { Eraser, History, Minimize2, PencilLine, RotateCcw } from 'lucide-react'; |
| import { WhiteboardCanvas } from './whiteboard-canvas'; |
| import type { WhiteboardCanvasHandle } from './whiteboard-canvas'; |
| import { WhiteboardHistory } from './whiteboard-history'; |
| import { useStageStore } from '@/lib/store'; |
| import { useCanvasStore } from '@/lib/store/canvas'; |
| import { useWhiteboardHistoryStore } from '@/lib/store/whiteboard-history'; |
| import { createStageAPI } from '@/lib/api/stage-api'; |
| import { toast } from 'sonner'; |
| import { useI18n } from '@/lib/hooks/use-i18n'; |
|
|
| interface WhiteboardProps { |
| readonly isOpen: boolean; |
| readonly onClose: () => void; |
| } |
|
|
| |
| |
| |
| export function Whiteboard({ isOpen, onClose }: WhiteboardProps) { |
| const { t } = useI18n(); |
| const stage = useStageStore.use.stage(); |
| const isClearing = useCanvasStore.use.whiteboardClearing(); |
| const clearingRef = useRef(false); |
| const [historyOpen, setHistoryOpen] = useState(false); |
| const [viewModified, setViewModified] = useState(false); |
| const canvasRef = useRef<WhiteboardCanvasHandle>(null); |
| const snapshotCount = useWhiteboardHistoryStore((s) => s.snapshots.length); |
|
|
| |
| const whiteboard = stage?.whiteboard?.[0]; |
| const elementCount = whiteboard?.elements?.length || 0; |
|
|
| const stageAPI = createStageAPI(useStageStore); |
|
|
| const handleClear = async () => { |
| if (!whiteboard || elementCount === 0 || clearingRef.current) return; |
| clearingRef.current = true; |
|
|
| |
| if (whiteboard.elements && whiteboard.elements.length > 0) { |
| useWhiteboardHistoryStore.getState().pushSnapshot(whiteboard.elements); |
| } |
|
|
| |
| useCanvasStore.getState().setWhiteboardClearing(true); |
|
|
| |
| const animMs = Math.min(380 + elementCount * 55, 1400); |
| await new Promise((resolve) => setTimeout(resolve, animMs)); |
|
|
| |
| const result = stageAPI.whiteboard.delete(whiteboard.id); |
| useCanvasStore.getState().setWhiteboardClearing(false); |
| clearingRef.current = false; |
|
|
| if (result.success) { |
| toast.success(t('whiteboard.clearSuccess')); |
| } else { |
| toast.error(t('whiteboard.clearError') + result.error); |
| } |
| }; |
|
|
| return ( |
| <> |
| {/* Main Whiteboard Overlay */} |
| <AnimatePresence> |
| {isOpen && ( |
| <motion.div |
| initial={{ opacity: 0, scale: 0.92, y: 30 }} |
| animate={{ |
| opacity: 1, |
| scale: 1, |
| y: 0, |
| transition: { |
| type: 'spring', |
| stiffness: 120, |
| damping: 18, |
| mass: 1.2, |
| }, |
| }} |
| exit={{ |
| opacity: 0, |
| scale: 0.95, |
| y: 16, |
| transition: { duration: 0.5, ease: [0.4, 0, 0.2, 1] }, |
| }} |
| className="absolute inset-4 pointer-events-auto bg-white/95 dark:bg-gray-800/95 backdrop-blur-2xl rounded-3xl shadow-[0_32px_80px_-20px_rgba(0,0,0,0.25)] border-2 border-purple-200/60 dark:border-purple-700/60 flex flex-col overflow-hidden z-[120] ring-4 ring-purple-100/40 dark:ring-purple-800/40" |
| > |
| {/* Header */} |
| <div className="h-14 px-6 border-b border-gray-100 dark:border-gray-700 flex items-center justify-between shrink-0 bg-white/50 dark:bg-gray-800/50"> |
| <div className="flex items-center gap-3"> |
| <div className="w-8 h-8 rounded-xl bg-purple-100 dark:bg-purple-900/30 flex items-center justify-center text-purple-600 dark:text-purple-400"> |
| <PencilLine className="w-4 h-4" /> |
| </div> |
| <span className="font-bold text-gray-800 dark:text-gray-200 tracking-tight"> |
| {t('whiteboard.title')} |
| </span> |
| </div> |
| |
| <div className="flex items-center gap-2"> |
| <AnimatePresence> |
| {viewModified && ( |
| <motion.button |
| type="button" |
| initial={{ opacity: 0, scale: 0.8 }} |
| animate={{ opacity: 1, scale: 1 }} |
| exit={{ opacity: 0, scale: 0.8 }} |
| transition={{ duration: 0.15 }} |
| onClick={() => canvasRef.current?.resetView()} |
| whileTap={{ scale: 0.9 }} |
| className="p-2 text-gray-400 dark:text-gray-500 hover:text-purple-500 dark:hover:text-purple-400 hover:bg-purple-50 dark:hover:bg-purple-900/20 rounded-lg transition-colors" |
| title={t('whiteboard.resetView')} |
| > |
| <RotateCcw className="w-4 h-4" /> |
| </motion.button> |
| )} |
| </AnimatePresence> |
| <motion.button |
| type="button" |
| onClick={handleClear} |
| disabled={isClearing || elementCount === 0} |
| whileTap={{ scale: 0.9 }} |
| className="p-2 text-gray-400 dark:text-gray-500 hover:text-red-500 dark:hover:text-red-400 hover:bg-red-50 dark:hover:bg-red-900/20 rounded-lg transition-colors disabled:opacity-40 disabled:pointer-events-none" |
| title={t('whiteboard.clear')} |
| > |
| <motion.div |
| animate={isClearing ? { rotate: [0, -15, 15, -10, 10, 0] } : { rotate: 0 }} |
| transition={ |
| isClearing ? { duration: 0.5, ease: 'easeInOut' } : { duration: 0.2 } |
| } |
| > |
| <Eraser className="w-4 h-4" /> |
| </motion.div> |
| </motion.button> |
| {/* History button + popover wrapper */} |
| <div className="relative"> |
| <motion.button |
| type="button" |
| onClick={() => setHistoryOpen(!historyOpen)} |
| whileTap={{ scale: 0.9 }} |
| className="relative p-2 text-gray-400 dark:text-gray-500 hover:text-purple-500 dark:hover:text-purple-400 hover:bg-purple-50 dark:hover:bg-purple-900/20 rounded-lg transition-colors" |
| title={t('whiteboard.history')} |
| > |
| <History className="w-4 h-4" /> |
| {snapshotCount > 0 && ( |
| <span className="absolute -top-0.5 -right-0.5 min-w-[16px] h-4 px-1 rounded-full bg-purple-500 text-white text-[10px] font-bold flex items-center justify-center"> |
| {snapshotCount} |
| </span> |
| )} |
| </motion.button> |
| <WhiteboardHistory isOpen={historyOpen} onClose={() => setHistoryOpen(false)} /> |
| </div> |
| <div className="w-px h-4 bg-gray-200 dark:bg-gray-700 mx-1" /> |
| <button |
| type="button" |
| onClick={onClose} |
| className="p-2 text-gray-400 dark:text-gray-500 hover:text-gray-800 dark:hover:text-gray-200 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-lg transition-colors" |
| title={t('whiteboard.minimize')} |
| > |
| <Minimize2 className="w-5 h-5" /> |
| </button> |
| </div> |
| </div> |
| |
| {/* Whiteboard Content Area */} |
| <div className="flex-1 relative bg-[radial-gradient(#e5e7eb_1px,transparent_1px)] dark:bg-[radial-gradient(#374151_1px,transparent_1px)] [background-size:24px_24px] overflow-hidden"> |
| <WhiteboardCanvas ref={canvasRef} onViewModifiedChange={setViewModified} /> |
| </div> |
| </motion.div> |
| )} |
| </AnimatePresence> |
| </> |
| ); |
| } |
|
|