whiteboard-canvas.tsx 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445
  1. 'use client';
  2. import {
  3. useRef,
  4. useState,
  5. useEffect,
  6. useCallback,
  7. useMemo,
  8. forwardRef,
  9. useImperativeHandle,
  10. } from 'react';
  11. import { motion, AnimatePresence } from 'motion/react';
  12. import { useStageStore } from '@/lib/store';
  13. import { useCanvasStore } from '@/lib/store/canvas';
  14. import { ScreenElement } from '@/components/slide-renderer/Editor/ScreenElement';
  15. import type { PPTElement } from '@/lib/types/slides';
  16. import { useI18n } from '@/lib/hooks/use-i18n';
  17. export type WhiteboardCanvasHandle = {
  18. resetView: () => void;
  19. };
  20. type InteractiveWhiteboardCanvasProps = {
  21. canvasHeight: number;
  22. canvasWidth: number;
  23. containerWidth: number;
  24. containerHeight: number;
  25. containerScale: number;
  26. elements: PPTElement[];
  27. isClearing: boolean;
  28. onViewModifiedChange?: (modified: boolean) => void;
  29. readyHintText: string;
  30. readyText: string;
  31. };
  32. function AnimatedElement({
  33. element,
  34. index,
  35. isClearing,
  36. totalElements,
  37. }: {
  38. element: PPTElement;
  39. index: number;
  40. isClearing: boolean;
  41. totalElements: number;
  42. }) {
  43. const clearDelay = isClearing ? (totalElements - 1 - index) * 0.055 : 0;
  44. const clearRotate = isClearing ? (index % 2 === 0 ? 1 : -1) * (2 + index * 0.4) : 0;
  45. return (
  46. <motion.div
  47. layout={false}
  48. initial={{ opacity: 0, scale: 0.92, y: 8, filter: 'blur(4px)' }}
  49. animate={
  50. isClearing
  51. ? {
  52. opacity: 0,
  53. scale: 0.35,
  54. y: -35,
  55. rotate: clearRotate,
  56. filter: 'blur(8px)',
  57. transition: {
  58. duration: 0.38,
  59. delay: clearDelay,
  60. ease: [0.5, 0, 1, 0.6],
  61. },
  62. }
  63. : {
  64. opacity: 1,
  65. scale: 1,
  66. y: 0,
  67. rotate: 0,
  68. filter: 'blur(0px)',
  69. transition: {
  70. duration: 0.45,
  71. ease: [0.16, 1, 0.3, 1],
  72. delay: index * 0.05,
  73. },
  74. }
  75. }
  76. exit={{
  77. opacity: 0,
  78. scale: 0.85,
  79. transition: { duration: 0.2 },
  80. }}
  81. className="absolute inset-0"
  82. style={{ pointerEvents: isClearing ? 'none' : undefined }}
  83. >
  84. <div style={{ pointerEvents: 'auto' }}>
  85. <ScreenElement elementInfo={element} elementIndex={index} animate />
  86. </div>
  87. </motion.div>
  88. );
  89. }
  90. const InteractiveWhiteboardCanvas = forwardRef<
  91. WhiteboardCanvasHandle,
  92. InteractiveWhiteboardCanvasProps
  93. >(function InteractiveWhiteboardCanvas(
  94. {
  95. canvasHeight,
  96. canvasWidth,
  97. containerWidth,
  98. containerHeight,
  99. containerScale,
  100. elements,
  101. isClearing,
  102. onViewModifiedChange,
  103. readyHintText,
  104. readyText,
  105. },
  106. ref,
  107. ) {
  108. const [viewZoom, setViewZoom] = useState(1);
  109. const [panX, setPanX] = useState(0);
  110. const [panY, setPanY] = useState(0);
  111. const [isPanning, setIsPanning] = useState(false);
  112. const [isResetting, setIsResetting] = useState(false);
  113. const panStartRef = useRef({ x: 0, y: 0, panX: 0, panY: 0 });
  114. const prevElementsLengthRef = useRef(elements.length);
  115. const resetTimerRef = useRef<number | null>(null);
  116. const viewportRef = useRef<HTMLDivElement>(null);
  117. const isViewModified = viewZoom !== 1 || panX !== 0 || panY !== 0;
  118. // Zoom-aware pan boundary: ensure at least an edge of the canvas stays visible
  119. const clampPan = useCallback(
  120. (x: number, y: number, zoom: number) => {
  121. const totalScale = containerScale * zoom;
  122. const maxPanX = canvasWidth / 2 + containerWidth / (2 * totalScale);
  123. const maxPanY = canvasHeight / 2 + containerHeight / (2 * totalScale);
  124. return {
  125. x: Math.max(-maxPanX, Math.min(maxPanX, x)),
  126. y: Math.max(-maxPanY, Math.min(maxPanY, y)),
  127. };
  128. },
  129. [canvasWidth, canvasHeight, containerWidth, containerHeight, containerScale],
  130. );
  131. const resetView = useCallback((animate: boolean) => {
  132. setIsPanning(false);
  133. setIsResetting(animate);
  134. setViewZoom(1);
  135. setPanX(0);
  136. setPanY(0);
  137. if (resetTimerRef.current) {
  138. window.clearTimeout(resetTimerRef.current);
  139. resetTimerRef.current = null;
  140. }
  141. if (!animate) {
  142. return;
  143. }
  144. resetTimerRef.current = window.setTimeout(() => {
  145. setIsResetting(false);
  146. resetTimerRef.current = null;
  147. }, 250);
  148. }, []);
  149. useImperativeHandle(
  150. ref,
  151. () => ({
  152. resetView: () => resetView(true),
  153. }),
  154. [resetView],
  155. );
  156. // Notify parent when view modified state changes
  157. useEffect(() => {
  158. onViewModifiedChange?.(isViewModified);
  159. }, [isViewModified, onViewModifiedChange]);
  160. // Always-on drag/pan — no toggle needed
  161. const handlePointerDown = useCallback(
  162. (e: React.PointerEvent) => {
  163. if (e.button !== 0) {
  164. return;
  165. }
  166. e.preventDefault();
  167. setIsPanning(true);
  168. panStartRef.current = { x: e.clientX, y: e.clientY, panX, panY };
  169. (e.currentTarget as HTMLElement).setPointerCapture(e.pointerId);
  170. },
  171. [panX, panY],
  172. );
  173. const handlePointerMove = useCallback(
  174. (e: React.PointerEvent) => {
  175. if (!isPanning) {
  176. return;
  177. }
  178. const dx = e.clientX - panStartRef.current.x;
  179. const dy = e.clientY - panStartRef.current.y;
  180. // Convert screen-space drag to canvas-space (accounts for both container scale and zoom)
  181. const effectiveScale = Math.max(containerScale * viewZoom, 0.001);
  182. const newPanX = panStartRef.current.panX + dx / effectiveScale;
  183. const newPanY = panStartRef.current.panY + dy / effectiveScale;
  184. const clamped = clampPan(newPanX, newPanY, viewZoom);
  185. setPanX(clamped.x);
  186. setPanY(clamped.y);
  187. },
  188. [containerScale, viewZoom, isPanning, clampPan],
  189. );
  190. const handlePointerUp = useCallback((e: React.PointerEvent) => {
  191. if ((e.currentTarget as HTMLElement).hasPointerCapture(e.pointerId)) {
  192. (e.currentTarget as HTMLElement).releasePointerCapture(e.pointerId);
  193. }
  194. setIsPanning(false);
  195. }, []);
  196. // Zoom toward cursor
  197. useEffect(() => {
  198. const el = viewportRef.current;
  199. if (!el) {
  200. return;
  201. }
  202. const onWheel = (e: WheelEvent) => {
  203. e.preventDefault();
  204. if (elements.length === 0) {
  205. return;
  206. }
  207. const zoomFactor = e.deltaY > 0 ? 0.9 : 1.1;
  208. setViewZoom((prevZoom) => {
  209. const newZoom = Math.min(5, Math.max(0.2, prevZoom * zoomFactor));
  210. // Adjust pan to keep the point under the cursor stationary
  211. const rect = el.getBoundingClientRect();
  212. const cursorX = e.clientX - rect.left;
  213. const cursorY = e.clientY - rect.top;
  214. const oldScale = containerScale * prevZoom;
  215. const newScale = containerScale * newZoom;
  216. const scaleDiff = 1 / newScale - 1 / oldScale;
  217. setPanX((prevPanX) => {
  218. const newPanX = prevPanX + (cursorX - containerWidth / 2) * scaleDiff;
  219. const maxPX = canvasWidth / 2 + containerWidth / (2 * newScale);
  220. return Math.max(-maxPX, Math.min(maxPX, newPanX));
  221. });
  222. setPanY((prevPanY) => {
  223. const newPanY = prevPanY + (cursorY - containerHeight / 2) * scaleDiff;
  224. const maxPY = canvasHeight / 2 + containerHeight / (2 * newScale);
  225. return Math.max(-maxPY, Math.min(maxPY, newPanY));
  226. });
  227. return newZoom;
  228. });
  229. };
  230. el.addEventListener('wheel', onWheel, { passive: false });
  231. return () => el.removeEventListener('wheel', onWheel);
  232. }, [elements.length, containerScale, containerWidth, containerHeight, canvasWidth, canvasHeight]);
  233. useEffect(() => {
  234. return () => {
  235. if (resetTimerRef.current) {
  236. window.clearTimeout(resetTimerRef.current);
  237. }
  238. };
  239. }, []);
  240. useEffect(() => {
  241. const prevLength = prevElementsLengthRef.current;
  242. const nextLength = elements.length;
  243. prevElementsLengthRef.current = nextLength;
  244. const clearedBoard = prevLength > 0 && nextLength === 0;
  245. const firstContentLoaded = prevLength === 0 && nextLength > 0;
  246. if (!clearedBoard && !firstContentLoaded) {
  247. return;
  248. }
  249. let cancelled = false;
  250. queueMicrotask(() => {
  251. if (!cancelled) {
  252. resetView(false);
  253. }
  254. });
  255. return () => {
  256. cancelled = true;
  257. };
  258. }, [elements.length, resetView]);
  259. const handleDoubleClick = useCallback(
  260. (e?: React.MouseEvent) => {
  261. e?.preventDefault();
  262. resetView(true);
  263. },
  264. [resetView],
  265. );
  266. // Canvas position: centered in workspace, offset by pan, scaled by containerScale * viewZoom
  267. const totalScale = containerScale * viewZoom;
  268. const canvasScreenX = (containerWidth - canvasWidth * totalScale) / 2 + panX * totalScale;
  269. const canvasScreenY = (containerHeight - canvasHeight * totalScale) / 2 + panY * totalScale;
  270. const canvasTransform = `translate(${canvasScreenX}px, ${canvasScreenY}px) scale(${totalScale})`;
  271. return (
  272. /* Viewport — fills workspace, handles pointer events, no clipping */
  273. <div
  274. ref={viewportRef}
  275. className="w-full h-full relative select-none"
  276. style={{
  277. cursor: isPanning ? 'grabbing' : 'grab',
  278. }}
  279. onPointerDown={handlePointerDown}
  280. onPointerMove={handlePointerMove}
  281. onPointerUp={handlePointerUp}
  282. onPointerCancel={handlePointerUp}
  283. onDoubleClick={handleDoubleClick}
  284. >
  285. {/* Bounded canvas — white background, positioned and scaled. No overflow-hidden so elements can spill into transparent space. */}
  286. <div
  287. className="absolute bg-white shadow-2xl rounded-lg border border-gray-200 dark:border-gray-600"
  288. style={{
  289. width: canvasWidth,
  290. height: canvasHeight,
  291. left: 0,
  292. top: 0,
  293. transform: canvasTransform,
  294. transformOrigin: '0 0',
  295. transition: isResetting ? 'transform 0.25s ease-out' : undefined,
  296. }}
  297. >
  298. {/* Empty state placeholder */}
  299. <AnimatePresence>
  300. {elements.length === 0 && !isClearing && (
  301. <motion.div
  302. key="placeholder"
  303. initial={{ opacity: 0 }}
  304. animate={{
  305. opacity: 1,
  306. transition: { delay: 0.25, duration: 0.4 },
  307. }}
  308. exit={{ opacity: 0, transition: { duration: 0.15 } }}
  309. className="absolute inset-0 flex items-center justify-center"
  310. >
  311. <div className="text-center text-gray-400">
  312. <p className="text-lg font-medium">{readyText}</p>
  313. <p className="text-sm mt-1">{readyHintText}</p>
  314. </div>
  315. </motion.div>
  316. )}
  317. </AnimatePresence>
  318. {/* Content layer — elements rendered at their raw coordinates */}
  319. <div className="absolute inset-0">
  320. <AnimatePresence mode="popLayout">
  321. {elements.map((element, index) => (
  322. <AnimatedElement
  323. key={element.id}
  324. element={element}
  325. index={index}
  326. isClearing={isClearing}
  327. totalElements={elements.length}
  328. />
  329. ))}
  330. </AnimatePresence>
  331. </div>
  332. </div>
  333. </div>
  334. );
  335. });
  336. /**
  337. * Whiteboard canvas with pan, zoom, auto-fit, and bounded viewport.
  338. */
  339. export type WhiteboardCanvasProps = {
  340. onViewModifiedChange?: (modified: boolean) => void;
  341. };
  342. export const WhiteboardCanvas = forwardRef<WhiteboardCanvasHandle, WhiteboardCanvasProps>(
  343. function WhiteboardCanvas({ onViewModifiedChange }, ref) {
  344. const { t } = useI18n();
  345. const stage = useStageStore.use.stage();
  346. const isClearing = useCanvasStore.use.whiteboardClearing();
  347. const containerRef = useRef<HTMLDivElement>(null);
  348. const [containerSize, setContainerSize] = useState({ width: 0, height: 0 });
  349. const whiteboard = stage?.whiteboard?.[0];
  350. const rawElements = whiteboard?.elements;
  351. const elements = useMemo(() => rawElements ?? [], [rawElements]);
  352. const canvasWidth = 1000;
  353. const canvasHeight = 562.5;
  354. const containerScale = useMemo(() => {
  355. if (containerSize.width === 0 || containerSize.height === 0) return 1;
  356. return Math.min(containerSize.width / canvasWidth, containerSize.height / canvasHeight);
  357. }, [containerSize.width, containerSize.height, canvasWidth, canvasHeight]);
  358. useEffect(() => {
  359. const container = containerRef.current;
  360. if (!container) {
  361. return;
  362. }
  363. const observer = new ResizeObserver((entries) => {
  364. const entry = entries[0];
  365. if (entry) {
  366. setContainerSize({
  367. width: entry.contentRect.width,
  368. height: entry.contentRect.height,
  369. });
  370. }
  371. });
  372. observer.observe(container);
  373. // Initial measurement
  374. setContainerSize({ width: container.clientWidth, height: container.clientHeight });
  375. return () => observer.disconnect();
  376. }, []);
  377. return (
  378. <div ref={containerRef} className="w-full h-full overflow-hidden">
  379. <InteractiveWhiteboardCanvas
  380. ref={ref}
  381. canvasHeight={canvasHeight}
  382. canvasWidth={canvasWidth}
  383. containerWidth={containerSize.width}
  384. containerHeight={containerSize.height}
  385. containerScale={containerScale}
  386. elements={elements}
  387. isClearing={isClearing}
  388. onViewModifiedChange={onViewModifiedChange}
  389. readyHintText={t('whiteboard.readyHint')}
  390. readyText={t('whiteboard.ready')}
  391. />
  392. </div>
  393. );
  394. },
  395. );