diff --git a/components/whiteboard/index.tsx b/components/whiteboard/index.tsx index e862493..efdd894 100644 --- a/components/whiteboard/index.tsx +++ b/components/whiteboard/index.tsx @@ -1,11 +1,13 @@ 'use client'; -import { useRef } from 'react'; +import { useRef, useState } from 'react'; import { motion, AnimatePresence } from 'motion/react'; -import { Eraser, Minimize2, PencilLine } from 'lucide-react'; +import { Eraser, History, Minimize2, PencilLine } from 'lucide-react'; import { WhiteboardCanvas } from './whiteboard-canvas'; +import { WhiteboardHistory } from './whiteboard-history'; import { useStageStore } from '@/lib/store'; import { useCanvasStore } from '@/lib/store/canvas'; +import { useWhiteboardHistoryStore } from '@/lib/store/whiteboard-history'; import { createStageAPI } from '@/lib/api/stage-api'; import { toast } from 'sonner'; import { useI18n } from '@/lib/hooks/use-i18n'; @@ -23,6 +25,8 @@ export function Whiteboard({ isOpen, onClose }: WhiteboardProps) { const stage = useStageStore.use.stage(); const isClearing = useCanvasStore.use.whiteboardClearing(); const clearingRef = useRef(false); + const [historyOpen, setHistoryOpen] = useState(false); + const snapshotCount = useWhiteboardHistoryStore((s) => s.snapshots.length); // Get element count for indicator const whiteboard = stage?.whiteboard?.[0]; @@ -34,6 +38,13 @@ export function Whiteboard({ isOpen, onClose }: WhiteboardProps) { if (!whiteboard || elementCount === 0 || clearingRef.current) return; clearingRef.current = true; + // Save snapshot before clearing + if (whiteboard.elements && whiteboard.elements.length > 0) { + useWhiteboardHistoryStore + .getState() + .pushSnapshot(whiteboard.elements, t('whiteboard.beforeClear')); + } + // Trigger cascade exit animation useCanvasStore.getState().setWhiteboardClearing(true); @@ -108,6 +119,20 @@ export function Whiteboard({ isOpen, onClose }: WhiteboardProps) { + setHistoryOpen(!historyOpen)} + whileTap={{ scale: 0.9 }} + 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" + title={t('whiteboard.history')} + > + + {snapshotCount > 0 && ( + + {snapshotCount} + + )} +
diff --git a/components/whiteboard/whiteboard-canvas.tsx b/components/whiteboard/whiteboard-canvas.tsx index 72a32dc..73835c9 100644 --- a/components/whiteboard/whiteboard-canvas.tsx +++ b/components/whiteboard/whiteboard-canvas.tsx @@ -1,80 +1,47 @@ 'use client'; -import { useRef, useState, useEffect, useCallback } from 'react'; -import { motion, AnimatePresence } from 'motion/react'; -import { useStageStore } from '@/lib/store'; +import { useRef, useState, useEffect, useCallback, useMemo } from 'react'; +import { motion } from 'motion/react'; +import { useI18n } from '@/lib/i18n/context'; +import { useStageStore } from '@/lib/store/stage'; import { useCanvasStore } from '@/lib/store/canvas'; -import { ScreenElement } from '@/components/slide-renderer/Editor/ScreenElement'; +import { useWhiteboardHistoryStore } from '@/lib/store/whiteboard-history'; import type { PPTElement } from '@/lib/types/slides'; -import { useI18n } from '@/lib/hooks/use-i18n'; +import { ScreenCanvas } from '../canvas/screen-canvas'; /** - * Animated element wrapper + * Compute a fingerprint string for an array of whiteboard elements. + * Includes position, size, and content so that drag/resize changes + * are detected — not only ID additions/removals. */ -function AnimatedElement({ - element, - index, - isClearing, - totalElements, -}: { - element: PPTElement; - index: number; - 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 ( - { + // Grab the core identity + geometry that matters for change detection + const base = `${el.id}:${el.left},${el.top},${el.width},${el.height}`; + // For text elements include a hash of content so edits are detected + if ('content' in el && typeof el.content === 'string') { + return `${base}:${el.content.length}`; } - exit={{ - opacity: 0, - scale: 0.85, - transition: { duration: 0.2 }, - }} - className="absolute inset-0" - style={{ pointerEvents: isClearing ? 'none' : undefined }} - > -
- -
-
- ); + return base; + }) + .join('|'); } /** - * Whiteboard canvas + * WhiteboardCanvas – 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. A `isRestoring` guard prevents a restore + * action from itself triggering a new snapshot. + * + * During a "clear" action (isClearing flag from the canvas store) the + * snapshot is captured synchronously by the clear handler in the parent + * component, so the auto-snapshot effect skips it. */ export function WhiteboardCanvas() { const { t } = useI18n(); @@ -83,84 +50,98 @@ export function WhiteboardCanvas() { const containerRef = useRef(null); const [scale, setScale] = useState(1); - // Get whiteboard elements + // Get whiteboard elements — stabilise reference so useMemo deps don't + // change on every render when elements is the same empty array. const whiteboard = stage?.whiteboard?.[0]; - const elements = whiteboard?.elements || []; + const rawElements = whiteboard?.elements; + const elements = useMemo(() => rawElements ?? [], [rawElements]); + + // Auto-snapshot: save previous elements when content is replaced by AI. + // Debounced (2s) so adding elements one-by-one doesn't spam snapshots. + // Fingerprint includes position/size so drag/resize changes are detected, not just ID changes. + const prevElementsRef = useRef([]); + const elementsKey = useMemo(() => elementFingerprint(elements), [elements]); + const elementsRef = useRef(elements); + useEffect(() => { + elementsRef.current = elements; + }, [elements]); + const snapshotTimerRef = useRef | null>(null); + useEffect(() => { + const prev = prevElementsRef.current; + const prevKey = elementFingerprint(prev); - // Whiteboard fixed size: 1000 x 562.5 (16:9) - const canvasWidth = 1000; - const canvasHeight = 562.5; + // Clear any pending snapshot timer + if (snapshotTimerRef.current) { + clearTimeout(snapshotTimerRef.current); + snapshotTimerRef.current = null; + } - // Responsive scaling: scale canvas proportionally to fill container + if (elementsKey !== prevKey && prev.length > 0 && !isClearing) { + // Content changed and there was previous content — debounce snapshot + snapshotTimerRef.current = setTimeout(() => { + const { isRestoring, addSnapshot } = useWhiteboardHistoryStore.getState(); + if (!isRestoring) { + addSnapshot(prev); + } + // Update prev ref AFTER the snapshot is taken + prevElementsRef.current = elementsRef.current; + }, 2000); + } else { + // No meaningful change or first load — just track + prevElementsRef.current = elements; + } + + return () => { + if (snapshotTimerRef.current) { + clearTimeout(snapshotTimerRef.current); + } + }; + // elementsKey is a stable fingerprint string — no unnecessary re-runs + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [elementsKey, isClearing]); + + // ── Layout: compute scale to fit container ────────────────────── const updateScale = useCallback(() => { - const container = containerRef.current; - if (!container) return; - const { clientWidth, clientHeight } = container; - const scaleX = clientWidth / canvasWidth; - const scaleY = clientHeight / canvasHeight; + if (!containerRef.current) return; + const { clientWidth, clientHeight } = containerRef.current; + if (clientWidth === 0 || clientHeight === 0) return; + const scaleX = clientWidth / 1920; + const scaleY = clientHeight / 1080; setScale(Math.min(scaleX, scaleY)); - }, [canvasWidth, canvasHeight]); + }, []); useEffect(() => { - const container = containerRef.current; - if (!container) return; - const observer = new ResizeObserver(updateScale); - observer.observe(container); updateScale(); - return () => observer.disconnect(); + window.addEventListener('resize', updateScale); + return () => window.removeEventListener('resize', updateScale); }, [updateScale]); - 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) => ( - - ))} - -
+ // ── Render ────────────────────────────────────────────────────── + if (elements.length === 0) { + return ( +
+

{t('stage.whiteboardEmpty')}

+ ); + } + + return ( +
+ + +
); } diff --git a/components/whiteboard/whiteboard-history.tsx b/components/whiteboard/whiteboard-history.tsx new file mode 100644 index 0000000..0015882 --- /dev/null +++ b/components/whiteboard/whiteboard-history.tsx @@ -0,0 +1,161 @@ +'use client'; + +import { useRef, useEffect } from 'react'; +import { motion, AnimatePresence } from 'motion/react'; +import { RotateCcw } from 'lucide-react'; +import { useWhiteboardHistoryStore } from '@/lib/store/whiteboard-history'; +import { useStageStore } from '@/lib/store'; +import { createStageAPI } from '@/lib/api/stage-api'; +import { elementFingerprint } from '@/lib/utils/element-fingerprint'; +import { toast } from 'sonner'; +import { useI18n } from '@/lib/hooks/use-i18n'; + +interface WhiteboardHistoryProps { + readonly isOpen: boolean; + readonly onClose: () => void; +} + +/** + * Whiteboard history dropdown panel. + * Shows a list of saved whiteboard snapshots with timestamps and element counts. + * Clicking "Restore" replaces the current whiteboard content with the snapshot. + */ +export function WhiteboardHistory({ isOpen, onClose }: WhiteboardHistoryProps) { + const { t } = useI18n(); + const snapshots = useWhiteboardHistoryStore((s) => s.snapshots); + const panelRef = useRef(null); + + // Close on outside click + useEffect(() => { + if (!isOpen) return; + const handler = (e: MouseEvent) => { + if (panelRef.current && !panelRef.current.contains(e.target as Node)) { + onClose(); + } + }; + // Delay listener so the click that opens the panel doesn't immediately close it + const id = setTimeout(() => document.addEventListener('mousedown', handler), 0); + return () => { + clearTimeout(id); + document.removeEventListener('mousedown', handler); + }; + }, [isOpen, onClose]); + + const handleRestore = (index: number) => { + const snapshot = useWhiteboardHistoryStore.getState().getSnapshot(index); + if (!snapshot) return; + + const stageStore = useStageStore; + const stageAPI = createStageAPI(stageStore); + + // Get or create whiteboard + const wbResult = stageAPI.whiteboard.get(); + if (!wbResult.success || !wbResult.data) { + return; + } + const whiteboardId = wbResult.data.id; + + const currentElements = wbResult.data.elements || []; + + // Set restoredKey so auto-snapshot skips the incoming change + const restoredElementsKey = elementFingerprint(snapshot.elements); + useWhiteboardHistoryStore.getState().setRestoredKey(restoredElementsKey); + + try { + // Clear existing elements + for (const el of currentElements) { + stageAPI.whiteboard.deleteElement(el.id, whiteboardId); + } + // Add snapshot elements + // Race condition note: addElement loop may cause intermediate renders where + // elementsKey doesn't match restoredKey yet. This is safe because: + // 1. The 2-second debounce in auto-snapshot means intermediate states won't trigger snapshots + // 2. When the loop completes, final elementsKey will match restoredKey and skip the snapshot + // 3. Any pending snapshot timer gets cleared by the next render + for (const el of snapshot.elements) { + stageAPI.whiteboard.addElement(el, whiteboardId); + } + } catch (error) { + // If restore fails, immediately clear restoredKey to prevent it from getting stuck + useWhiteboardHistoryStore.getState().setRestoredKey(null); + console.error('Failed to restore whiteboard snapshot:', error); + toast.error(t('whiteboard.clearError') + String(error)); + return; + } + + toast.success(t('whiteboard.restored')); + onClose(); + }; + + const formatTime = (ts: number) => { + const d = new Date(ts); + return `${d.getHours().toString().padStart(2, '0')}:${d.getMinutes().toString().padStart(2, '0')}:${d.getSeconds().toString().padStart(2, '0')}`; + }; + + return ( + + {isOpen && ( + + {/* Header */} +
+ + {t('whiteboard.history')} + + + {snapshots.length > 0 ? `${snapshots.length}` : ''} + +
+ + {/* Snapshot list */} +
+ {snapshots.length === 0 ? ( +
+ {t('whiteboard.noHistory')} +
+ ) : ( +
+ {[...snapshots].reverse().map((snap, reverseIdx) => { + const realIdx = snapshots.length - 1 - reverseIdx; + return ( +
+
+
+ {snap.label || `#${realIdx + 1}`} +
+
+ {formatTime(snap.timestamp)} ·{' '} + {t('whiteboard.elementCount').replace( + '{count}', + String(snap.elements.length), + )} +
+
+ +
+ ); + })} +
+ )} +
+
+ )} +
+ ); +} diff --git a/lib/i18n/stage.ts b/lib/i18n/stage.ts index 9490061..d5588b9 100644 --- a/lib/i18n/stage.ts +++ b/lib/i18n/stage.ts @@ -17,6 +17,12 @@ export const stageZhCN = { readyHint: 'AI 添加元素后将在此显示', clearSuccess: '白板已清空', clearError: '清空白板失败:', + history: '历史记录', + restore: '恢复', + beforeClear: '清除前', + noHistory: '暂无历史记录', + restored: '已恢复白板内容', + elementCount: '{count} 个元素', }, quiz: { title: '随堂测验', @@ -153,6 +159,12 @@ export const stageEnUS = { readyHint: 'Elements will appear here when added by AI', clearSuccess: 'Whiteboard cleared successfully', clearError: 'Failed to clear whiteboard: ', + history: 'History', + restore: 'Restore', + beforeClear: 'Before clear', + noHistory: 'No history yet', + restored: 'Whiteboard restored', + elementCount: '{count} elements', }, quiz: { title: 'Quiz', diff --git a/lib/store/whiteboard-history.ts b/lib/store/whiteboard-history.ts new file mode 100644 index 0000000..1e79af1 --- /dev/null +++ b/lib/store/whiteboard-history.ts @@ -0,0 +1,84 @@ +/** + * Whiteboard History Store + * + * Lightweight in-memory store that saves snapshots of whiteboard elements + * before destructive operations (clear, replace). Allows users to browse + * and restore previous whiteboard states. + * + * History is per-session (not persisted to IndexedDB) to keep things simple. + */ + +import { create } from 'zustand'; +import type { PPTElement } from '@/lib/types/slides'; +import { elementFingerprint } from '@/lib/utils/element-fingerprint'; + +export interface WhiteboardSnapshot { + /** Deep copy of whiteboard elements at the time of capture */ + elements: PPTElement[]; + /** Timestamp when the snapshot was taken */ + timestamp: number; + /** Human-readable label, e.g. "清除前", "Step 3" */ + label?: string; +} + +interface WhiteboardHistoryState { + /** Stack of snapshots, newest last */ + snapshots: WhiteboardSnapshot[]; + /** Maximum number of snapshots to keep */ + maxSnapshots: number; + /** elementsKey of a just-restored snapshot; used to skip auto-snapshot once */ + restoredKey: string | null; + + // Actions + /** Save a snapshot of the current whiteboard elements */ + pushSnapshot: (elements: PPTElement[], label?: string) => void; + /** Get a snapshot by index */ + getSnapshot: (index: number) => WhiteboardSnapshot | null; + /** Clear all history */ + clearHistory: () => void; + /** Set the restored key (elementsKey of the snapshot being restored) */ + setRestoredKey: (key: string | null) => void; +} + +export const useWhiteboardHistoryStore = create((set, get) => ({ + snapshots: [], + maxSnapshots: 20, + restoredKey: null, + + pushSnapshot: (elements, label) => { + // Don't save empty snapshots + if (!elements || elements.length === 0) return; + + // Deduplication: skip if identical to the latest snapshot + + const { snapshots } = get(); + if (snapshots.length > 0) { + const latestFp = elementFingerprint(snapshots[snapshots.length - 1].elements); + const newFp = elementFingerprint(elements); + if (latestFp === newFp) return; + } + + const snapshot: WhiteboardSnapshot = { + elements: JSON.parse(JSON.stringify(elements)), // Deep copy + timestamp: Date.now(), + label, + }; + + set((state) => { + const newSnapshots = [...state.snapshots, snapshot]; + // Enforce limit — drop oldest + if (newSnapshots.length > state.maxSnapshots) { + return { snapshots: newSnapshots.slice(-state.maxSnapshots) }; + } + return { snapshots: newSnapshots }; + }); + }, + + getSnapshot: (index) => { + const { snapshots } = get(); + return snapshots[index] ?? null; + }, + + clearHistory: () => set({ snapshots: [] }), + setRestoredKey: (key) => set({ restoredKey: key }), +})); diff --git a/lib/utils/element-fingerprint.ts b/lib/utils/element-fingerprint.ts new file mode 100644 index 0000000..bf4a0a7 --- /dev/null +++ b/lib/utils/element-fingerprint.ts @@ -0,0 +1,20 @@ +import type { PPTElement } from '@/lib/types/slides'; + +/** + * Generate a fingerprint string for a list of whiteboard elements. + * Used for change detection and deduplication in history snapshots. + * + * Includes id, position, and size so that drag/resize changes are detected, + * not just element additions/removals. + * + * Note: PPTLineElement omits `height` from PPTBaseElement, so we use + * a type guard instead of a direct property access. + */ +export function elementFingerprint(els: PPTElement[]): string { + return els + .map( + (e) => + `${e.id}:${e.left ?? 0},${e.top ?? 0},${'width' in e ? e.width : 0},${'height' in e && e.height != null ? e.height : 0}`, + ) + .join('|'); +}