/** * ReplayControls — Play/pause, step navigation, speed selector, keyboard shortcuts (Story 5.2) * * Features: * - Play/pause button with auto-advance at selected speed * - Step back (←) / step forward (→) buttons * - Speed selector (1x, 2x, 5x, 10x) * - Step counter "Step 23 of 847" * - Keyboard: Space=play/pause, ArrowRight=step forward, ArrowLeft=step back, Home=first, End=last * - Updates URL ?step=N via pushState (no navigation) */ import React, { useCallback, useEffect, useRef, useState } from 'react'; import { findNextError, findPrevError } from '../../hooks/useErrorIndices'; // ─── Types ────────────────────────────────────────────────────────── export interface ReplayControlsProps { currentStep: number; totalSteps: number; onStepChange: (step: number) => void; disabled?: boolean; /** Pre-computed error event indices for Shift+E / Shift+Ctrl+E navigation */ errorIndices?: number[]; } const SPEED_OPTIONS = [1, 2, 5, 10] as const; type Speed = (typeof SPEED_OPTIONS)[number]; /** Base interval in ms at 1x speed */ const BASE_INTERVAL_MS = 500; // ─── Component ────────────────────────────────────────────────────── export function ReplayControls({ currentStep, totalSteps, onStepChange, disabled = false, errorIndices = [], }: ReplayControlsProps): React.ReactElement { const [playing, setPlaying] = useState(false); const [speed, setSpeed] = useState(1); const intervalRef = useRef | null>(null); const stepRef = useRef(currentStep); // Keep stepRef in sync stepRef.current = currentStep; const isAtEnd = currentStep >= totalSteps - 1; const isAtStart = currentStep <= 0; // ── Step helpers ──────────────────────────────────────────────── const stepForward = useCallback(() => { if (stepRef.current < totalSteps - 1) { onStepChange(stepRef.current + 1); } }, [totalSteps, onStepChange]); const stepBackward = useCallback(() => { if (stepRef.current > 0) { onStepChange(stepRef.current - 1); } }, [onStepChange]); const goToFirst = useCallback(() => { onStepChange(0); }, [onStepChange]); const goToLast = useCallback(() => { onStepChange(totalSteps - 1); }, [totalSteps, onStepChange]); // ── Play / pause ────────────────────────────────────────────── const stopPlaying = useCallback(() => { setPlaying(false); if (intervalRef.current !== null) { clearInterval(intervalRef.current); intervalRef.current = null; } }, []); const startPlaying = useCallback(() => { if (isAtEnd) return; setPlaying(true); }, [isAtEnd]); const togglePlay = useCallback(() => { if (playing) { stopPlaying(); } else { startPlaying(); } }, [playing, stopPlaying, startPlaying]); // Pause at end useEffect(() => { if (playing && isAtEnd) { stopPlaying(); } }, [playing, isAtEnd, stopPlaying]); // Auto-advance interval useEffect(() => { if (intervalRef.current !== null) { clearInterval(intervalRef.current); intervalRef.current = null; } if (playing && !disabled) { intervalRef.current = setInterval(() => { if (stepRef.current < totalSteps - 1) { onStepChange(stepRef.current + 1); } }, BASE_INTERVAL_MS / speed); } return () => { if (intervalRef.current !== null) { clearInterval(intervalRef.current); intervalRef.current = null; } }; }, [playing, speed, totalSteps, onStepChange, disabled]); // ── URL sync ────────────────────────────────────────────────── useEffect(() => { const url = new URL(window.location.href); url.searchParams.set('step', String(currentStep)); window.history.replaceState(null, '', url.toString()); }, [currentStep]); // ── Keyboard shortcuts ──────────────────────────────────────── useEffect(() => { if (disabled) return; const handleKeyDown = (e: KeyboardEvent) => { // Don't capture if user is typing in an input const tag = (e.target as HTMLElement)?.tagName; if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT') return; // Shift+E = next error, Shift+Ctrl+E = previous error if (e.key === 'E' && e.shiftKey) { e.preventDefault(); if (playing) stopPlaying(); if (e.ctrlKey || e.metaKey) { const prev = findPrevError(errorIndices, stepRef.current); if (prev !== null) onStepChange(prev); } else { const next = findNextError(errorIndices, stepRef.current); if (next !== null) onStepChange(next); } return; } switch (e.key) { case ' ': e.preventDefault(); togglePlay(); break; case 'ArrowRight': e.preventDefault(); if (playing) stopPlaying(); stepForward(); break; case 'ArrowLeft': e.preventDefault(); if (playing) stopPlaying(); stepBackward(); break; case 'Home': e.preventDefault(); if (playing) stopPlaying(); goToFirst(); break; case 'End': e.preventDefault(); if (playing) stopPlaying(); goToLast(); break; } }; window.addEventListener('keydown', handleKeyDown); return () => window.removeEventListener('keydown', handleKeyDown); }, [disabled, togglePlay, stopPlaying, stepForward, stepBackward, goToFirst, goToLast, playing, errorIndices, onStepChange]); // ── Render ──────────────────────────────────────────────────── return (
{/* Step backward */} {/* Play / Pause */} {/* Step forward */} {/* Divider */}
{/* Step counter */} Step {totalSteps === 0 ? 0 : currentStep + 1} of {totalSteps} {/* Divider */}
{/* Speed selector */}
Speed {SPEED_OPTIONS.map((s) => ( ))}
); }