From ff9f82b741944083d004976abfaa17532f4a37ed Mon Sep 17 00:00:00 2001 From: Yizuki_Ame Date: Wed, 18 Mar 2026 05:26:57 +0800 Subject: [PATCH 1/4] feat: add whiteboard history and auto-save (closes #32) - New whiteboard-history.tsx: browseable history panel UI - New whiteboard-history.ts: Zustand store with snapshot stack, fingerprint-based deduplication, and restoredKey one-shot guard - New element-fingerprint.ts: shared fingerprint utility (id+position+size) - whiteboard-canvas.tsx: auto-snapshot effect that saves the current stable state after 2s debounce; unified fingerprint import; useMemo for elements - index.tsx: pushSnapshot before UI clear; history panel trigger button - stage.ts: i18n keys for history UI (zh/en) --- components/whiteboard/index.tsx | 32 +++- components/whiteboard/whiteboard-canvas.tsx | 74 ++++++++- components/whiteboard/whiteboard-history.tsx | 149 +++++++++++++++++++ lib/i18n/stage.ts | 12 ++ lib/store/whiteboard-history.ts | 84 +++++++++++ lib/utils/element-fingerprint.ts | 20 +++ 6 files changed, 364 insertions(+), 7 deletions(-) create mode 100644 components/whiteboard/whiteboard-history.tsx create mode 100644 lib/store/whiteboard-history.ts create mode 100644 lib/utils/element-fingerprint.ts 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..ad62f4f 100644 --- a/components/whiteboard/whiteboard-canvas.tsx +++ b/components/whiteboard/whiteboard-canvas.tsx @@ -1,13 +1,18 @@ '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 { elementFingerprint } from '@/lib/utils/element-fingerprint'; import type { PPTElement } from '@/lib/types/slides'; import { useI18n } from '@/lib/hooks/use-i18n'; +// elementFingerprint is imported from @/lib/utils/element-fingerprint +// to ensure consistent fingerprinting across canvas, history, and store. + /** * Animated element wrapper */ @@ -74,7 +79,14 @@ function AnimatedElement({ } /** - * Whiteboard canvas + * 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(); @@ -85,13 +97,64 @@ export function WhiteboardCanvas() { // Get whiteboard elements const whiteboard = stage?.whiteboard?.[0]; - const elements = whiteboard?.elements || []; + 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 lastSnapshotKeyRef = useRef(''); + const elementsKey = useMemo(() => elementFingerprint(elements), [elements]); + const elementsRef = useRef(elements); + 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; + } + + // Don't snapshot empty states or during clearing animation + if (elements.length === 0 || isClearing) return; + + // 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; + } + + // Don't snapshot if the content matches the last snapshot we took + if (elementsKey === lastSnapshotKeyRef.current) 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); + lastSnapshotKeyRef.current = elementFingerprint(current); + } + }, 2000); + + return () => { + if (snapshotTimerRef.current) { + clearTimeout(snapshotTimerRef.current); + } + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [elementsKey, isClearing]); - // Whiteboard fixed size: 1000 x 562.5 (16:9) + // ── Layout: 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(() => { const container = containerRef.current; if (!container) return; @@ -110,6 +173,7 @@ export function WhiteboardCanvas() { return () => observer.disconnect(); }, [updateScale]); + // ── Render ────────────────────────────────────────────────────── return (
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; + + // Set restoredKey so auto-snapshot skips the incoming change + const restoredElementsKey = elementFingerprint(snapshot.elements); + useWhiteboardHistoryStore.getState().setRestoredKey(restoredElementsKey); + + // Transactional restore: replace all elements in one update() call + // instead of looping delete/add which produces intermediate states. + const result = stageAPI.whiteboard.update({ elements: snapshot.elements }, whiteboardId); + + if (!result.success) { + // Restore failed — clear restoredKey so auto-snapshot isn't stuck + useWhiteboardHistoryStore.getState().setRestoredKey(null); + console.error('Failed to restore whiteboard snapshot:', result.error); + toast.error(t('whiteboard.clearError') + (result.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('|'); +} From cb428b33de0e7f89cd7649d079518077019f1990 Mon Sep 17 00:00:00 2001 From: Yizuki_Ame Date: Wed, 18 Mar 2026 05:27:10 +0800 Subject: [PATCH 2/4] fix: address audit findings for whiteboard history - engine.ts: AI wb_clear now saves snapshot before clearing (audit #3) - page.tsx: clear whiteboard history on classroom switch (audit #4) --- app/classroom/[id]/page.tsx | 4 ++++ lib/action/engine.ts | 4 ++++ 2 files changed, 8 insertions(+) diff --git a/app/classroom/[id]/page.tsx b/app/classroom/[id]/page.tsx index dc9fef7..523e232 100644 --- a/app/classroom/[id]/page.tsx +++ b/app/classroom/[id]/page.tsx @@ -8,6 +8,7 @@ import { useEffect, useRef, useState, useCallback } from 'react'; import { useParams } from 'next/navigation'; import { useSceneGenerator } from '@/lib/hooks/use-scene-generator'; import { useMediaGenerationStore } from '@/lib/store/media-generation'; +import { useWhiteboardHistoryStore } from '@/lib/store/whiteboard-history'; import { createLogger } from '@/lib/logger'; import { MediaStageProvider } from '@/lib/contexts/media-stage-context'; import { generateMediaForOutlines } from '@/lib/media/media-orchestrator'; @@ -88,6 +89,9 @@ export default function ClassroomDetailPage() { mediaStore.revokeObjectUrls(); useMediaGenerationStore.setState({ tasks: {} }); + // Clear whiteboard history to prevent snapshots from a previous course leaking in. + useWhiteboardHistoryStore.getState().clearHistory(); + loadClassroom(); // Cancel ongoing generation when classroomId changes or component unmounts diff --git a/lib/action/engine.ts b/lib/action/engine.ts index 620f2f9..42c4210 100644 --- a/lib/action/engine.ts +++ b/lib/action/engine.ts @@ -12,6 +12,7 @@ import type { StageStore } from '@/lib/api/stage-api'; import { createStageAPI } from '@/lib/api/stage-api'; import { useCanvasStore } from '@/lib/store/canvas'; +import { useWhiteboardHistoryStore } from '@/lib/store/whiteboard-history'; import { useMediaGenerationStore, isMediaPlaceholder } from '@/lib/store/media-generation'; import type { AudioPlayer } from '@/lib/utils/audio-player'; import type { @@ -498,6 +499,9 @@ export class ActionEngine { const elementCount = wb.data.elements?.length || 0; if (elementCount === 0) return; + // Save snapshot before AI clear (mirrors UI handleClear in index.tsx) + useWhiteboardHistoryStore.getState().pushSnapshot(wb.data.elements!); + // Trigger cascade exit animation useCanvasStore.getState().setWhiteboardClearing(true); From f7c11a296465e0ae3a130c71408e4625f204bb3c Mon Sep 17 00:00:00 2001 From: Yizuki_Ame Date: Wed, 18 Mar 2026 05:45:28 +0800 Subject: [PATCH 3/4] fix: address review round 2 (P1/P2a/P2b/P3) - P1: Block restore during clear animation (race condition) - P2a: Skip no-op restores to avoid stale restoredKey - P2b: clearHistory() now also resets restoredKey - P3: Dedicated restoreError i18n key --- components/whiteboard/whiteboard-history.tsx | 23 ++++++++++++++++++-- lib/i18n/stage.ts | 2 ++ lib/store/whiteboard-history.ts | 2 +- 3 files changed, 24 insertions(+), 3 deletions(-) diff --git a/components/whiteboard/whiteboard-history.tsx b/components/whiteboard/whiteboard-history.tsx index 716cd39..2cf4acd 100644 --- a/components/whiteboard/whiteboard-history.tsx +++ b/components/whiteboard/whiteboard-history.tsx @@ -5,6 +5,7 @@ import { motion, AnimatePresence } from 'motion/react'; import { RotateCcw } from 'lucide-react'; import { useWhiteboardHistoryStore } from '@/lib/store/whiteboard-history'; import { useStageStore } from '@/lib/store'; +import { useCanvasStore } from '@/lib/store/canvas'; import { createStageAPI } from '@/lib/api/stage-api'; import { elementFingerprint } from '@/lib/utils/element-fingerprint'; import { toast } from 'sonner'; @@ -42,6 +43,13 @@ export function WhiteboardHistory({ isOpen, onClose }: WhiteboardHistoryProps) { }, [isOpen, onClose]); const handleRestore = (index: number) => { + // P1: Block restore while a clear animation is in flight — the pending + // delete/update would overwrite the restored content moments later. + if (useCanvasStore.getState().whiteboardClearing) { + toast.error(t('whiteboard.restoreError')); + return; + } + const snapshot = useWhiteboardHistoryStore.getState().getSnapshot(index); if (!snapshot) return; @@ -55,8 +63,18 @@ export function WhiteboardHistory({ isOpen, onClose }: WhiteboardHistoryProps) { } const whiteboardId = wbResult.data.id; - // Set restoredKey so auto-snapshot skips the incoming change + // P2a: Skip no-op restores — if the snapshot matches what's already + // on screen, applying it would not change elementsKey, leaving + // restoredKey armed indefinitely and suppressing a future snapshot. const restoredElementsKey = elementFingerprint(snapshot.elements); + const currentKey = elementFingerprint(wbResult.data.elements ?? []); + if (restoredElementsKey === currentKey) { + toast.success(t('whiteboard.restored')); + onClose(); + return; + } + + // Set restoredKey so auto-snapshot skips the incoming change useWhiteboardHistoryStore.getState().setRestoredKey(restoredElementsKey); // Transactional restore: replace all elements in one update() call @@ -67,7 +85,8 @@ export function WhiteboardHistory({ isOpen, onClose }: WhiteboardHistoryProps) { // Restore failed — clear restoredKey so auto-snapshot isn't stuck useWhiteboardHistoryStore.getState().setRestoredKey(null); console.error('Failed to restore whiteboard snapshot:', result.error); - toast.error(t('whiteboard.clearError') + (result.error ?? '')); + // P3: Dedicated restoreError key (not clearError) + toast.error(t('whiteboard.restoreError') + (result.error ?? '')); return; } diff --git a/lib/i18n/stage.ts b/lib/i18n/stage.ts index d5588b9..5c97693 100644 --- a/lib/i18n/stage.ts +++ b/lib/i18n/stage.ts @@ -17,6 +17,7 @@ export const stageZhCN = { readyHint: 'AI 添加元素后将在此显示', clearSuccess: '白板已清空', clearError: '清空白板失败:', + restoreError: '恢复白板失败:', history: '历史记录', restore: '恢复', beforeClear: '清除前', @@ -159,6 +160,7 @@ export const stageEnUS = { readyHint: 'Elements will appear here when added by AI', clearSuccess: 'Whiteboard cleared successfully', clearError: 'Failed to clear whiteboard: ', + restoreError: 'Failed to restore whiteboard: ', history: 'History', restore: 'Restore', beforeClear: 'Before clear', diff --git a/lib/store/whiteboard-history.ts b/lib/store/whiteboard-history.ts index 1e79af1..9e3b673 100644 --- a/lib/store/whiteboard-history.ts +++ b/lib/store/whiteboard-history.ts @@ -79,6 +79,6 @@ export const useWhiteboardHistoryStore = create((set, ge return snapshots[index] ?? null; }, - clearHistory: () => set({ snapshots: [] }), + clearHistory: () => set({ snapshots: [], restoredKey: null }), setRestoredKey: (key) => set({ restoredKey: key }), })); From f084bec449cb8130cd49792748379c6eae4e9271 Mon Sep 17 00:00:00 2001 From: Yizuki_Ame Date: Wed, 18 Mar 2026 06:01:06 +0800 Subject: [PATCH 4/4] fix: fingerprint includes semantic content per element type (P2c) --- lib/utils/element-fingerprint.ts | 72 +++++++++++++++++++++++++++----- 1 file changed, 61 insertions(+), 11 deletions(-) diff --git a/lib/utils/element-fingerprint.ts b/lib/utils/element-fingerprint.ts index bf4a0a7..f794840 100644 --- a/lib/utils/element-fingerprint.ts +++ b/lib/utils/element-fingerprint.ts @@ -1,20 +1,70 @@ import type { PPTElement } from '@/lib/types/slides'; +/** + * Extract the semantic payload for each element type. + * Used by elementFingerprint to detect content-only changes + * (same id/position but different text, chart data, media src, etc.). + */ +function semanticPart(e: PPTElement) { + switch (e.type) { + case 'text': + return { content: e.content }; + case 'image': + return { src: e.src }; + case 'shape': + return { + path: e.path, + fill: e.fill, + text: e.text?.content ?? '', + gradient: e.gradient ?? null, + pattern: e.pattern ?? null, + }; + case 'line': + return { + start: e.start, + end: e.end, + color: e.color, + style: e.style, + points: e.points, + }; + case 'chart': + return { + chartType: e.chartType, + data: e.data, + themeColors: e.themeColors, + }; + case 'table': + return { + data: e.data.map((row) => row.map((c) => c.text)), + colWidths: e.colWidths, + theme: e.theme ?? null, + }; + case 'latex': + return { latex: e.latex }; + case 'video': + return { src: e.src, poster: e.poster ?? '' }; + case 'audio': + return { src: e.src }; + } +} + /** * 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. + * Covers both geometry (id, position, size) AND semantic content + * via structured JSON.stringify — avoids delimiter-collision issues + * that hand-concatenated strings would have with rich-text HTML content. */ 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('|'); + return JSON.stringify( + els.map((e) => ({ + id: e.id, + left: e.left ?? 0, + top: e.top ?? 0, + width: 'width' in e ? e.width : 0, + height: 'height' in e && e.height != null ? e.height : 0, + sem: semanticPart(e), + })), + ); }