whiteboard-history.tsx 6.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167
  1. 'use client';
  2. import { useRef, useEffect } from 'react';
  3. import { motion, AnimatePresence } from 'motion/react';
  4. import { RotateCcw } from 'lucide-react';
  5. import { useWhiteboardHistoryStore } from '@/lib/store/whiteboard-history';
  6. import { useStageStore } from '@/lib/store';
  7. import { useCanvasStore } from '@/lib/store/canvas';
  8. import { createStageAPI } from '@/lib/api/stage-api';
  9. import { elementFingerprint } from '@/lib/utils/element-fingerprint';
  10. import { toast } from 'sonner';
  11. import { useI18n } from '@/lib/hooks/use-i18n';
  12. interface WhiteboardHistoryProps {
  13. readonly isOpen: boolean;
  14. readonly onClose: () => void;
  15. }
  16. /**
  17. * Whiteboard history dropdown panel.
  18. * Shows a list of saved whiteboard snapshots with timestamps and element counts.
  19. * Clicking "Restore" replaces the current whiteboard content with the snapshot.
  20. */
  21. export function WhiteboardHistory({ isOpen, onClose }: WhiteboardHistoryProps) {
  22. const { t } = useI18n();
  23. const snapshots = useWhiteboardHistoryStore((s) => s.snapshots);
  24. const isClearing = useCanvasStore.use.whiteboardClearing();
  25. const panelRef = useRef<HTMLDivElement>(null);
  26. // Close on outside click
  27. useEffect(() => {
  28. if (!isOpen) return;
  29. const handler = (e: MouseEvent) => {
  30. if (panelRef.current && !panelRef.current.contains(e.target as Node)) {
  31. onClose();
  32. }
  33. };
  34. // Delay listener so the click that opens the panel doesn't immediately close it
  35. const id = setTimeout(() => document.addEventListener('mousedown', handler), 0);
  36. return () => {
  37. clearTimeout(id);
  38. document.removeEventListener('mousedown', handler);
  39. };
  40. }, [isOpen, onClose]);
  41. const handleRestore = (index: number) => {
  42. // P1: Block restore while a clear animation is in flight — the pending
  43. // delete/update would overwrite the restored content moments later.
  44. if (isClearing) {
  45. toast.error(t('whiteboard.restoreError'));
  46. return;
  47. }
  48. const snapshot = useWhiteboardHistoryStore.getState().getSnapshot(index);
  49. if (!snapshot) return;
  50. const stageStore = useStageStore;
  51. const stageAPI = createStageAPI(stageStore);
  52. // Get or create whiteboard
  53. const wbResult = stageAPI.whiteboard.get();
  54. if (!wbResult.success || !wbResult.data) {
  55. return;
  56. }
  57. const whiteboardId = wbResult.data.id;
  58. // P2a: Skip no-op restores — if the snapshot matches what's already
  59. // on screen, restoring would be a no-op.
  60. const restoredElementsKey = snapshot.fingerprint;
  61. const currentKey = elementFingerprint(wbResult.data.elements ?? []);
  62. if (restoredElementsKey === currentKey) {
  63. toast.success(t('whiteboard.restored'));
  64. onClose();
  65. return;
  66. }
  67. // Save current content before overwriting so the user can undo the restore
  68. const currentElements = wbResult.data.elements ?? [];
  69. if (currentElements.length > 0) {
  70. useWhiteboardHistoryStore.getState().pushSnapshot(currentElements);
  71. }
  72. // Transactional restore: replace all elements in one update() call
  73. // instead of looping delete/add which produces intermediate states.
  74. const result = stageAPI.whiteboard.update({ elements: snapshot.elements }, whiteboardId);
  75. if (!result.success) {
  76. console.error('Failed to restore whiteboard snapshot:', result.error);
  77. // P3: Dedicated restoreError key (not clearError)
  78. toast.error(t('whiteboard.restoreError') + (result.error ?? ''));
  79. return;
  80. }
  81. toast.success(t('whiteboard.restored'));
  82. onClose();
  83. };
  84. const formatTime = (ts: number) => {
  85. const d = new Date(ts);
  86. return `${d.getHours().toString().padStart(2, '0')}:${d.getMinutes().toString().padStart(2, '0')}:${d.getSeconds().toString().padStart(2, '0')}`;
  87. };
  88. return (
  89. <AnimatePresence>
  90. {isOpen && (
  91. <motion.div
  92. ref={panelRef}
  93. initial={{ opacity: 0, y: -8, scale: 0.95 }}
  94. animate={{ opacity: 1, y: 0, scale: 1 }}
  95. exit={{ opacity: 0, y: -8, scale: 0.95 }}
  96. transition={{ duration: 0.15 }}
  97. className="absolute right-0 top-full mt-2 z-[130] w-72 max-h-80 bg-white dark:bg-gray-800 rounded-xl shadow-2xl border border-gray-200 dark:border-gray-700 overflow-hidden flex flex-col"
  98. >
  99. {/* Header */}
  100. <div className="px-4 py-3 border-b border-gray-100 dark:border-gray-700 flex items-center justify-between">
  101. <span className="text-sm font-semibold text-gray-700 dark:text-gray-200">
  102. {t('whiteboard.history')}
  103. </span>
  104. <span className="text-xs text-gray-400">
  105. {snapshots.length > 0 ? `${snapshots.length}` : ''}
  106. </span>
  107. </div>
  108. {/* Snapshot list */}
  109. <div className="flex-1 overflow-y-auto">
  110. {snapshots.length === 0 ? (
  111. <div className="px-4 py-8 text-center text-sm text-gray-400 dark:text-gray-500">
  112. {t('whiteboard.noHistory')}
  113. </div>
  114. ) : (
  115. <div className="py-1">
  116. {[...snapshots].reverse().map((snap, reverseIdx) => {
  117. const realIdx = snapshots.length - 1 - reverseIdx;
  118. return (
  119. <div
  120. key={`${snap.timestamp}-${realIdx}`}
  121. className="px-4 py-2.5 flex items-center justify-between hover:bg-gray-50 dark:hover:bg-gray-700/50 transition-colors group"
  122. >
  123. <div className="flex-1 min-w-0">
  124. <div className="text-sm font-medium text-gray-700 dark:text-gray-200 truncate">
  125. {`#${realIdx + 1}`}
  126. </div>
  127. <div className="text-xs text-gray-400 dark:text-gray-500 mt-0.5">
  128. {formatTime(snap.timestamp)} ·{' '}
  129. {t('whiteboard.elementCount', { count: snap.elements.length })}
  130. </div>
  131. </div>
  132. <button
  133. type="button"
  134. onClick={() => handleRestore(realIdx)}
  135. disabled={isClearing}
  136. className="ml-2 px-2 py-1 text-xs text-purple-600 dark:text-purple-400 hover:bg-purple-50 dark:hover:bg-purple-900/20 rounded-md opacity-0 group-hover:opacity-100 transition-opacity flex items-center gap-1 disabled:opacity-40 disabled:cursor-not-allowed disabled:hover:bg-transparent"
  137. >
  138. <RotateCcw className="w-3 h-3" />
  139. {t('whiteboard.restore')}
  140. </button>
  141. </div>
  142. );
  143. })}
  144. </div>
  145. )}
  146. </div>
  147. </motion.div>
  148. )}
  149. </AnimatePresence>
  150. );
  151. }