index.tsx 7.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179
  1. 'use client';
  2. import { useRef, useState } from 'react';
  3. import { motion, AnimatePresence } from 'motion/react';
  4. import { Eraser, History, Minimize2, PencilLine, RotateCcw } from 'lucide-react';
  5. import { WhiteboardCanvas } from './whiteboard-canvas';
  6. import type { WhiteboardCanvasHandle } from './whiteboard-canvas';
  7. import { WhiteboardHistory } from './whiteboard-history';
  8. import { useStageStore } from '@/lib/store';
  9. import { useCanvasStore } from '@/lib/store/canvas';
  10. import { useWhiteboardHistoryStore } from '@/lib/store/whiteboard-history';
  11. import { createStageAPI } from '@/lib/api/stage-api';
  12. import { toast } from 'sonner';
  13. import { useI18n } from '@/lib/hooks/use-i18n';
  14. interface WhiteboardProps {
  15. readonly isOpen: boolean;
  16. readonly onClose: () => void;
  17. }
  18. /**
  19. * Whiteboard component
  20. */
  21. export function Whiteboard({ isOpen, onClose }: WhiteboardProps) {
  22. const { t } = useI18n();
  23. const stage = useStageStore.use.stage();
  24. const isClearing = useCanvasStore.use.whiteboardClearing();
  25. const clearingRef = useRef(false);
  26. const [historyOpen, setHistoryOpen] = useState(false);
  27. const [viewModified, setViewModified] = useState(false);
  28. const canvasRef = useRef<WhiteboardCanvasHandle>(null);
  29. const snapshotCount = useWhiteboardHistoryStore((s) => s.snapshots.length);
  30. // Get element count for indicator
  31. const whiteboard = stage?.whiteboard?.[0];
  32. const elementCount = whiteboard?.elements?.length || 0;
  33. const stageAPI = createStageAPI(useStageStore);
  34. const handleClear = async () => {
  35. if (!whiteboard || elementCount === 0 || clearingRef.current) return;
  36. clearingRef.current = true;
  37. // Save snapshot before clearing
  38. if (whiteboard.elements && whiteboard.elements.length > 0) {
  39. useWhiteboardHistoryStore.getState().pushSnapshot(whiteboard.elements);
  40. }
  41. // Trigger cascade exit animation
  42. useCanvasStore.getState().setWhiteboardClearing(true);
  43. // Wait for cascade: base 380ms + 55ms per element, capped at 1400ms
  44. const animMs = Math.min(380 + elementCount * 55, 1400);
  45. await new Promise((resolve) => setTimeout(resolve, animMs));
  46. // Actually remove elements
  47. const result = stageAPI.whiteboard.delete(whiteboard.id);
  48. useCanvasStore.getState().setWhiteboardClearing(false);
  49. clearingRef.current = false;
  50. if (result.success) {
  51. toast.success(t('whiteboard.clearSuccess'));
  52. } else {
  53. toast.error(t('whiteboard.clearError') + result.error);
  54. }
  55. };
  56. return (
  57. <>
  58. {/* Main Whiteboard Overlay */}
  59. <AnimatePresence>
  60. {isOpen && (
  61. <motion.div
  62. initial={{ opacity: 0, scale: 0.92, y: 30 }}
  63. animate={{
  64. opacity: 1,
  65. scale: 1,
  66. y: 0,
  67. transition: {
  68. type: 'spring',
  69. stiffness: 120,
  70. damping: 18,
  71. mass: 1.2,
  72. },
  73. }}
  74. exit={{
  75. opacity: 0,
  76. scale: 0.95,
  77. y: 16,
  78. transition: { duration: 0.5, ease: [0.4, 0, 0.2, 1] },
  79. }}
  80. 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"
  81. >
  82. {/* Header */}
  83. <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">
  84. <div className="flex items-center gap-3">
  85. <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">
  86. <PencilLine className="w-4 h-4" />
  87. </div>
  88. <span className="font-bold text-gray-800 dark:text-gray-200 tracking-tight">
  89. {t('whiteboard.title')}
  90. </span>
  91. </div>
  92. <div className="flex items-center gap-2">
  93. <AnimatePresence>
  94. {viewModified && (
  95. <motion.button
  96. type="button"
  97. initial={{ opacity: 0, scale: 0.8 }}
  98. animate={{ opacity: 1, scale: 1 }}
  99. exit={{ opacity: 0, scale: 0.8 }}
  100. transition={{ duration: 0.15 }}
  101. onClick={() => canvasRef.current?.resetView()}
  102. whileTap={{ scale: 0.9 }}
  103. 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"
  104. title={t('whiteboard.resetView')}
  105. >
  106. <RotateCcw className="w-4 h-4" />
  107. </motion.button>
  108. )}
  109. </AnimatePresence>
  110. <motion.button
  111. type="button"
  112. onClick={handleClear}
  113. disabled={isClearing || elementCount === 0}
  114. whileTap={{ scale: 0.9 }}
  115. 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"
  116. title={t('whiteboard.clear')}
  117. >
  118. <motion.div
  119. animate={isClearing ? { rotate: [0, -15, 15, -10, 10, 0] } : { rotate: 0 }}
  120. transition={
  121. isClearing ? { duration: 0.5, ease: 'easeInOut' } : { duration: 0.2 }
  122. }
  123. >
  124. <Eraser className="w-4 h-4" />
  125. </motion.div>
  126. </motion.button>
  127. {/* History button + popover wrapper */}
  128. <div className="relative">
  129. <motion.button
  130. type="button"
  131. onClick={() => setHistoryOpen(!historyOpen)}
  132. whileTap={{ scale: 0.9 }}
  133. 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"
  134. title={t('whiteboard.history')}
  135. >
  136. <History className="w-4 h-4" />
  137. {snapshotCount > 0 && (
  138. <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">
  139. {snapshotCount}
  140. </span>
  141. )}
  142. </motion.button>
  143. <WhiteboardHistory isOpen={historyOpen} onClose={() => setHistoryOpen(false)} />
  144. </div>
  145. <div className="w-px h-4 bg-gray-200 dark:bg-gray-700 mx-1" />
  146. <button
  147. type="button"
  148. onClick={onClose}
  149. 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"
  150. title={t('whiteboard.minimize')}
  151. >
  152. <Minimize2 className="w-5 h-5" />
  153. </button>
  154. </div>
  155. </div>
  156. {/* Whiteboard Content Area */}
  157. <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">
  158. <WhiteboardCanvas ref={canvasRef} onViewModifiedChange={setViewModified} />
  159. </div>
  160. </motion.div>
  161. )}
  162. </AnimatePresence>
  163. </>
  164. );
  165. }