'use client'; import { useRef, useState, useEffect, useCallback, useMemo, memo, forwardRef, useImperativeHandle, } from 'react'; import { motion, AnimatePresence } from 'motion/react'; import { useCanvasStore } from '@/lib/store/canvas'; import { ScreenElement } from '@/components/slide-renderer/Editor/ScreenElement'; import { normalizeWhiteboardViewportRatio } from '@/lib/whiteboard/viewport'; import type { PPTElement, Whiteboard } from '@openmaic/dsl'; import { useI18n } from '@/lib/hooks/use-i18n'; export type WhiteboardCanvasHandle = { resetView: () => void; }; type InteractiveWhiteboardCanvasProps = { canvasHeight: number; canvasWidth: number; containerWidth: number; containerHeight: number; containerScale: number; elements: PPTElement[]; isClearing: boolean; onViewModifiedChange?: (modified: boolean) => void; readyHintText: string; readyText: string; }; function AnimatedElementBase({ element, index, isClearing, totalElements, }: { element: PPTElement; index: number; isClearing: boolean; totalElements: number; }) { const clearDelay = isClearing ? (totalElements - 1 - index) * 0.055 : 0; const clearRotate = isClearing ? (index % 2 === 0 ? 1 : -1) * (2 + index * 0.4) : 0; return ( ); } // Memoized so whiteboard pan/zoom state changes (which rerender the parent // on every pointer/wheel event) do not cascade into ScreenElement rerenders. // Without this, motion's projection system inside CodeLineRow remeasures // against the panning parent transform and animates the diff, making code // content visibly lag behind the surrounding element box during a pan. const AnimatedElement = memo(AnimatedElementBase); const InteractiveWhiteboardCanvas = forwardRef< WhiteboardCanvasHandle, InteractiveWhiteboardCanvasProps >(function InteractiveWhiteboardCanvas( { canvasHeight, canvasWidth, containerWidth, containerHeight, containerScale, elements, isClearing, onViewModifiedChange, readyHintText, readyText, }, ref, ) { const [viewZoom, setViewZoom] = useState(1); const [panX, setPanX] = useState(0); const [panY, setPanY] = useState(0); const [isPanning, setIsPanning] = useState(false); const [isResetting, setIsResetting] = useState(false); const panStartRef = useRef({ x: 0, y: 0, panX: 0, panY: 0 }); const prevElementsLengthRef = useRef(elements.length); const resetTimerRef = useRef(null); const viewportRef = useRef(null); const isViewModified = viewZoom !== 1 || panX !== 0 || panY !== 0; // Zoom-aware pan boundary: ensure at least an edge of the canvas stays visible const clampPan = useCallback( (x: number, y: number, zoom: number) => { const totalScale = containerScale * zoom; const maxPanX = canvasWidth / 2 + containerWidth / (2 * totalScale); const maxPanY = canvasHeight / 2 + containerHeight / (2 * totalScale); return { x: Math.max(-maxPanX, Math.min(maxPanX, x)), y: Math.max(-maxPanY, Math.min(maxPanY, y)), }; }, [canvasWidth, canvasHeight, containerWidth, containerHeight, containerScale], ); const resetView = useCallback((animate: boolean) => { setIsPanning(false); setIsResetting(animate); setViewZoom(1); setPanX(0); setPanY(0); if (resetTimerRef.current) { window.clearTimeout(resetTimerRef.current); resetTimerRef.current = null; } if (!animate) { return; } resetTimerRef.current = window.setTimeout(() => { setIsResetting(false); resetTimerRef.current = null; }, 250); }, []); useImperativeHandle( ref, () => ({ resetView: () => resetView(true), }), [resetView], ); // Notify parent when view modified state changes useEffect(() => { onViewModifiedChange?.(isViewModified); }, [isViewModified, onViewModifiedChange]); // Always-on drag/pan — no toggle needed const handlePointerDown = useCallback( (e: React.PointerEvent) => { if (e.button !== 0) { return; } e.preventDefault(); setIsPanning(true); panStartRef.current = { x: e.clientX, y: e.clientY, panX, panY }; (e.currentTarget as HTMLElement).setPointerCapture(e.pointerId); }, [panX, panY], ); const handlePointerMove = useCallback( (e: React.PointerEvent) => { if (!isPanning) { return; } const dx = e.clientX - panStartRef.current.x; const dy = e.clientY - panStartRef.current.y; // Convert screen-space drag to canvas-space (accounts for both container scale and zoom) const effectiveScale = Math.max(containerScale * viewZoom, 0.001); const newPanX = panStartRef.current.panX + dx / effectiveScale; const newPanY = panStartRef.current.panY + dy / effectiveScale; const clamped = clampPan(newPanX, newPanY, viewZoom); setPanX(clamped.x); setPanY(clamped.y); }, [containerScale, viewZoom, isPanning, clampPan], ); const handlePointerUp = useCallback((e: React.PointerEvent) => { if ((e.currentTarget as HTMLElement).hasPointerCapture(e.pointerId)) { (e.currentTarget as HTMLElement).releasePointerCapture(e.pointerId); } setIsPanning(false); }, []); // Zoom toward cursor useEffect(() => { const el = viewportRef.current; if (!el) { return; } const onWheel = (e: WheelEvent) => { e.preventDefault(); if (elements.length === 0) { return; } const zoomFactor = e.deltaY > 0 ? 0.9 : 1.1; setViewZoom((prevZoom) => { const newZoom = Math.min(5, Math.max(0.2, prevZoom * zoomFactor)); // Adjust pan to keep the point under the cursor stationary const rect = el.getBoundingClientRect(); const cursorX = e.clientX - rect.left; const cursorY = e.clientY - rect.top; const oldScale = containerScale * prevZoom; const newScale = containerScale * newZoom; const scaleDiff = 1 / newScale - 1 / oldScale; setPanX((prevPanX) => { const newPanX = prevPanX + (cursorX - containerWidth / 2) * scaleDiff; const maxPX = canvasWidth / 2 + containerWidth / (2 * newScale); return Math.max(-maxPX, Math.min(maxPX, newPanX)); }); setPanY((prevPanY) => { const newPanY = prevPanY + (cursorY - containerHeight / 2) * scaleDiff; const maxPY = canvasHeight / 2 + containerHeight / (2 * newScale); return Math.max(-maxPY, Math.min(maxPY, newPanY)); }); return newZoom; }); }; el.addEventListener('wheel', onWheel, { passive: false }); return () => el.removeEventListener('wheel', onWheel); }, [elements.length, containerScale, containerWidth, containerHeight, canvasWidth, canvasHeight]); useEffect(() => { return () => { if (resetTimerRef.current) { window.clearTimeout(resetTimerRef.current); } }; }, []); useEffect(() => { const prevLength = prevElementsLengthRef.current; const nextLength = elements.length; prevElementsLengthRef.current = nextLength; const clearedBoard = prevLength > 0 && nextLength === 0; const firstContentLoaded = prevLength === 0 && nextLength > 0; if (!clearedBoard && !firstContentLoaded) { return; } let cancelled = false; queueMicrotask(() => { if (!cancelled) { resetView(false); } }); return () => { cancelled = true; }; }, [elements.length, resetView]); const handleDoubleClick = useCallback( (e?: React.MouseEvent) => { e?.preventDefault(); resetView(true); }, [resetView], ); // Canvas position: centered in workspace, offset by pan, scaled by containerScale * viewZoom const totalScale = containerScale * viewZoom; const canvasScreenX = (containerWidth - canvasWidth * totalScale) / 2 + panX * totalScale; const canvasScreenY = (containerHeight - canvasHeight * totalScale) / 2 + panY * totalScale; const canvasTransform = `translate(${canvasScreenX}px, ${canvasScreenY}px) scale(${totalScale})`; return ( /* Viewport — fills workspace, handles pointer events, no clipping */ {/* Bounded canvas — white background, positioned and scaled. No overflow-hidden so elements can spill into transparent space. */} {/* Empty state placeholder */} {elements.length === 0 && !isClearing && ( {readyText} {readyHintText} )} {/* Content layer — elements rendered at their raw coordinates */} {elements.map((element, index) => ( ))} ); }); /** * Whiteboard canvas with pan, zoom, auto-fit, and bounded viewport. */ export type WhiteboardCanvasProps = { whiteboard?: Whiteboard | null; onViewModifiedChange?: (modified: boolean) => void; }; export const WhiteboardCanvas = forwardRef( function WhiteboardCanvas({ whiteboard, onViewModifiedChange }, ref) { const { t } = useI18n(); const isClearing = useCanvasStore.use.whiteboardClearing(); const containerRef = useRef(null); const [containerSize, setContainerSize] = useState({ width: 0, height: 0 }); const rawElements = whiteboard?.elements; const elements = useMemo(() => rawElements ?? [], [rawElements]); const canvasWidth = whiteboard?.viewportSize ?? 1000; // viewportRatio is height/width; normalize into the plausible band so an // inverted persisted ratio (16:9 written as width/height) can never render // a sheet taller than it is wide, even if it bypassed the runtime repair. const canvasHeight = canvasWidth * normalizeWhiteboardViewportRatio(whiteboard?.viewportRatio ?? 0.5625); const containerScale = useMemo(() => { if (containerSize.width === 0 || containerSize.height === 0) return 1; return Math.min(containerSize.width / canvasWidth, containerSize.height / canvasHeight); }, [containerSize.width, containerSize.height, canvasWidth, canvasHeight]); useEffect(() => { const container = containerRef.current; if (!container) { return; } const observer = new ResizeObserver((entries) => { const entry = entries[0]; if (entry) { setContainerSize({ width: entry.contentRect.width, height: entry.contentRect.height, }); } }); observer.observe(container); // Initial measurement setContainerSize({ width: container.clientWidth, height: container.clientHeight }); return () => observer.disconnect(); }, []); return ( ); }, );
{readyText}
{readyHintText}