canvas-area.tsx 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259
  1. 'use client';
  2. import { useCallback } from 'react';
  3. import { motion, AnimatePresence } from 'motion/react';
  4. import { Play } from 'lucide-react';
  5. import { cn } from '@/lib/utils';
  6. import { SceneRenderer } from '@/components/stage/scene-renderer';
  7. import { SceneProvider } from '@/lib/contexts/scene-context';
  8. import { Whiteboard } from '@/components/whiteboard';
  9. import { CanvasToolbar } from '@/components/canvas/canvas-toolbar';
  10. import type { CanvasToolbarProps } from '@/components/canvas/canvas-toolbar';
  11. import type { Scene, StageMode } from '@/lib/types/stage';
  12. import { useI18n } from '@/lib/hooks/use-i18n';
  13. interface CanvasAreaProps extends CanvasToolbarProps {
  14. readonly currentScene: Scene | null;
  15. readonly mode: StageMode;
  16. readonly hideToolbar?: boolean;
  17. readonly isPendingScene?: boolean;
  18. readonly isGenerationFailed?: boolean;
  19. readonly onRetryGeneration?: () => void;
  20. }
  21. export function CanvasArea({
  22. currentScene,
  23. currentSceneIndex,
  24. scenesCount,
  25. mode,
  26. engineState,
  27. isLiveSession,
  28. whiteboardOpen,
  29. sidebarCollapsed,
  30. chatCollapsed,
  31. onToggleSidebar,
  32. onToggleChat,
  33. onPrevSlide,
  34. onNextSlide,
  35. onPlayPause,
  36. onWhiteboardClose,
  37. isPresenting,
  38. onTogglePresentation,
  39. showStopDiscussion,
  40. onStopDiscussion,
  41. hideToolbar,
  42. isPendingScene,
  43. isGenerationFailed,
  44. onRetryGeneration,
  45. }: CanvasAreaProps) {
  46. const { t } = useI18n();
  47. const showControls = mode === 'playback' && !whiteboardOpen;
  48. const showPlayHint =
  49. showControls &&
  50. engineState !== 'playing' &&
  51. currentScene?.type === 'slide' &&
  52. !isLiveSession &&
  53. !isPendingScene;
  54. const handleSlideClick = useCallback(
  55. (e: React.MouseEvent) => {
  56. if (!showControls || isLiveSession || currentScene?.type !== 'slide') return;
  57. // Don't trigger page play/pause when clicking inside a video element's visual area.
  58. // Video elements may be visually covered by other slide elements (e.g. text),
  59. // so we check click coordinates against all video element bounding rects.
  60. const container = e.currentTarget as HTMLElement;
  61. const videoEls = container.querySelectorAll('[data-video-element]');
  62. for (const el of videoEls) {
  63. const rect = el.getBoundingClientRect();
  64. if (
  65. e.clientX >= rect.left &&
  66. e.clientX <= rect.right &&
  67. e.clientY >= rect.top &&
  68. e.clientY <= rect.bottom
  69. ) {
  70. return;
  71. }
  72. }
  73. onPlayPause();
  74. },
  75. [showControls, isLiveSession, onPlayPause, currentScene?.type],
  76. );
  77. return (
  78. <div className="w-full h-full flex flex-col bg-gray-50 dark:bg-gray-900 group/canvas">
  79. {/* Slide area — takes remaining space */}
  80. <div
  81. className={cn(
  82. 'flex-1 min-h-0 relative overflow-hidden flex items-center justify-center p-2 transition-colors duration-500',
  83. currentScene?.type === 'interactive'
  84. ? 'bg-blue-50/30 dark:bg-blue-900/10'
  85. : 'bg-gray-50/30 dark:bg-gray-900/30',
  86. )}
  87. >
  88. <div
  89. className={cn(
  90. 'aspect-[16/9] h-full max-h-full max-w-full bg-white dark:bg-gray-800 shadow-2xl rounded-lg overflow-hidden relative transition-all duration-700',
  91. showControls && !isLiveSession && currentScene?.type === 'slide' && 'cursor-pointer',
  92. currentScene?.type === 'interactive'
  93. ? 'shadow-blue-200/50 dark:shadow-blue-900/50 ring-1 ring-blue-900/5 dark:ring-blue-500/10'
  94. : 'shadow-gray-200/50 dark:shadow-gray-800/50 ring-1 ring-gray-950/5 dark:ring-white/5',
  95. )}
  96. onClick={handleSlideClick}
  97. >
  98. {/* Whiteboard Layer */}
  99. <div className="absolute inset-0 z-[110] pointer-events-none">
  100. <SceneProvider>
  101. <Whiteboard isOpen={whiteboardOpen} onClose={onWhiteboardClose} />
  102. </SceneProvider>
  103. </div>
  104. {/* Scene Content */}
  105. {currentScene && !whiteboardOpen && (
  106. <div className="absolute inset-0">
  107. <SceneProvider>
  108. <SceneRenderer scene={currentScene} mode={mode} />
  109. </SceneProvider>
  110. </div>
  111. )}
  112. {/* Pending Scene Loading Overlay */}
  113. <AnimatePresence>
  114. {isPendingScene && !currentScene && (
  115. <motion.div
  116. initial={{ opacity: 0 }}
  117. animate={{ opacity: 1 }}
  118. exit={{ opacity: 0 }}
  119. transition={{ duration: 0.4, ease: 'easeOut' }}
  120. className="absolute inset-0 z-[105] flex flex-col items-center justify-center bg-white dark:bg-gray-800"
  121. >
  122. {isGenerationFailed ? (
  123. <div className="flex flex-col items-center gap-3">
  124. <div className="w-12 h-12 rounded-full bg-red-50 dark:bg-red-900/20 flex items-center justify-center">
  125. <svg
  126. className="w-6 h-6 text-red-400 dark:text-red-500"
  127. fill="none"
  128. viewBox="0 0 24 24"
  129. stroke="currentColor"
  130. strokeWidth={1.5}
  131. >
  132. <path
  133. strokeLinecap="round"
  134. strokeLinejoin="round"
  135. d="M12 9v3.75m9-.75a9 9 0 11-18 0 9 9 0 0118 0zm-9 3.75h.008v.008H12v-.008z"
  136. />
  137. </svg>
  138. </div>
  139. <span className="text-sm text-red-500 dark:text-red-400 font-medium">
  140. {t('stage.generationFailed')}
  141. </span>
  142. {onRetryGeneration && (
  143. <button
  144. onClick={onRetryGeneration}
  145. className="mt-1 px-4 py-1.5 text-xs font-medium rounded-full bg-red-50 dark:bg-red-900/20 text-red-600 dark:text-red-400 hover:bg-red-100 dark:hover:bg-red-900/40 transition-colors active:scale-95"
  146. >
  147. {t('generation.retryScene')}
  148. </button>
  149. )}
  150. </div>
  151. ) : (
  152. <div className="flex flex-col items-center gap-4">
  153. {/* Spinner */}
  154. <div className="relative w-12 h-12">
  155. <div className="absolute inset-0 rounded-full border-2 border-gray-100 dark:border-gray-700" />
  156. <div className="absolute inset-0 rounded-full border-2 border-transparent border-t-purple-500 dark:border-t-purple-400 animate-spin" />
  157. </div>
  158. {/* Text */}
  159. <motion.span
  160. initial={{ opacity: 0, y: 4 }}
  161. animate={{ opacity: 1, y: 0 }}
  162. transition={{ delay: 0.2, duration: 0.3 }}
  163. className="text-sm text-gray-400 dark:text-gray-500 font-medium"
  164. >
  165. {t('stage.generatingNextPage')}
  166. </motion.span>
  167. </div>
  168. )}
  169. </motion.div>
  170. )}
  171. </AnimatePresence>
  172. {/* Scene Number Badge */}
  173. {currentScene && (
  174. <div className="absolute top-4 right-4 text-gray-200 dark:text-gray-700 font-black text-4xl opacity-50 pointer-events-none select-none mix-blend-multiply dark:mix-blend-screen">
  175. {(currentSceneIndex + 1).toString().padStart(2, '0')}
  176. </div>
  177. )}
  178. {/* Play hint — breathing button when idle or paused (slides only) */}
  179. <AnimatePresence>
  180. {showPlayHint && (
  181. <motion.div
  182. initial={{ opacity: 0 }}
  183. animate={{ opacity: 1 }}
  184. exit={{ opacity: 0 }}
  185. transition={{ duration: 0.3 }}
  186. className="absolute inset-0 z-[102] flex items-center justify-center pointer-events-none"
  187. >
  188. <motion.div
  189. className="opacity-50 group-hover/canvas:opacity-100 transition-opacity duration-300 pointer-events-auto cursor-pointer"
  190. exit={{ pointerEvents: 'none' }}
  191. onClick={(e) => {
  192. e.stopPropagation();
  193. onPlayPause();
  194. }}
  195. >
  196. <motion.div
  197. initial={{ scale: 0.85 }}
  198. animate={{ scale: [1, 1.06] }}
  199. exit={{ scale: 1.15, opacity: 0 }}
  200. transition={{
  201. default: { duration: 0.3, ease: [0.4, 0, 0.2, 1] },
  202. scale: {
  203. repeat: Infinity,
  204. repeatType: 'mirror',
  205. duration: 1,
  206. ease: 'easeInOut',
  207. },
  208. }}
  209. className="w-20 h-20 rounded-full bg-white/95 dark:bg-gray-800/95 flex items-center justify-center shadow-[0_4px_30px_rgba(147,51,234,0.15),inset_0_0_0_1px_rgba(233,213,255,0.5)] dark:shadow-[0_4px_30px_rgba(147,51,234,0.3),inset_0_0_0_1px_rgba(126,34,206,0.3)]"
  210. style={{ willChange: 'transform' }}
  211. >
  212. <Play className="w-7 h-7 text-purple-600 dark:text-purple-400 fill-purple-600/90 dark:fill-purple-400/90 ml-0.5" />
  213. </motion.div>
  214. </motion.div>
  215. </motion.div>
  216. )}
  217. </AnimatePresence>
  218. </div>
  219. </div>
  220. {/* ── Canvas Toolbar — in document flow, only when not merged into roundtable ── */}
  221. {!hideToolbar && (
  222. <CanvasToolbar
  223. className={cn(
  224. 'shrink-0 h-9 px-2',
  225. 'bg-white/80 dark:bg-gray-800/80 backdrop-blur-xl',
  226. 'border-t border-gray-200/40 dark:border-gray-700/40',
  227. )}
  228. currentSceneIndex={currentSceneIndex}
  229. scenesCount={scenesCount}
  230. engineState={engineState}
  231. isLiveSession={isLiveSession}
  232. whiteboardOpen={whiteboardOpen}
  233. sidebarCollapsed={sidebarCollapsed}
  234. chatCollapsed={chatCollapsed}
  235. onToggleSidebar={onToggleSidebar}
  236. onToggleChat={onToggleChat}
  237. onPrevSlide={onPrevSlide}
  238. onNextSlide={onNextSlide}
  239. onPlayPause={onPlayPause}
  240. onWhiteboardClose={onWhiteboardClose}
  241. isPresenting={isPresenting}
  242. onTogglePresentation={onTogglePresentation}
  243. showStopDiscussion={showStopDiscussion}
  244. onStopDiscussion={onStopDiscussion}
  245. />
  246. )}
  247. </div>
  248. );
  249. }