Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 6 additions & 3 deletions components/whiteboard/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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];
Expand All @@ -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
Expand Down
5 changes: 4 additions & 1 deletion components/whiteboard/whiteboard-canvas.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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);

Expand Down
13 changes: 10 additions & 3 deletions components/whiteboard/whiteboard-history.tsx
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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<HTMLDivElement>(null);

Expand Down Expand Up @@ -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;
Expand Down
9 changes: 6 additions & 3 deletions lib/action/engine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
125 changes: 78 additions & 47 deletions lib/store/whiteboard-history.ts
Original file line number Diff line number Diff line change
@@ -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';

Expand All @@ -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<string, WhiteboardSnapshot[]>;
/** 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<WhiteboardHistoryState>((set, get) => ({
snapshots: [],
maxSnapshots: 20,
restoredKey: null,
export const useWhiteboardHistoryStore = create<WhiteboardHistoryState>()(
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 }),
},
),
);
1 change: 1 addition & 0 deletions lib/utils/database.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ export interface StageRecord {
language?: string;
style?: string;
currentSceneId?: string;
whiteboard?: Whiteboard[]; // Whiteboard data (non-indexed)
}

/**
Expand Down
1 change: 1 addition & 0 deletions lib/utils/stage-storage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading