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
32 changes: 30 additions & 2 deletions components/whiteboard/index.tsx
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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];
Expand All @@ -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);

Expand Down Expand Up @@ -108,6 +119,20 @@ export function Whiteboard({ isOpen, onClose }: WhiteboardProps) {
<Eraser className="w-4 h-4" />
</motion.div>
</motion.button>
<motion.button
type="button"
onClick={() => 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')}
>
<History className="w-4 h-4" />
{snapshotCount > 0 && (
<span className="absolute -top-0.5 -right-0.5 min-w-[16px] h-4 px-1 rounded-full bg-purple-500 text-white text-[10px] font-bold flex items-center justify-center">
{snapshotCount}
</span>
)}
</motion.button>
<div className="w-px h-4 bg-gray-200 dark:bg-gray-700 mx-1" />
<button
type="button"
Expand All @@ -124,6 +149,9 @@ export function Whiteboard({ isOpen, onClose }: WhiteboardProps) {
<div className="flex-1 relative bg-[radial-gradient(#e5e7eb_1px,transparent_1px)] dark:bg-[radial-gradient(#374151_1px,transparent_1px)] [background-size:24px_24px] overflow-hidden">
<WhiteboardCanvas />

{/* History panel */}
<WhiteboardHistory isOpen={historyOpen} onClose={() => setHistoryOpen(false)} />

{/* Test panel */}
{/* <WhiteboardTestPanel /> */}
</div>
Expand Down
247 changes: 114 additions & 133 deletions components/whiteboard/whiteboard-canvas.tsx
Original file line number Diff line number Diff line change
@@ -1,80 +1,47 @@
'use client';

import { useRef, useState, useEffect, useCallback } from 'react';
import { motion, AnimatePresence } from 'motion/react';
import { useStageStore } from '@/lib/store';
import { useRef, useState, useEffect, useCallback, useMemo } from 'react';
import { motion } from 'motion/react';
import { useI18n } from '@/lib/i18n/context';

Check failure on line 5 in components/whiteboard/whiteboard-canvas.tsx

View workflow job for this annotation

GitHub Actions / Lint & Typecheck

Cannot find module '@/lib/i18n/context' or its corresponding type declarations.
import { useStageStore } from '@/lib/store/stage';
import { useCanvasStore } from '@/lib/store/canvas';
import { ScreenElement } from '@/components/slide-renderer/Editor/ScreenElement';
import { useWhiteboardHistoryStore } from '@/lib/store/whiteboard-history';
import type { PPTElement } from '@/lib/types/slides';
import { useI18n } from '@/lib/hooks/use-i18n';
import { ScreenCanvas } from '../canvas/screen-canvas';

Check failure on line 10 in components/whiteboard/whiteboard-canvas.tsx

View workflow job for this annotation

GitHub Actions / Lint & Typecheck

Cannot find module '../canvas/screen-canvas' or its corresponding type declarations.

/**
* Animated element wrapper
* Compute a fingerprint string for an array of whiteboard elements.
* Includes position, size, and content so that drag/resize changes
* are detected — not only ID additions/removals.
*/
function AnimatedElement({
element,
index,
isClearing,
totalElements,
}: {
element: PPTElement;
index: number;
isClearing: boolean;
totalElements: number;
}) {
// Reverse stagger: last-drawn element exits first for a "wipe" cascade
const clearDelay = isClearing ? (totalElements - 1 - index) * 0.055 : 0;
// Alternate tilt direction for organic feel
const clearRotate = isClearing ? (index % 2 === 0 ? 1 : -1) * (2 + index * 0.4) : 0;

return (
<motion.div
layout={false}
initial={{ opacity: 0, scale: 0.92, y: 8, filter: 'blur(4px)' }}
animate={
isClearing
? {
opacity: 0,
scale: 0.35,
y: -35,
rotate: clearRotate,
filter: 'blur(8px)',
transition: {
duration: 0.38,
delay: clearDelay,
ease: [0.5, 0, 1, 0.6],
},
}
: {
opacity: 1,
scale: 1,
y: 0,
rotate: 0,
filter: 'blur(0px)',
transition: {
duration: 0.45,
ease: [0.16, 1, 0.3, 1],
delay: index * 0.05,
},
}
function elementFingerprint(elements: PPTElement[]): string {
if (elements.length === 0) return '';
return elements
.map((el) => {
// Grab the core identity + geometry that matters for change detection
const base = `${el.id}:${el.left},${el.top},${el.width},${el.height}`;

Check failure on line 22 in components/whiteboard/whiteboard-canvas.tsx

View workflow job for this annotation

GitHub Actions / Lint & Typecheck

Property 'height' does not exist on type 'PPTElement'.
// For text elements include a hash of content so edits are detected
if ('content' in el && typeof el.content === 'string') {
return `${base}:${el.content.length}`;
}
exit={{
opacity: 0,
scale: 0.85,
transition: { duration: 0.2 },
}}
className="absolute inset-0"
style={{ pointerEvents: isClearing ? 'none' : undefined }}
>
<div style={{ pointerEvents: 'auto' }}>
<ScreenElement elementInfo={element} elementIndex={index} animate />
</div>
</motion.div>
);
return base;
})
.join('|');
}

/**
* Whiteboard canvas
* WhiteboardCanvas – 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. A `isRestoring` guard prevents a restore
* action from itself triggering a new snapshot.
*
* During a "clear" action (isClearing flag from the canvas store) the
* snapshot is captured synchronously by the clear handler in the parent
* component, so the auto-snapshot effect skips it.
*/
export function WhiteboardCanvas() {
const { t } = useI18n();
Expand All @@ -83,84 +50,98 @@
const containerRef = useRef<HTMLDivElement>(null);
const [scale, setScale] = useState(1);

// Get whiteboard elements
// Get whiteboard elements — stabilise reference so useMemo deps don't
// change on every render when elements is the same empty array.
const whiteboard = stage?.whiteboard?.[0];
const elements = whiteboard?.elements || [];
const rawElements = whiteboard?.elements;
const elements = useMemo(() => rawElements ?? [], [rawElements]);

// Auto-snapshot: save previous elements when content is replaced by AI.
// Debounced (2s) so adding elements one-by-one doesn't spam snapshots.
// Fingerprint includes position/size so drag/resize changes are detected, not just ID changes.
const prevElementsRef = useRef<PPTElement[]>([]);
const elementsKey = useMemo(() => elementFingerprint(elements), [elements]);
const elementsRef = useRef(elements);
useEffect(() => {
elementsRef.current = elements;
}, [elements]);
const snapshotTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
useEffect(() => {
const prev = prevElementsRef.current;
const prevKey = elementFingerprint(prev);

// Whiteboard fixed size: 1000 x 562.5 (16:9)
const canvasWidth = 1000;
const canvasHeight = 562.5;
// Clear any pending snapshot timer
if (snapshotTimerRef.current) {
clearTimeout(snapshotTimerRef.current);
snapshotTimerRef.current = null;
}

// Responsive scaling: scale canvas proportionally to fill container
if (elementsKey !== prevKey && prev.length > 0 && !isClearing) {
// Content changed and there was previous content — debounce snapshot
snapshotTimerRef.current = setTimeout(() => {
const { isRestoring, addSnapshot } = useWhiteboardHistoryStore.getState();

Check failure on line 82 in components/whiteboard/whiteboard-canvas.tsx

View workflow job for this annotation

GitHub Actions / Lint & Typecheck

Property 'addSnapshot' does not exist on type 'WhiteboardHistoryState'.

Check failure on line 82 in components/whiteboard/whiteboard-canvas.tsx

View workflow job for this annotation

GitHub Actions / Lint & Typecheck

Property 'isRestoring' does not exist on type 'WhiteboardHistoryState'.
if (!isRestoring) {
addSnapshot(prev);
}
// Update prev ref AFTER the snapshot is taken
prevElementsRef.current = elementsRef.current;
}, 2000);
} else {
// No meaningful change or first load — just track
prevElementsRef.current = elements;
}

return () => {
if (snapshotTimerRef.current) {
clearTimeout(snapshotTimerRef.current);
}
};
// elementsKey is a stable fingerprint string — no unnecessary re-runs
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [elementsKey, isClearing]);

// ── Layout: compute scale to fit container ──────────────────────
const updateScale = useCallback(() => {
const container = containerRef.current;
if (!container) return;
const { clientWidth, clientHeight } = container;
const scaleX = clientWidth / canvasWidth;
const scaleY = clientHeight / canvasHeight;
if (!containerRef.current) return;
const { clientWidth, clientHeight } = containerRef.current;
if (clientWidth === 0 || clientHeight === 0) return;
const scaleX = clientWidth / 1920;
const scaleY = clientHeight / 1080;
setScale(Math.min(scaleX, scaleY));
}, [canvasWidth, canvasHeight]);
}, []);

useEffect(() => {
const container = containerRef.current;
if (!container) return;
const observer = new ResizeObserver(updateScale);
observer.observe(container);
updateScale();
return () => observer.disconnect();
window.addEventListener('resize', updateScale);
return () => window.removeEventListener('resize', updateScale);
}, [updateScale]);

return (
<div
ref={containerRef}
className="w-full h-full flex items-center justify-center overflow-hidden"
>
{/* Layout wrapper: its size matches the scaled visual size so flex centering works correctly */}
<div style={{ width: canvasWidth * scale, height: canvasHeight * scale }}>
<div
className="relative bg-white shadow-2xl rounded-lg overflow-hidden"
style={{
width: canvasWidth,
height: canvasHeight,
transform: `scale(${scale})`,
transformOrigin: 'top left',
}}
>
{/* Placeholder when empty and not mid-clear */}
<AnimatePresence>
{elements.length === 0 && !isClearing && (
<motion.div
key="placeholder"
initial={{ opacity: 0 }}
animate={{
opacity: 1,
transition: { delay: 0.25, duration: 0.4 },
}}
exit={{ opacity: 0, transition: { duration: 0.15 } }}
className="absolute inset-0 flex items-center justify-center"
>
<div className="text-center text-gray-400">
<p className="text-lg font-medium">{t('whiteboard.ready')}</p>
<p className="text-sm mt-1">{t('whiteboard.readyHint')}</p>
</div>
</motion.div>
)}
</AnimatePresence>

{/* Elements — always rendered so AnimatePresence can track exits */}
<AnimatePresence mode="popLayout">
{elements.map((element, index) => (
<AnimatedElement
key={element.id}
element={element}
index={index}
isClearing={isClearing}
totalElements={elements.length}
/>
))}
</AnimatePresence>
</div>
// ── Render ──────────────────────────────────────────────────────
if (elements.length === 0) {
return (
<div className="whiteboard-canvas-empty">
<p>{t('stage.whiteboardEmpty')}</p>
</div>
);
}

return (
<div ref={containerRef} className="whiteboard-canvas-container">
<motion.div
key={elementsKey}
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ duration: 0.3 }}
className="whiteboard-canvas-inner"
style={{
width: 1920,
height: 1080,
transform: `scale(${scale})`,
transformOrigin: 'top left',
}}
>
<ScreenCanvas elements={elements} />
</motion.div>
</div>
);
}
Loading
Loading