diff --git a/components/whiteboard/whiteboard-canvas.tsx b/components/whiteboard/whiteboard-canvas.tsx index e07b5f49..8bdc138b 100644 --- a/components/whiteboard/whiteboard-canvas.tsx +++ b/components/whiteboard/whiteboard-canvas.tsx @@ -7,84 +7,12 @@ import { useCanvasStore } from '@/lib/store/canvas'; import { useWhiteboardHistoryStore } from '@/lib/store/whiteboard-history'; import { ScreenElement } from '@/components/slide-renderer/Editor/ScreenElement'; import { elementFingerprint } from '@/lib/utils/element-fingerprint'; -import type { PPTElement, PPTLineElement } from '@/lib/types/slides'; +import type { PPTElement } from '@/lib/types/slides'; import { useI18n } from '@/lib/hooks/use-i18n'; -import { getElementRange } from '@/lib/utils/element'; - -type ElementBounds = { - minX: number; - minY: number; - maxX: number; - maxY: number; -}; - -type InteractiveWhiteboardCanvasProps = { - autoFitTransform: { - scale: number; - tx: number; - ty: number; - }; - canvasHeight: number; - canvasWidth: number; - containerScale: number; - elements: PPTElement[]; - isClearing: boolean; - readyHintText: string; - readyText: string; - resetViewText: string; - zoomHintText: string; -}; - -function getLineBounds(element: PPTLineElement): ElementBounds { - const originX = element.left ?? 0; - const originY = element.top ?? 0; - const points: Array<[number, number]> = [element.start, element.end]; - - if (element.broken) { - points.push(element.broken); - } - - if (element.broken2) { - const horizontalFirst = - Math.abs(element.end[0] - element.start[0]) >= Math.abs(element.end[1] - element.start[1]); - - if (horizontalFirst) { - points.push([element.broken2[0], element.start[1]], [element.broken2[0], element.end[1]]); - } else { - points.push([element.start[0], element.broken2[1]], [element.end[0], element.broken2[1]]); - } - } - - if (element.curve) { - points.push(element.curve); - } - - if (element.cubic) { - points.push(...element.cubic); - } - - const xs = points.map(([x]) => originX + x); - const ys = points.map(([, y]) => originY + y); - const strokePad = Math.max(element.width ?? 0, 1) / 2; - const markerPad = element.points.some(Boolean) ? Math.max(element.width ?? 0, 1) * 1.5 : 0; - const pad = strokePad + markerPad; - - return { - minX: Math.min(...xs) - pad, - minY: Math.min(...ys) - pad, - maxX: Math.max(...xs) + pad, - maxY: Math.max(...ys) + pad, - }; -} - -function getWhiteboardElementBounds(element: PPTElement): ElementBounds { - if (element.type === 'line') { - return getLineBounds(element); - } - - return getElementRange(element); -} +/** + * Animated element wrapper + */ function AnimatedElement({ element, index, @@ -96,7 +24,9 @@ function AnimatedElement({ isClearing: boolean; totalElements: number; }) { + // Reverse stagger: last-drawn element exits first for a "wipe" cascade const clearDelay = isClearing ? (totalElements - 1 - index) * 0.055 : 0; + // Alternate tilt direction for organic feel const clearRotate = isClearing ? (index % 2 === 0 ? 1 : -1) * (2 + index * 0.4) : 0; return ( @@ -145,321 +75,60 @@ function AnimatedElement({ ); } -function InteractiveWhiteboardCanvas({ - autoFitTransform, - canvasHeight, - canvasWidth, - containerScale, - elements, - isClearing, - readyHintText, - readyText, - resetViewText, - zoomHintText, -}: InteractiveWhiteboardCanvasProps) { - 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 [hintTimedOut, setHintTimedOut] = useState(false); - const panStartRef = useRef({ x: 0, y: 0, panX: 0, panY: 0 }); - const prevElementsLengthRef = useRef(elements.length); - const resetTimerRef = useRef(null); - const hintTimerRef = useRef(null); - const hintEpochRef = useRef(0); - const canvasRef = useRef(null); - - const isViewModified = viewZoom !== 1 || panX !== 0 || panY !== 0; - const hasOverflow = autoFitTransform.scale < 1; - const canPan = elements.length > 0 && (hasOverflow || isViewModified); - const hintEpoch = elements.length > 0 && !isViewModified ? 1 : 0; - const showHint = hintEpoch === 1 && !hintTimedOut; - - useEffect(() => { - if (hintEpoch === 0) { - return; - } - - const epoch = ++hintEpochRef.current; - hintTimerRef.current = window.setTimeout(() => { - if (hintEpochRef.current === epoch) { - setHintTimedOut(true); - } - }, 3000); - - return () => { - if (hintTimerRef.current !== null) { - window.clearTimeout(hintTimerRef.current); - hintTimerRef.current = null; - } - }; - }, [hintEpoch]); - - const handlePointerDown = useCallback( - (e: React.PointerEvent) => { - if (e.button !== 0 || !canPan) { - return; - } - - e.preventDefault(); - setIsPanning(true); - setHintTimedOut(false); - panStartRef.current = { x: e.clientX, y: e.clientY, panX, panY }; - (e.currentTarget as HTMLElement).setPointerCapture(e.pointerId); - }, - [canPan, 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; - const effectiveScale = Math.max(containerScale, 0.001); - - setPanX(panStartRef.current.panX + dx / effectiveScale); - setPanY(panStartRef.current.panY + dy / effectiveScale); - }, - [containerScale, isPanning], - ); - - const handlePointerUp = useCallback((e: React.PointerEvent) => { - if ((e.currentTarget as HTMLElement).hasPointerCapture(e.pointerId)) { - (e.currentTarget as HTMLElement).releasePointerCapture(e.pointerId); - } - - setIsPanning(false); - }, []); - - const resetView = useCallback((animate: boolean) => { - setIsPanning(false); - setIsResetting(animate); - setHintTimedOut(false); - 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); - }, []); - - useEffect(() => { - const el = canvasRef.current; - if (!el) { - return; - } - - const onWheel = (e: WheelEvent) => { - e.preventDefault(); - if (elements.length === 0) { - return; - } - - setHintTimedOut(false); - const zoomFactor = e.deltaY > 0 ? 0.9 : 1.1; - setViewZoom((prev) => Math.min(5, Math.max(0.2, prev * zoomFactor))); - }; - - el.addEventListener('wheel', onWheel, { passive: false }); - return () => el.removeEventListener('wheel', onWheel); - }, [elements.length]); - - 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], - ); - - const contentTransform = useMemo(() => { - const scale = autoFitTransform.scale * viewZoom; - const tx = autoFitTransform.tx + panX; - const ty = autoFitTransform.ty + panY; - return `translate(${tx}px, ${ty}px) scale(${scale})`; - }, [autoFitTransform, panX, panY, viewZoom]); - - return ( -
- - {elements.length === 0 && !isClearing && ( - -
-

{readyText}

-

{readyHintText}

-
-
- )} -
- -
- - {elements.map((element, index) => ( - - ))} - -
- - - {showHint && !isViewModified && elements.length > 0 && ( - - {zoomHintText} - - )} - - - - {isViewModified && elements.length > 0 && ( - e.stopPropagation()} - onClick={(e) => { - e.stopPropagation(); - handleDoubleClick(); - }} - className="absolute bottom-3 right-3 z-50 px-2.5 py-1 rounded-md - bg-black/60 text-white text-xs backdrop-blur-sm - hover:bg-black/80 transition-colors cursor-pointer select-none" - > - {resetViewText} - - )} - -
- ); -} - /** - * Whiteboard canvas with pan, zoom, auto-fit, and history auto-snapshot support. + * Whiteboard canvas — renders the current whiteboard elements and handles + * auto-snapshotting so the user can browse/restore previous states. + * + * The auto-snapshot logic watches for "content replacement" events — + * i.e. when AI replaces the whiteboard content with new elements. It + * debounces by 2 seconds so that one-by-one element additions don't + * spam the history store. The `restoredKey` one-shot guard prevents a + * restore action from itself triggering a new snapshot. */ export function WhiteboardCanvas() { const { t } = useI18n(); const stage = useStageStore.use.stage(); const isClearing = useCanvasStore.use.whiteboardClearing(); const containerRef = useRef(null); - const [containerScale, setContainerScale] = useState(1); + const [scale, setScale] = useState(1); + // Get whiteboard elements const whiteboard = stage?.whiteboard?.[0]; const rawElements = whiteboard?.elements; const elements = useMemo(() => rawElements ?? [], [rawElements]); + + // ── Auto-snapshot logic ────────────────────────────────────────── + // Saves a snapshot of the CURRENT state after elements have been stable + // (unchanged) for 2 seconds. This ensures the complete "finished" result + // appears in history, not just intermediate build-up states. const elementsKey = useMemo(() => elementFingerprint(elements), [elements]); const elementsRef = useRef(elements); - const snapshotTimerRef = useRef | null>(null); - useEffect(() => { elementsRef.current = elements; }, [elements]); + const snapshotTimerRef = useRef | null>(null); useEffect(() => { + // Cancel any pending timer whenever elements change if (snapshotTimerRef.current) { clearTimeout(snapshotTimerRef.current); snapshotTimerRef.current = null; } - if (elements.length === 0 || isClearing) { - return; - } + // Don't snapshot empty states or during clearing animation + if (elements.length === 0 || isClearing) return; - const historyStore = useWhiteboardHistoryStore.getState(); - if (historyStore.restoredKey && historyStore.restoredKey === elementsKey) { - historyStore.setRestoredKey(null); + // If this state matches a just-restored snapshot, skip and clear the flag. + // This check uses fingerprint comparison (reviewer point #5) rather than + // a fragile boolean flag, eliminating timing dependencies entirely. + const { restoredKey } = useWhiteboardHistoryStore.getState(); + if (restoredKey && elementsKey === restoredKey) { + useWhiteboardHistoryStore.getState().setRestoredKey(null); return; } snapshotTimerRef.current = setTimeout(() => { + // Save the CURRENT stable state (not the previous one) const current = elementsRef.current; if (current.length > 0) { useWhiteboardHistoryStore.getState().pushSnapshot(current); @@ -469,106 +138,84 @@ export function WhiteboardCanvas() { return () => { if (snapshotTimerRef.current) { clearTimeout(snapshotTimerRef.current); - snapshotTimerRef.current = null; } }; - }, [elements.length, elementsKey, isClearing]); - - useEffect(() => { - return () => { - if (snapshotTimerRef.current) { - clearTimeout(snapshotTimerRef.current); - } - }; - }, []); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [elementsKey, isClearing]); + // ── Layout: whiteboard fixed size 1000 x 562.5 (16:9) ───────── const canvasWidth = 1000; const canvasHeight = 562.5; - const padding = 24; - const updateContainerScale = useCallback(() => { + const updateScale = useCallback(() => { const container = containerRef.current; - if (!container) { - return; - } - + if (!container) return; const { clientWidth, clientHeight } = container; const scaleX = clientWidth / canvasWidth; const scaleY = clientHeight / canvasHeight; - setContainerScale(Math.min(scaleX, scaleY)); + setScale(Math.min(scaleX, scaleY)); }, [canvasWidth, canvasHeight]); useEffect(() => { const container = containerRef.current; - if (!container) { - return; - } - - const observer = new ResizeObserver(updateContainerScale); + if (!container) return; + const observer = new ResizeObserver(updateScale); observer.observe(container); - updateContainerScale(); - + updateScale(); return () => observer.disconnect(); - }, [updateContainerScale]); - - const autoFitTransform = useMemo(() => { - if (elements.length === 0) { - return { scale: 1, tx: 0, ty: 0 }; - } - - let minX = Infinity; - let minY = Infinity; - let maxX = -Infinity; - let maxY = -Infinity; - - for (const element of elements) { - const bounds = getWhiteboardElementBounds(element); - minX = Math.min(minX, bounds.minX); - minY = Math.min(minY, bounds.minY); - maxX = Math.max(maxX, bounds.maxX); - maxY = Math.max(maxY, bounds.maxY); - } - - const contentWidth = maxX - minX; - const contentHeight = maxY - minY; - const overflowsX = minX < 0 || maxX > canvasWidth; - const overflowsY = minY < 0 || maxY > canvasHeight; - - if (!overflowsX && !overflowsY) { - return { scale: 1, tx: 0, ty: 0 }; - } - - const availableWidth = canvasWidth - padding * 2; - const availableHeight = canvasHeight - padding * 2; - const fitScale = Math.min(1, availableWidth / contentWidth, availableHeight / contentHeight); - const scaledWidth = contentWidth * fitScale; - const scaledHeight = contentHeight * fitScale; - - return { - scale: fitScale, - tx: (canvasWidth - scaledWidth) / 2 - minX * fitScale, - ty: (canvasHeight - scaledHeight) / 2 - minY * fitScale, - }; - }, [canvasHeight, canvasWidth, elements, padding]); + }, [updateScale]); + // ── Render ────────────────────────────────────────────────────── return (
-
- + {/* Layout wrapper: its size matches the scaled visual size so flex centering works correctly */} +
+
+ {/* Placeholder when empty and not mid-clear */} + + {elements.length === 0 && !isClearing && ( + +
+

{t('whiteboard.ready')}

+

{t('whiteboard.readyHint')}

+
+
+ )} +
+ + {/* Elements — always rendered so AnimatePresence can track exits */} + + {elements.map((element, index) => ( + + ))} + +
); diff --git a/lib/i18n/stage.ts b/lib/i18n/stage.ts index 81b08d8d..f1b074c3 100644 --- a/lib/i18n/stage.ts +++ b/lib/i18n/stage.ts @@ -17,8 +17,6 @@ export const stageZhCN = { readyHint: 'AI 添加元素后将在此显示', clearSuccess: '白板已清空', clearError: '清空白板失败:', - resetView: '重置视图', - zoomHint: '滚轮缩放 · 拖拽平移', restoreError: '恢复白板失败:', history: '历史记录', restore: '恢复', @@ -165,8 +163,6 @@ export const stageEnUS = { readyHint: 'Elements will appear here when added by AI', clearSuccess: 'Whiteboard cleared successfully', clearError: 'Failed to clear whiteboard: ', - resetView: 'Reset View', - zoomHint: 'Scroll to zoom · Drag to pan', restoreError: 'Failed to restore whiteboard: ', history: 'History', restore: 'Restore',