diff --git a/app/page.tsx b/app/page.tsx index 80dfbd8..610e740 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -263,26 +263,42 @@ function HomePage() { let pdfFileName: string | undefined; let pdfProviderId: string | undefined; let pdfProviderConfig: { apiKey?: string; baseUrl?: string } | undefined; + let directText = ''; if (form.pdfFile) { - pdfStorageKey = await storePdfBlob(form.pdfFile); - pdfFileName = form.pdfFile.name; - - const settings = useSettingsStore.getState(); - pdfProviderId = settings.pdfProviderId; - const providerCfg = settings.pdfProvidersConfig?.[settings.pdfProviderId]; - if (providerCfg) { - pdfProviderConfig = { - apiKey: providerCfg.apiKey, - baseUrl: providerCfg.baseUrl, - }; + const ext = form.pdfFile.name.split('.').pop()?.toLowerCase() ?? ''; + const isTextFile = ['md', 'txt', 'markdown'].includes(ext); + + if (isTextFile) { + // Text-based files: read content directly, skip OCR pipeline + directText = await new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onload = () => resolve(reader.result as string); + reader.onerror = () => reject(new Error('Failed to read file')); + reader.readAsText(form.pdfFile!); + }); + pdfFileName = form.pdfFile.name; + } else { + // PDF files: store blob for later OCR parsing + pdfStorageKey = await storePdfBlob(form.pdfFile); + pdfFileName = form.pdfFile.name; + + const settings = useSettingsStore.getState(); + pdfProviderId = settings.pdfProviderId; + const providerCfg = settings.pdfProvidersConfig?.[settings.pdfProviderId]; + if (providerCfg) { + pdfProviderConfig = { + apiKey: providerCfg.apiKey, + baseUrl: providerCfg.baseUrl, + }; + } } } const sessionState = { sessionId: nanoid(), requirements, - pdfText: '', + pdfText: directText, pdfImages: [], imageStorageIds: [], pdfStorageKey, diff --git a/components/generation/generation-toolbar.tsx b/components/generation/generation-toolbar.tsx index 27301bb..6495981 100644 --- a/components/generation/generation-toolbar.tsx +++ b/components/generation/generation-toolbar.tsx @@ -25,6 +25,16 @@ import { MediaPopover } from '@/components/generation/media-popover'; // ─── Constants ─────────────────────────────────────────────── const MAX_PDF_SIZE_MB = 50; const MAX_PDF_SIZE_BYTES = MAX_PDF_SIZE_MB * 1024 * 1024; +const ACCEPTED_EXTENSIONS = '.pdf,.md,.txt,.markdown'; + +/** Check whether a File is one of our supported document types */ +function isSupportedFile(file: File): boolean { + const ext = file.name.split('.').pop()?.toLowerCase() ?? ''; + if (['md', 'txt', 'markdown'].includes(ext)) return true; + if (ext === 'pdf' || file.type === 'application/pdf') return true; + if (file.type.startsWith('text/')) return true; + return false; +} // ─── Types ─────────────────────────────────────────────────── export interface GenerationToolbarProps { @@ -98,7 +108,7 @@ export function GenerationToolbar({ // PDF handler const handleFileSelect = (file: File) => { - if (file.type !== 'application/pdf') return; + if (!isSupportedFile(file)) return; if (file.size > MAX_PDF_SIZE_BYTES) { onPdfError(t('upload.fileTooLarge')); return; @@ -212,7 +222,7 @@ export function GenerationToolbar({ type="file" ref={fileInputRef} className="hidden" - accept=".pdf" + accept={ACCEPTED_EXTENSIONS} onChange={(e) => { const f = e.target.files?.[0]; if (f) handleFileSelect(f); @@ -261,7 +271,7 @@ export function GenerationToolbar({ }} > -

{t('toolbar.pdfUpload')}

+

{t('toolbar.docUpload')}

{t('upload.pdfSizeLimit')}

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..c73d163 100644 --- a/components/whiteboard/whiteboard-canvas.tsx +++ b/components/whiteboard/whiteboard-canvas.tsx @@ -1,9 +1,10 @@ 'use client'; -import { useRef, useState, useEffect, useCallback } from 'react'; +import { useRef, useState, useEffect, useCallback, useMemo } from 'react'; import { motion, AnimatePresence } from 'motion/react'; import { useStageStore } from '@/lib/store'; import { useCanvasStore } from '@/lib/store/canvas'; +import { useWhiteboardHistoryStore } from '@/lib/store/whiteboard-history'; import { ScreenElement } from '@/components/slide-renderer/Editor/ScreenElement'; import type { PPTElement } from '@/lib/types/slides'; import { useI18n } from '@/lib/hooks/use-i18n'; @@ -74,41 +75,214 @@ function AnimatedElement({ } /** - * Whiteboard canvas + * Whiteboard canvas with pan & zoom support. + * + * Features: + * - Responsive scaling to fill the container + * - Auto-fit: when elements overflow the canvas, content is scaled & centered + * so nothing is clipped + * - Drag-to-pan: hold mouse and drag to pan around the whiteboard + * - Scroll-to-zoom: use the scroll wheel to zoom in/out (centered on cursor) + * - Double-click to reset view to auto-fit */ export function WhiteboardCanvas() { const { t } = useI18n(); const stage = useStageStore.use.stage(); const isClearing = useCanvasStore.use.whiteboardClearing(); const containerRef = useRef(null); - const [scale, setScale] = useState(1); + const [containerScale, setContainerScale] = useState(1); // Get whiteboard elements const whiteboard = stage?.whiteboard?.[0]; const elements = whiteboard?.elements || []; + // Auto-snapshot: save previous elements when content is replaced by AI. + // Debounced (2s) so adding elements one-by-one doesn't spam snapshots. + const prevElementsRef = useRef([]); + const elementsKey = elements.map((e) => e.id).join(','); + const snapshotTimerRef = useRef | null>(null); + useEffect(() => { + const prev = prevElementsRef.current; + const prevKey = prev.map((e) => e.id).join(','); + + // Clear any pending snapshot timer + if (snapshotTimerRef.current) { + clearTimeout(snapshotTimerRef.current); + snapshotTimerRef.current = null; + } + + // Only snapshot when old content was non-empty and IDs actually changed + // Skip if we're in the middle of a restore operation + const { isRestoring } = useWhiteboardHistoryStore.getState(); + if (prev.length > 0 && prevKey !== elementsKey && elementsKey !== '' && !isRestoring) { + // Capture prev in closure before it's overwritten + const elementsToSave = prev; + snapshotTimerRef.current = setTimeout(() => { + useWhiteboardHistoryStore.getState().pushSnapshot(elementsToSave); + snapshotTimerRef.current = null; + }, 2000); + } + prevElementsRef.current = elements; + + return () => { + if (snapshotTimerRef.current) { + clearTimeout(snapshotTimerRef.current); + } + }; + }, [elementsKey, elements]); + // Whiteboard fixed size: 1000 x 562.5 (16:9) const canvasWidth = 1000; const canvasHeight = 562.5; - // Responsive scaling: scale canvas proportionally to fill container - const updateScale = useCallback(() => { + // ==================== Responsive container scaling ==================== + const updateContainerScale = useCallback(() => { const container = containerRef.current; if (!container) return; const { clientWidth, clientHeight } = container; const scaleX = clientWidth / canvasWidth; const scaleY = clientHeight / canvasHeight; - setScale(Math.min(scaleX, scaleY)); + setContainerScale(Math.min(scaleX, scaleY)); }, [canvasWidth, canvasHeight]); useEffect(() => { const container = containerRef.current; if (!container) return; - const observer = new ResizeObserver(updateScale); + const observer = new ResizeObserver(updateContainerScale); observer.observe(container); - updateScale(); + updateContainerScale(); return () => observer.disconnect(); - }, [updateScale]); + }, [updateContainerScale]); + + // ==================== Auto-fit: compute initial transform ==================== + const PADDING = 24; // px padding inside the canvas edges + + const autoFitTransform = useMemo(() => { + if (elements.length === 0) return { scale: 1, tx: 0, ty: 0 }; + + // Compute bounding box of all elements + let minX = Infinity, + minY = Infinity, + maxX = -Infinity, + maxY = -Infinity; + for (const el of elements) { + const left = el.left ?? 0; + const top = el.top ?? 0; + const width = 'width' in el ? (el.width ?? 0) : 0; + const height = 'height' in el ? (el.height ?? 0) : 0; + minX = Math.min(minX, left); + minY = Math.min(minY, top); + maxX = Math.max(maxX, left + width); + maxY = Math.max(maxY, top + height); + } + + const contentWidth = maxX - minX; + const contentHeight = maxY - minY; + + // Only apply fit when content actually overflows the canvas + const overflowsX = minX < 0 || maxX > canvasWidth; + const overflowsY = minY < 0 || maxY > canvasHeight; + if (!overflowsX && !overflowsY) return { scale: 1, tx: 0, ty: 0 }; + + // Scale down to fit all content within the canvas (with padding) + const availW = canvasWidth - PADDING * 2; + const availH = canvasHeight - PADDING * 2; + const fitScale = Math.min(1, availW / contentWidth, availH / contentHeight); + + // Center the content + const scaledW = contentWidth * fitScale; + const scaledH = contentHeight * fitScale; + const tx = (canvasWidth - scaledW) / 2 - minX * fitScale; + const ty = (canvasHeight - scaledH) / 2 - minY * fitScale; + + return { scale: fitScale, tx, ty }; + }, [elements, canvasWidth, canvasHeight]); + + // ==================== Pan & Zoom state ==================== + const [viewZoom, setViewZoom] = useState(1); // user zoom (relative to auto-fit) + const [panX, setPanX] = useState(0); // user pan offset in canvas-space px + const [panY, setPanY] = useState(0); + const isPanningRef = useRef(false); + const panStartRef = useRef({ x: 0, y: 0, panX: 0, panY: 0 }); + const [isResetting, setIsResetting] = useState(false); + + // Reset view only when whiteboard content actually changes (not on every re-render). + // elementsKey is defined above (auto-snapshot section) + useEffect(() => { + setViewZoom(1); + setPanX(0); + setPanY(0); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [elementsKey]); + + // ---- Drag-to-pan handlers ---- + const handlePointerDown = useCallback( + (e: React.PointerEvent) => { + // Only pan with left mouse button or single touch + if (e.button !== 0) return; + e.preventDefault(); // prevent text selection + isPanningRef.current = 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 (!isPanningRef.current) return; + const dx = e.clientX - panStartRef.current.x; + const dy = e.clientY - panStartRef.current.y; + // Divide by containerScale so pan distance is consistent regardless of container size + const effectiveScale = containerScale * autoFitTransform.scale * viewZoom; + setPanX(panStartRef.current.panX + dx / effectiveScale); + setPanY(panStartRef.current.panY + dy / effectiveScale); + }, + [containerScale, autoFitTransform.scale, viewZoom], + ); + + const handlePointerUp = useCallback(() => { + isPanningRef.current = false; + }, []); + + // ---- Scroll-to-zoom: must use native listener for { passive: false } ---- + const canvasRef = useRef(null); + + useEffect(() => { + const el = canvasRef.current; + if (!el) return; + const onWheel = (e: WheelEvent) => { + e.preventDefault(); + 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); + }, []); + + // ---- Double-click to reset (with smooth animation) ---- + const handleDoubleClick = useCallback((e: React.MouseEvent) => { + e.preventDefault(); // prevent text selection + setIsResetting(true); + setViewZoom(1); + setPanX(0); + setPanY(0); + setTimeout(() => setIsResetting(false), 250); + }, []); + + // ==================== Computed content transform ==================== + // Combines auto-fit + user pan/zoom into a single CSS transform + const contentTransform = useMemo(() => { + const s = autoFitTransform.scale * viewZoom; + const tx = autoFitTransform.tx + panX; + const ty = autoFitTransform.ty + panY; + return `translate(${tx}px, ${ty}px) scale(${s})`; + }, [autoFitTransform, viewZoom, panX, panY]); + + // Whether the user has modified the view (show reset hint) + const isViewModified = viewZoom !== 1 || panX !== 0 || panY !== 0; + // Whether content overflows (auto-fit is active) + const hasOverflow = autoFitTransform.scale < 1; return (
{/* Layout wrapper: its size matches the scaled visual size so flex centering works correctly */} -
+
{/* Placeholder when empty and not mid-clear */} @@ -147,17 +332,48 @@ export function WhiteboardCanvas() { )} - {/* Elements — always rendered so AnimatePresence can track exits */} - - {elements.map((element, index) => ( - - ))} + {/* Content layer — auto-fit + user pan/zoom applied */} +
+ {/* Elements — always rendered so AnimatePresence can track exits */} + + {elements.map((element, index) => ( + + ))} + +
+ + {/* Reset hint — shown when view is modified */} + + {isViewModified && ( + { + e.stopPropagation(); + handleDoubleClick(e as unknown as React.MouseEvent); + }} + 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" + > + {t('whiteboard.resetView') ?? 'Reset View'} + + )}
diff --git a/components/whiteboard/whiteboard-history.tsx b/components/whiteboard/whiteboard-history.tsx new file mode 100644 index 0000000..d08a4a5 --- /dev/null +++ b/components/whiteboard/whiteboard-history.tsx @@ -0,0 +1,152 @@ +'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 { 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; + + // Suppress auto-snapshot during restore + useWhiteboardHistoryStore.getState().setRestoring(true); + + const stageStore = useStageStore; + const stageAPI = createStageAPI(stageStore); + + // Get or create whiteboard + const wbResult = stageAPI.whiteboard.get(); + if (!wbResult.success || !wbResult.data) { + useWhiteboardHistoryStore.getState().setRestoring(false); + return; + } + const whiteboardId = wbResult.data.id; + + // Clear existing elements + const currentElements = wbResult.data.elements || []; + for (const el of currentElements) { + stageAPI.whiteboard.deleteElement(el.id, whiteboardId); + } + + // Add snapshot elements + for (const el of snapshot.elements) { + stageAPI.whiteboard.addElement(el, whiteboardId); + } + + // Re-enable auto-snapshot after a tick (let the effect run and skip) + setTimeout(() => { + useWhiteboardHistoryStore.getState().setRestoring(false); + }, 100); + + 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/common.ts b/lib/i18n/common.ts index 1bceb5d..d7b77e5 100644 --- a/lib/i18n/common.ts +++ b/lib/i18n/common.ts @@ -13,6 +13,7 @@ export const commonZhCN = { languageHint: '课程将以此语言生成', pdfParser: '解析器', pdfUpload: '上传 PDF', + docUpload: '上传文档', removePdf: '移除文件', webSearchOn: '已开启', webSearchOff: '点击开启', @@ -54,6 +55,7 @@ export const commonEnUS = { languageHint: 'Course will be generated in this language', pdfParser: 'Parser', pdfUpload: 'Upload PDF', + docUpload: 'Upload Document', removePdf: 'Remove file', webSearchOn: 'Enabled', webSearchOff: 'Click to enable', diff --git a/lib/i18n/generation.ts b/lib/i18n/generation.ts index 1ed17f3..be91a2e 100644 --- a/lib/i18n/generation.ts +++ b/lib/i18n/generation.ts @@ -10,12 +10,12 @@ export const generationZhCN = { delete: '删除', }, upload: { - pdfSizeLimit: '支持最大50MB的PDF文件', + pdfSizeLimit: '支持 PDF / Markdown / TXT 文件,最大50MB', generateFailed: '生成课堂失败,请重试', requirementPlaceholder: '输入你想学的任何内容,例如:\n「从零学 Python,30 分钟写出第一个程序」\n「用白板给我讲解傅里叶变换」\n「阿瓦隆桌游怎么玩」', requirementRequired: '请输入课程需求', - fileTooLarge: '文件过大,请选择小于50MB的PDF文件', + fileTooLarge: '文件过大,请选择小于50MB的文件', }, generation: { // Progress steps (used dynamically via activeStep) @@ -74,12 +74,12 @@ export const generationEnUS = { delete: 'Delete', }, upload: { - pdfSizeLimit: 'Supports PDF files up to 50MB', + pdfSizeLimit: 'Supports PDF / Markdown / TXT files up to 50MB', generateFailed: 'Failed to generate classroom, please try again', requirementPlaceholder: 'Tell me anything you want to learn, e.g.\n"Teach me Python from scratch in 30 minutes"\n"Explain Fourier Transform on the whiteboard"\n"How to play the board game Avalon"', requirementRequired: 'Please enter course requirements', - fileTooLarge: 'File too large. Please select a PDF file smaller than 50MB', + fileTooLarge: 'File too large. Please select a file smaller than 50MB', }, generation: { // Progress steps (used dynamically via activeStep) diff --git a/lib/i18n/stage.ts b/lib/i18n/stage.ts index 9490061..2de0a86 100644 --- a/lib/i18n/stage.ts +++ b/lib/i18n/stage.ts @@ -17,6 +17,13 @@ export const stageZhCN = { readyHint: 'AI 添加元素后将在此显示', clearSuccess: '白板已清空', clearError: '清空白板失败:', + resetView: '重置视图', + history: '历史记录', + restore: '恢复', + beforeClear: '清除前', + noHistory: '暂无历史记录', + restored: '已恢复白板内容', + elementCount: '{count} 个元素', }, quiz: { title: '随堂测验', @@ -153,6 +160,13 @@ export const stageEnUS = { readyHint: 'Elements will appear here when added by AI', clearSuccess: 'Whiteboard cleared successfully', clearError: 'Failed to clear whiteboard: ', + resetView: 'Reset View', + 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..887bead --- /dev/null +++ b/lib/store/whiteboard-history.ts @@ -0,0 +1,74 @@ +/** + * 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'; + +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; + /** Flag to suppress auto-snapshot during restore */ + isRestoring: boolean; + + // 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 restoring flag */ + setRestoring: (value: boolean) => void; +} + +export const useWhiteboardHistoryStore = create((set, get) => ({ + snapshots: [], + maxSnapshots: 20, + isRestoring: false, + + pushSnapshot: (elements, label) => { + // Don't save empty snapshots + if (!elements || elements.length === 0) 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: [] }), + setRestoring: (value) => set({ isRestoring: value }), +}));