diff --git a/components/whiteboard/index.tsx b/components/whiteboard/index.tsx index efdd8946..3083bf89 100644 --- a/components/whiteboard/index.tsx +++ b/components/whiteboard/index.tsx @@ -26,7 +26,10 @@ export function Whiteboard({ isOpen, onClose }: WhiteboardProps) { const isClearing = useCanvasStore.use.whiteboardClearing(); const clearingRef = useRef(false); const [historyOpen, setHistoryOpen] = useState(false); - const snapshotCount = useWhiteboardHistoryStore((s) => s.snapshots.length); + const stageId = stage?.id; + const snapshotCount = useWhiteboardHistoryStore((s) => + stageId ? (s.snapshotsByStage[stageId]?.length ?? 0) : 0, + ); // Get element count for indicator const whiteboard = stage?.whiteboard?.[0]; @@ -39,10 +42,10 @@ export function Whiteboard({ isOpen, onClose }: WhiteboardProps) { clearingRef.current = true; // Save snapshot before clearing - if (whiteboard.elements && whiteboard.elements.length > 0) { + if (stageId && whiteboard.elements && whiteboard.elements.length > 0) { useWhiteboardHistoryStore .getState() - .pushSnapshot(whiteboard.elements, t('whiteboard.beforeClear')); + .pushSnapshot(stageId, whiteboard.elements, t('whiteboard.beforeClear')); } // Trigger cascade exit animation diff --git a/components/whiteboard/whiteboard-canvas.tsx b/components/whiteboard/whiteboard-canvas.tsx index e07b5f49..ccdd3bc8 100644 --- a/components/whiteboard/whiteboard-canvas.tsx +++ b/components/whiteboard/whiteboard-canvas.tsx @@ -462,7 +462,10 @@ export function WhiteboardCanvas() { snapshotTimerRef.current = setTimeout(() => { const current = elementsRef.current; if (current.length > 0) { - useWhiteboardHistoryStore.getState().pushSnapshot(current); + const stageId = useStageStore.getState().stage?.id; + if (stageId) { + useWhiteboardHistoryStore.getState().pushSnapshot(stageId, current); + } } }, 2000); diff --git a/components/whiteboard/whiteboard-history.tsx b/components/whiteboard/whiteboard-history.tsx index ef925933..fdf82168 100644 --- a/components/whiteboard/whiteboard-history.tsx +++ b/components/whiteboard/whiteboard-history.tsx @@ -1,6 +1,6 @@ 'use client'; -import { useRef, useEffect } from 'react'; +import { useRef, useEffect, useMemo } from 'react'; import { motion, AnimatePresence } from 'motion/react'; import { RotateCcw } from 'lucide-react'; import { useWhiteboardHistoryStore } from '@/lib/store/whiteboard-history'; @@ -23,7 +23,12 @@ interface WhiteboardHistoryProps { */ export function WhiteboardHistory({ isOpen, onClose }: WhiteboardHistoryProps) { const { t } = useI18n(); - const snapshots = useWhiteboardHistoryStore((s) => s.snapshots); + const stage = useStageStore.use.stage(); + const stageId = stage?.id; + const rawSnapshots = useWhiteboardHistoryStore((s) => + stageId ? s.snapshotsByStage[stageId] : undefined, + ); + const snapshots = useMemo(() => rawSnapshots ?? [], [rawSnapshots]); const isClearing = useCanvasStore.use.whiteboardClearing(); const panelRef = useRef(null); @@ -51,7 +56,9 @@ export function WhiteboardHistory({ isOpen, onClose }: WhiteboardHistoryProps) { return; } - const snapshot = useWhiteboardHistoryStore.getState().getSnapshot(index); + if (!stageId) return; + + const snapshot = useWhiteboardHistoryStore.getState().getSnapshot(stageId, index); if (!snapshot) return; const stageStore = useStageStore; diff --git a/lib/action/engine.ts b/lib/action/engine.ts index d2d38316..26d7ed16 100644 --- a/lib/action/engine.ts +++ b/lib/action/engine.ts @@ -501,9 +501,12 @@ export class ActionEngine { if (elementCount === 0) return; // Save snapshot before AI clear (mirrors UI handleClear in index.tsx) - useWhiteboardHistoryStore - .getState() - .pushSnapshot(wb.data.elements!, getClientTranslation('whiteboard.beforeAIClear')); + const stageId = this.stageStore.getState().stage?.id; + if (stageId) { + useWhiteboardHistoryStore + .getState() + .pushSnapshot(stageId, wb.data.elements!, getClientTranslation('whiteboard.beforeAIClear')); + } // Trigger cascade exit animation useCanvasStore.getState().setWhiteboardClearing(true); diff --git a/lib/store/whiteboard-history.ts b/lib/store/whiteboard-history.ts index a1445704..f38b1292 100644 --- a/lib/store/whiteboard-history.ts +++ b/lib/store/whiteboard-history.ts @@ -1,14 +1,14 @@ /** * 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. + * Saves snapshots of whiteboard elements before destructive operations + * (clear, replace). Allows users to browse and restore previous states. * - * History is per-session (not persisted to IndexedDB) to keep things simple. + * History is scoped per-stage (stageId) and persisted to localStorage. */ import { create } from 'zustand'; +import { persist } from 'zustand/middleware'; import type { PPTElement } from '@/lib/types/slides'; import { elementFingerprint } from '@/lib/utils/element-fingerprint'; @@ -24,61 +24,92 @@ export interface WhiteboardSnapshot { } interface WhiteboardHistoryState { - /** Stack of snapshots, newest last */ - snapshots: WhiteboardSnapshot[]; - /** Maximum number of snapshots to keep */ + /** Snapshots grouped by stageId, newest last within each group */ + snapshotsByStage: Record; + /** Maximum number of snapshots to keep per stage */ 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; + /** Save a snapshot of the current whiteboard elements for a given stage */ + pushSnapshot: (stageId: string, elements: PPTElement[], label?: string) => void; + /** Get all snapshots for a given stage */ + getSnapshots: (stageId: string) => WhiteboardSnapshot[]; + /** Get a snapshot by stage and index */ + getSnapshot: (stageId: string, index: number) => WhiteboardSnapshot | null; + /** Clear history. If stageId is provided, clear only that stage; otherwise clear all. */ + clearHistory: (stageId?: string) => 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, +export const useWhiteboardHistoryStore = create()( + persist( + (set, get) => ({ + snapshotsByStage: {}, + maxSnapshots: 20, + restoredKey: null, - pushSnapshot: (elements, label) => { - // Don't save empty snapshots - if (!elements || elements.length === 0) return; + pushSnapshot: (stageId, elements, label) => { + // Don't save empty snapshots + if (!stageId || !elements || elements.length === 0) return; - const { snapshots } = get(); - const newFingerprint = elementFingerprint(elements); - if (snapshots.length > 0 && snapshots[snapshots.length - 1].fingerprint === newFingerprint) { - return; - } + const { snapshotsByStage, maxSnapshots } = get(); + const stageSnapshots = snapshotsByStage[stageId] ?? []; + const newFingerprint = elementFingerprint(elements); - const snapshot: WhiteboardSnapshot = { - elements: JSON.parse(JSON.stringify(elements)), // Deep copy - timestamp: Date.now(), - label, - fingerprint: newFingerprint, - }; + // Skip duplicate consecutive snapshots + if ( + stageSnapshots.length > 0 && + stageSnapshots[stageSnapshots.length - 1].fingerprint === newFingerprint + ) { + return; + } - set((state) => { - const newSnapshots = [...state.snapshots, snapshot]; - // Enforce limit: drop oldest snapshots first. - if (newSnapshots.length > state.maxSnapshots) { - return { snapshots: newSnapshots.slice(-state.maxSnapshots) }; - } - return { snapshots: newSnapshots }; - }); - }, + const snapshot: WhiteboardSnapshot = { + elements: JSON.parse(JSON.stringify(elements)), // Deep copy + timestamp: Date.now(), + label, + fingerprint: newFingerprint, + }; - getSnapshot: (index) => { - const { snapshots } = get(); - return snapshots[index] ?? null; - }, + const newSnapshots = [...stageSnapshots, snapshot]; + const trimmed = + newSnapshots.length > maxSnapshots ? newSnapshots.slice(-maxSnapshots) : newSnapshots; - clearHistory: () => set({ snapshots: [], restoredKey: null }), - setRestoredKey: (key) => set({ restoredKey: key }), -})); + set({ + snapshotsByStage: { + ...snapshotsByStage, + [stageId]: trimmed, + }, + }); + }, + + getSnapshots: (stageId) => { + return get().snapshotsByStage[stageId] ?? []; + }, + + getSnapshot: (stageId, index) => { + const stageSnapshots = get().snapshotsByStage[stageId] ?? []; + return stageSnapshots[index] ?? null; + }, + + clearHistory: (stageId?) => { + if (stageId) { + const { snapshotsByStage } = get(); + const { [stageId]: _, ...rest } = snapshotsByStage; + set({ snapshotsByStage: rest, restoredKey: null }); + } else { + set({ snapshotsByStage: {}, restoredKey: null }); + } + }, + + setRestoredKey: (key) => set({ restoredKey: key }), + }), + { + name: 'openmaic-whiteboard-history', + partialize: (state) => ({ snapshotsByStage: state.snapshotsByStage }), + }, + ), +); diff --git a/lib/utils/database.ts b/lib/utils/database.ts index 62420bad..95d8655e 100644 --- a/lib/utils/database.ts +++ b/lib/utils/database.ts @@ -47,6 +47,7 @@ export interface StageRecord { language?: string; style?: string; currentSceneId?: string; + whiteboard?: Whiteboard[]; // Whiteboard data (non-indexed) } /** diff --git a/lib/utils/stage-storage.ts b/lib/utils/stage-storage.ts index 74c0f32b..7d019eb7 100644 --- a/lib/utils/stage-storage.ts +++ b/lib/utils/stage-storage.ts @@ -47,6 +47,7 @@ export async function saveStageData(stageId: string, data: StageStoreData): Prom language: data.stage.language, style: data.stage.style, currentSceneId: data.currentSceneId || undefined, + whiteboard: data.stage.whiteboard, }); // Delete old scenes first to avoid orphaned data