feat: implement undo/redo functionality in video editor
This commit is contained in:
@@ -6,19 +6,28 @@ export function KeyboardShortcutsHelp() {
|
||||
const [shortcuts, setShortcuts] = useState({
|
||||
delete: 'Ctrl + D',
|
||||
pan: 'Shift + Ctrl + Scroll',
|
||||
zoom: 'Ctrl + Scroll'
|
||||
zoom: 'Ctrl + Scroll',
|
||||
undo: 'Ctrl + Z',
|
||||
redo: 'Ctrl + Shift + Z',
|
||||
redoAlt: 'Ctrl + Y',
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
Promise.all([
|
||||
formatShortcut(['mod', 'D']),
|
||||
formatShortcut(['shift', 'mod', 'Scroll']),
|
||||
formatShortcut(['mod', 'Scroll'])
|
||||
]).then(([deleteKey, panKey, zoomKey]) => {
|
||||
formatShortcut(['mod', 'Scroll']),
|
||||
formatShortcut(['mod', 'Z']),
|
||||
formatShortcut(['shift', 'mod', 'Z']),
|
||||
formatShortcut(['mod', 'Y']),
|
||||
]).then(([deleteKey, panKey, zoomKey, undoKey, redoKey, redoAltKey]) => {
|
||||
setShortcuts({
|
||||
delete: deleteKey,
|
||||
pan: panKey,
|
||||
zoom: zoomKey
|
||||
zoom: zoomKey,
|
||||
undo: undoKey,
|
||||
redo: redoKey,
|
||||
redoAlt: redoAltKey,
|
||||
});
|
||||
});
|
||||
}, []);
|
||||
@@ -61,6 +70,18 @@ export function KeyboardShortcutsHelp() {
|
||||
<span className="text-slate-400">Pause/Play</span>
|
||||
<kbd className="px-1 py-0.5 bg-white/5 border border-white/10 rounded text-[#34B27B] font-mono">Space</kbd>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-slate-400">Undo</span>
|
||||
<kbd className="px-1 py-0.5 bg-white/5 border border-white/10 rounded text-[#34B27B] font-mono">{shortcuts.undo}</kbd>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-slate-400">Redo</span>
|
||||
<div className="flex items-center gap-1">
|
||||
<kbd className="px-1 py-0.5 bg-white/5 border border-white/10 rounded text-[#34B27B] font-mono">{shortcuts.redo}</kbd>
|
||||
<span className="text-slate-600 text-[9px]">or</span>
|
||||
<kbd className="px-1 py-0.5 bg-white/5 border border-white/10 rounded text-[#34B27B] font-mono">{shortcuts.redoAlt}</kbd>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -58,14 +58,17 @@ interface SettingsPanelProps {
|
||||
onTrimDelete?: (id: string) => void;
|
||||
shadowIntensity?: number;
|
||||
onShadowChange?: (intensity: number) => void;
|
||||
onShadowCommit?: () => void;
|
||||
showBlur?: boolean;
|
||||
onBlurChange?: (showBlur: boolean) => void;
|
||||
motionBlurEnabled?: boolean;
|
||||
onMotionBlurChange?: (enabled: boolean) => void;
|
||||
borderRadius?: number;
|
||||
onBorderRadiusChange?: (radius: number) => void;
|
||||
onBorderRadiusCommit?: () => void;
|
||||
padding?: number;
|
||||
onPaddingChange?: (padding: number) => void;
|
||||
onPaddingCommit?: () => void;
|
||||
cropRegion?: CropRegion;
|
||||
onCropChange?: (region: CropRegion) => void;
|
||||
aspectRatio: AspectRatio;
|
||||
@@ -114,14 +117,17 @@ export function SettingsPanel({
|
||||
onTrimDelete,
|
||||
shadowIntensity = 0,
|
||||
onShadowChange,
|
||||
onShadowCommit,
|
||||
showBlur,
|
||||
onBlurChange,
|
||||
motionBlurEnabled = false,
|
||||
onMotionBlurChange,
|
||||
borderRadius = 0,
|
||||
onBorderRadiusChange,
|
||||
onBorderRadiusCommit,
|
||||
padding = 50,
|
||||
onPaddingChange,
|
||||
onPaddingCommit,
|
||||
cropRegion,
|
||||
onCropChange,
|
||||
aspectRatio,
|
||||
@@ -358,6 +364,7 @@ export function SettingsPanel({
|
||||
<Slider
|
||||
value={[shadowIntensity]}
|
||||
onValueChange={(values) => onShadowChange?.(values[0])}
|
||||
onValueCommit={() => onShadowCommit?.()}
|
||||
min={0}
|
||||
max={1}
|
||||
step={0.01}
|
||||
@@ -372,6 +379,7 @@ export function SettingsPanel({
|
||||
<Slider
|
||||
value={[borderRadius]}
|
||||
onValueChange={(values) => onBorderRadiusChange?.(values[0])}
|
||||
onValueCommit={() => onBorderRadiusCommit?.()}
|
||||
min={0}
|
||||
max={16}
|
||||
step={0.5}
|
||||
@@ -386,6 +394,7 @@ export function SettingsPanel({
|
||||
<Slider
|
||||
value={[padding]}
|
||||
onValueChange={(values) => onPaddingChange?.(values[0])}
|
||||
onValueCommit={() => onPaddingCommit?.()}
|
||||
min={0}
|
||||
max={100}
|
||||
step={1}
|
||||
|
||||
@@ -15,7 +15,6 @@ import type { Span } from "dnd-timeline";
|
||||
import {
|
||||
DEFAULT_ZOOM_DEPTH,
|
||||
clampFocusToDepth,
|
||||
DEFAULT_CROP_REGION,
|
||||
DEFAULT_ANNOTATION_POSITION,
|
||||
DEFAULT_ANNOTATION_SIZE,
|
||||
DEFAULT_ANNOTATION_STYLE,
|
||||
@@ -26,42 +25,38 @@ import {
|
||||
type CursorTelemetryPoint,
|
||||
type TrimRegion,
|
||||
type AnnotationRegion,
|
||||
type CropRegion,
|
||||
type FigureData,
|
||||
} from "./types";
|
||||
import { VideoExporter, GifExporter, type ExportProgress, type ExportQuality, type ExportSettings, type ExportFormat, type GifFrameRate, type GifSizePreset, GIF_SIZE_PRESETS, calculateOutputDimensions } from "@/lib/exporter";
|
||||
import { type AspectRatio, getAspectRatioValue } from "@/utils/aspectRatioUtils";
|
||||
import { getAspectRatioValue } from "@/utils/aspectRatioUtils";
|
||||
import { getAssetPath } from "@/lib/assetPath";
|
||||
|
||||
const WALLPAPER_COUNT = 18;
|
||||
const WALLPAPER_PATHS = Array.from({ length: WALLPAPER_COUNT }, (_, i) => `/wallpapers/wallpaper${i + 1}.jpg`);
|
||||
import { useEditorHistory, INITIAL_EDITOR_STATE } from "@/hooks/useEditorHistory";
|
||||
|
||||
export default function VideoEditor() {
|
||||
const { state: editorState, pushState, updateState, commitState, undo, redo } =
|
||||
useEditorHistory(INITIAL_EDITOR_STATE);
|
||||
|
||||
const {
|
||||
zoomRegions, trimRegions, annotationRegions,
|
||||
cropRegion, wallpaper, shadowIntensity, showBlur,
|
||||
motionBlurEnabled, borderRadius, padding, aspectRatio,
|
||||
} = editorState;
|
||||
|
||||
// ── Non-undoable state
|
||||
const [videoPath, setVideoPath] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [isPlaying, setIsPlaying] = useState(false);
|
||||
const [currentTime, setCurrentTime] = useState(0);
|
||||
const [duration, setDuration] = useState(0);
|
||||
const [wallpaper, setWallpaper] = useState<string>(WALLPAPER_PATHS[0]);
|
||||
const [shadowIntensity, setShadowIntensity] = useState(0);
|
||||
const [showBlur, setShowBlur] = useState(false);
|
||||
const [motionBlurEnabled, setMotionBlurEnabled] = useState(false);
|
||||
const [borderRadius, setBorderRadius] = useState(0);
|
||||
const [padding, setPadding] = useState(50);
|
||||
const [cropRegion, setCropRegion] = useState<CropRegion>(DEFAULT_CROP_REGION);
|
||||
const [zoomRegions, setZoomRegions] = useState<ZoomRegion[]>([]);
|
||||
const [cursorTelemetry, setCursorTelemetry] = useState<CursorTelemetryPoint[]>([]);
|
||||
const [selectedZoomId, setSelectedZoomId] = useState<string | null>(null);
|
||||
const [trimRegions, setTrimRegions] = useState<TrimRegion[]>([]);
|
||||
const [selectedTrimId, setSelectedTrimId] = useState<string | null>(null);
|
||||
const [annotationRegions, setAnnotationRegions] = useState<AnnotationRegion[]>([]);
|
||||
const [selectedAnnotationId, setSelectedAnnotationId] = useState<string | null>(null);
|
||||
const [isExporting, setIsExporting] = useState(false);
|
||||
const [exportProgress, setExportProgress] = useState<ExportProgress | null>(null);
|
||||
const [exportError, setExportError] = useState<string | null>(null);
|
||||
const [showExportDialog, setShowExportDialog] = useState(false);
|
||||
const [aspectRatio, setAspectRatio] = useState<AspectRatio>('16:9');
|
||||
const [exportQuality, setExportQuality] = useState<ExportQuality>('good');
|
||||
const [exportFormat, setExportFormat] = useState<ExportFormat>('mp4');
|
||||
const [gifFrameRate, setGifFrameRate] = useState<GifFrameRate>(15);
|
||||
@@ -72,23 +67,12 @@ export default function VideoEditor() {
|
||||
const nextZoomIdRef = useRef(1);
|
||||
const nextTrimIdRef = useRef(1);
|
||||
const nextAnnotationIdRef = useRef(1);
|
||||
const nextAnnotationZIndexRef = useRef(1); // Track z-index for stacking order
|
||||
const nextAnnotationZIndexRef = useRef(1);
|
||||
const exporterRef = useRef<VideoExporter | null>(null);
|
||||
|
||||
// Helper to convert file path to proper file:// URL
|
||||
const toFileUrl = (filePath: string): string => {
|
||||
// Normalize path separators to forward slashes
|
||||
const normalized = filePath.replace(/\\/g, '/');
|
||||
|
||||
// Check if it's a Windows absolute path (e.g., C:/Users/...)
|
||||
if (normalized.match(/^[a-zA-Z]:/)) {
|
||||
const fileUrl = `file:///${normalized}`;
|
||||
return fileUrl;
|
||||
}
|
||||
|
||||
// Unix-style absolute path
|
||||
const fileUrl = `file://${normalized}`;
|
||||
return fileUrl;
|
||||
return normalized.match(/^[a-zA-Z]:/) ? `file:///${normalized}` : `file://${normalized}`;
|
||||
};
|
||||
|
||||
const fromFileUrl = (fileUrl: string): string => {
|
||||
@@ -158,19 +142,11 @@ export default function VideoEditor() {
|
||||
// Initialize default wallpaper with resolved asset path
|
||||
useEffect(() => {
|
||||
let mounted = true;
|
||||
(async () => {
|
||||
try {
|
||||
const resolvedPath = await getAssetPath('wallpapers/wallpaper1.jpg');
|
||||
if (mounted) {
|
||||
setWallpaper(resolvedPath);
|
||||
}
|
||||
} catch (err) {
|
||||
// If resolution fails, keep the fallback
|
||||
console.warn('Failed to resolve default wallpaper path:', err);
|
||||
}
|
||||
})();
|
||||
return () => { mounted = false };
|
||||
}, []);
|
||||
getAssetPath('wallpapers/wallpaper1.jpg')
|
||||
.then((path) => { if (mounted) updateState({ wallpaper: path }); })
|
||||
.catch((err) => console.warn('Failed to resolve default wallpaper path:', err));
|
||||
return () => { mounted = false; };
|
||||
}, [updateState]);
|
||||
|
||||
function togglePlayPause() {
|
||||
const playback = videoPlaybackRef.current;
|
||||
@@ -220,11 +196,11 @@ export default function VideoEditor() {
|
||||
depth: DEFAULT_ZOOM_DEPTH,
|
||||
focus: { cx: 0.5, cy: 0.5 },
|
||||
};
|
||||
setZoomRegions((prev) => [...prev, newRegion]);
|
||||
pushState((prev) => ({ zoomRegions: [...prev.zoomRegions, newRegion] }));
|
||||
setSelectedZoomId(id);
|
||||
setSelectedTrimId(null);
|
||||
setSelectedAnnotationId(null);
|
||||
}, []);
|
||||
}, [pushState]);
|
||||
|
||||
const handleZoomSuggested = useCallback((span: Span, focus: ZoomFocus) => {
|
||||
const id = `zoom-${nextZoomIdRef.current++}`;
|
||||
@@ -235,11 +211,11 @@ export default function VideoEditor() {
|
||||
depth: DEFAULT_ZOOM_DEPTH,
|
||||
focus: clampFocusToDepth(focus, DEFAULT_ZOOM_DEPTH),
|
||||
};
|
||||
setZoomRegions((prev) => [...prev, newRegion]);
|
||||
pushState((prev) => ({ zoomRegions: [...prev.zoomRegions, newRegion] }));
|
||||
setSelectedZoomId(id);
|
||||
setSelectedTrimId(null);
|
||||
setSelectedAnnotationId(null);
|
||||
}, []);
|
||||
}, [pushState]);
|
||||
|
||||
const handleTrimAdded = useCallback((span: Span) => {
|
||||
const id = `trim-${nextTrimIdRef.current++}`;
|
||||
@@ -248,85 +224,71 @@ export default function VideoEditor() {
|
||||
startMs: Math.round(span.start),
|
||||
endMs: Math.round(span.end),
|
||||
};
|
||||
setTrimRegions((prev) => [...prev, newRegion]);
|
||||
pushState((prev) => ({ trimRegions: [...prev.trimRegions, newRegion] }));
|
||||
setSelectedTrimId(id);
|
||||
setSelectedZoomId(null);
|
||||
setSelectedAnnotationId(null);
|
||||
}, []);
|
||||
}, [pushState]);
|
||||
|
||||
const handleZoomSpanChange = useCallback((id: string, span: Span) => {
|
||||
setZoomRegions((prev) =>
|
||||
prev.map((region) =>
|
||||
pushState((prev) => ({
|
||||
zoomRegions: prev.zoomRegions.map((region) =>
|
||||
region.id === id
|
||||
? {
|
||||
...region,
|
||||
startMs: Math.round(span.start),
|
||||
endMs: Math.round(span.end),
|
||||
}
|
||||
? { ...region, startMs: Math.round(span.start), endMs: Math.round(span.end) }
|
||||
: region,
|
||||
),
|
||||
);
|
||||
}, []);
|
||||
}));
|
||||
}, [pushState]);
|
||||
|
||||
const handleTrimSpanChange = useCallback((id: string, span: Span) => {
|
||||
setTrimRegions((prev) =>
|
||||
prev.map((region) =>
|
||||
pushState((prev) => ({
|
||||
trimRegions: prev.trimRegions.map((region) =>
|
||||
region.id === id
|
||||
? {
|
||||
...region,
|
||||
startMs: Math.round(span.start),
|
||||
endMs: Math.round(span.end),
|
||||
}
|
||||
? { ...region, startMs: Math.round(span.start), endMs: Math.round(span.end) }
|
||||
: region,
|
||||
),
|
||||
);
|
||||
}, []);
|
||||
}));
|
||||
}, [pushState]);
|
||||
|
||||
// Focus drag: updateState for live preview, commitState on pointer-up
|
||||
const handleZoomFocusChange = useCallback((id: string, focus: ZoomFocus) => {
|
||||
setZoomRegions((prev) =>
|
||||
prev.map((region) =>
|
||||
updateState((prev) => ({
|
||||
zoomRegions: prev.zoomRegions.map((region) =>
|
||||
region.id === id
|
||||
? {
|
||||
...region,
|
||||
focus: clampFocusToDepth(focus, region.depth),
|
||||
}
|
||||
? { ...region, focus: clampFocusToDepth(focus, region.depth) }
|
||||
: region,
|
||||
),
|
||||
);
|
||||
}, []);
|
||||
}));
|
||||
}, [updateState]);
|
||||
|
||||
const handleZoomDepthChange = useCallback((depth: ZoomDepth) => {
|
||||
if (!selectedZoomId) return;
|
||||
setZoomRegions((prev) =>
|
||||
prev.map((region) =>
|
||||
pushState((prev) => ({
|
||||
zoomRegions: prev.zoomRegions.map((region) =>
|
||||
region.id === selectedZoomId
|
||||
? {
|
||||
...region,
|
||||
depth,
|
||||
focus: clampFocusToDepth(region.focus, depth),
|
||||
}
|
||||
? { ...region, depth, focus: clampFocusToDepth(region.focus, depth) }
|
||||
: region,
|
||||
),
|
||||
);
|
||||
}, [selectedZoomId]);
|
||||
}));
|
||||
}, [selectedZoomId, pushState]);
|
||||
|
||||
const handleZoomDelete = useCallback((id: string) => {
|
||||
setZoomRegions((prev) => prev.filter((region) => region.id !== id));
|
||||
pushState((prev) => ({ zoomRegions: prev.zoomRegions.filter((r) => r.id !== id) }));
|
||||
if (selectedZoomId === id) {
|
||||
setSelectedZoomId(null);
|
||||
}
|
||||
}, [selectedZoomId]);
|
||||
}, [selectedZoomId, pushState]);
|
||||
|
||||
const handleTrimDelete = useCallback((id: string) => {
|
||||
setTrimRegions((prev) => prev.filter((region) => region.id !== id));
|
||||
pushState((prev) => ({ trimRegions: prev.trimRegions.filter((r) => r.id !== id) }));
|
||||
if (selectedTrimId === id) {
|
||||
setSelectedTrimId(null);
|
||||
}
|
||||
}, [selectedTrimId]);
|
||||
}, [selectedTrimId, pushState]);
|
||||
|
||||
const handleAnnotationAdded = useCallback((span: Span) => {
|
||||
const id = `annotation-${nextAnnotationIdRef.current++}`;
|
||||
const zIndex = nextAnnotationZIndexRef.current++; // Assign z-index based on creation order
|
||||
const zIndex = nextAnnotationZIndexRef.current++;
|
||||
const newRegion: AnnotationRegion = {
|
||||
id,
|
||||
startMs: Math.round(span.start),
|
||||
@@ -338,59 +300,48 @@ export default function VideoEditor() {
|
||||
style: { ...DEFAULT_ANNOTATION_STYLE },
|
||||
zIndex,
|
||||
};
|
||||
setAnnotationRegions((prev) => [...prev, newRegion]);
|
||||
pushState((prev) => ({ annotationRegions: [...prev.annotationRegions, newRegion] }));
|
||||
setSelectedAnnotationId(id);
|
||||
setSelectedZoomId(null);
|
||||
setSelectedTrimId(null);
|
||||
}, []);
|
||||
}, [pushState]);
|
||||
|
||||
const handleAnnotationSpanChange = useCallback((id: string, span: Span) => {
|
||||
setAnnotationRegions((prev) =>
|
||||
prev.map((region) =>
|
||||
pushState((prev) => ({
|
||||
annotationRegions: prev.annotationRegions.map((region) =>
|
||||
region.id === id
|
||||
? {
|
||||
...region,
|
||||
startMs: Math.round(span.start),
|
||||
endMs: Math.round(span.end),
|
||||
}
|
||||
? { ...region, startMs: Math.round(span.start), endMs: Math.round(span.end) }
|
||||
: region,
|
||||
),
|
||||
);
|
||||
}, []);
|
||||
}));
|
||||
}, [pushState]);
|
||||
|
||||
const handleAnnotationDelete = useCallback((id: string) => {
|
||||
setAnnotationRegions((prev) => prev.filter((region) => region.id !== id));
|
||||
pushState((prev) => ({ annotationRegions: prev.annotationRegions.filter((r) => r.id !== id) }));
|
||||
if (selectedAnnotationId === id) {
|
||||
setSelectedAnnotationId(null);
|
||||
}
|
||||
}, [selectedAnnotationId]);
|
||||
}, [selectedAnnotationId, pushState]);
|
||||
|
||||
const handleAnnotationContentChange = useCallback((id: string, content: string) => {
|
||||
setAnnotationRegions((prev) => {
|
||||
const updated = prev.map((region) => {
|
||||
pushState((prev) => ({
|
||||
annotationRegions: prev.annotationRegions.map((region) => {
|
||||
if (region.id !== id) return region;
|
||||
|
||||
// Store content in type-specific fields
|
||||
if (region.type === 'text') {
|
||||
return { ...region, content, textContent: content };
|
||||
} else if (region.type === 'image') {
|
||||
return { ...region, content, imageContent: content };
|
||||
} else {
|
||||
return { ...region, content };
|
||||
}
|
||||
});
|
||||
return updated;
|
||||
});
|
||||
}, []);
|
||||
return { ...region, content };
|
||||
}),
|
||||
}));
|
||||
}, [pushState]);
|
||||
|
||||
const handleAnnotationTypeChange = useCallback((id: string, type: AnnotationRegion['type']) => {
|
||||
setAnnotationRegions((prev) => {
|
||||
const updated = prev.map((region) => {
|
||||
pushState((prev) => ({
|
||||
annotationRegions: prev.annotationRegions.map((region) => {
|
||||
if (region.id !== id) return region;
|
||||
|
||||
const updatedRegion = { ...region, type };
|
||||
|
||||
// Restore content from type-specific storage
|
||||
if (type === 'text') {
|
||||
updatedRegion.content = region.textContent || 'Enter text...';
|
||||
} else if (type === 'image') {
|
||||
@@ -401,85 +352,71 @@ export default function VideoEditor() {
|
||||
updatedRegion.figureData = { ...DEFAULT_FIGURE_DATA };
|
||||
}
|
||||
}
|
||||
|
||||
return updatedRegion;
|
||||
});
|
||||
return updated;
|
||||
});
|
||||
}, []);
|
||||
}),
|
||||
}));
|
||||
}, [pushState]);
|
||||
|
||||
const handleAnnotationStyleChange = useCallback((id: string, style: Partial<AnnotationRegion['style']>) => {
|
||||
setAnnotationRegions((prev) =>
|
||||
prev.map((region) =>
|
||||
pushState((prev) => ({
|
||||
annotationRegions: prev.annotationRegions.map((region) =>
|
||||
region.id === id
|
||||
? { ...region, style: { ...region.style, ...style } }
|
||||
: region,
|
||||
),
|
||||
);
|
||||
}, []);
|
||||
}));
|
||||
}, [pushState]);
|
||||
|
||||
const handleAnnotationFigureDataChange = useCallback((id: string, figureData: FigureData) => {
|
||||
setAnnotationRegions((prev) =>
|
||||
prev.map((region) =>
|
||||
region.id === id
|
||||
? { ...region, figureData }
|
||||
: region,
|
||||
pushState((prev) => ({
|
||||
annotationRegions: prev.annotationRegions.map((region) =>
|
||||
region.id === id ? { ...region, figureData } : region,
|
||||
),
|
||||
);
|
||||
}, []);
|
||||
}));
|
||||
}, [pushState]);
|
||||
|
||||
const handleAnnotationPositionChange = useCallback((id: string, position: { x: number; y: number }) => {
|
||||
setAnnotationRegions((prev) =>
|
||||
prev.map((region) =>
|
||||
region.id === id
|
||||
? { ...region, position }
|
||||
: region,
|
||||
pushState((prev) => ({
|
||||
annotationRegions: prev.annotationRegions.map((region) =>
|
||||
region.id === id ? { ...region, position } : region,
|
||||
),
|
||||
);
|
||||
}, []);
|
||||
}));
|
||||
}, [pushState]);
|
||||
|
||||
const handleAnnotationSizeChange = useCallback((id: string, size: { width: number; height: number }) => {
|
||||
setAnnotationRegions((prev) =>
|
||||
prev.map((region) =>
|
||||
region.id === id
|
||||
? { ...region, size }
|
||||
: region,
|
||||
pushState((prev) => ({
|
||||
annotationRegions: prev.annotationRegions.map((region) =>
|
||||
region.id === id ? { ...region, size } : region,
|
||||
),
|
||||
);
|
||||
}, []);
|
||||
}));
|
||||
}, [pushState]);
|
||||
|
||||
// Global Tab prevention
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Tab') {
|
||||
// Allow tab only in inputs/textareas
|
||||
if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement) {
|
||||
return;
|
||||
}
|
||||
e.preventDefault();
|
||||
}
|
||||
const mod = e.ctrlKey || e.metaKey;
|
||||
const key = e.key.toLowerCase();
|
||||
|
||||
if (e.key === ' ' || e.code === 'Space') {
|
||||
// Allow space only in inputs/textareas
|
||||
if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement) {
|
||||
return;
|
||||
}
|
||||
if (mod && key === 'z' && !e.shiftKey) { e.preventDefault(); e.stopPropagation(); undo(); return; }
|
||||
if (mod && (key === 'y' || (key === 'z' && e.shiftKey))) { e.preventDefault(); e.stopPropagation(); redo(); return; }
|
||||
|
||||
const isInput = e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement;
|
||||
|
||||
if (e.key === 'Tab' && !isInput) { e.preventDefault(); }
|
||||
|
||||
if ((e.key === ' ' || e.code === 'Space') && !isInput) {
|
||||
e.preventDefault();
|
||||
|
||||
const playback = videoPlaybackRef.current;
|
||||
if (playback?.video) {
|
||||
if (playback.video.paused) {
|
||||
playback.play().catch(console.error);
|
||||
} else {
|
||||
playback.pause();
|
||||
}
|
||||
playback.video.paused
|
||||
? playback.play().catch(console.error)
|
||||
: playback.pause();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('keydown', handleKeyDown, { capture: true });
|
||||
return () => window.removeEventListener('keydown', handleKeyDown, { capture: true });
|
||||
}, []);
|
||||
}, [undo, redo]);
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedZoomId && !zoomRegions.some((region) => region.id === selectedZoomId)) {
|
||||
@@ -822,6 +759,7 @@ export default function VideoEditor() {
|
||||
selectedZoomId={selectedZoomId}
|
||||
onSelectZoom={handleSelectZoom}
|
||||
onZoomFocusChange={handleZoomFocusChange}
|
||||
onZoomFocusDragEnd={commitState}
|
||||
isPlaying={isPlaying}
|
||||
showShadow={shadowIntensity > 0}
|
||||
shadowIntensity={shadowIntensity}
|
||||
@@ -886,7 +824,7 @@ export default function VideoEditor() {
|
||||
selectedAnnotationId={selectedAnnotationId}
|
||||
onSelectAnnotation={handleSelectAnnotation}
|
||||
aspectRatio={aspectRatio}
|
||||
onAspectRatioChange={setAspectRatio}
|
||||
onAspectRatioChange={(ar) => pushState({ aspectRatio: ar })}
|
||||
/>
|
||||
</div>
|
||||
</Panel>
|
||||
@@ -896,7 +834,7 @@ export default function VideoEditor() {
|
||||
{/* Right section: settings panel */}
|
||||
<SettingsPanel
|
||||
selected={wallpaper}
|
||||
onWallpaperChange={setWallpaper}
|
||||
onWallpaperChange={(w) => pushState({ wallpaper: w })}
|
||||
selectedZoomDepth={selectedZoomId ? zoomRegions.find(z => z.id === selectedZoomId)?.depth : null}
|
||||
onZoomDepthChange={(depth) => selectedZoomId && handleZoomDepthChange(depth)}
|
||||
selectedZoomId={selectedZoomId}
|
||||
@@ -904,17 +842,20 @@ export default function VideoEditor() {
|
||||
selectedTrimId={selectedTrimId}
|
||||
onTrimDelete={handleTrimDelete}
|
||||
shadowIntensity={shadowIntensity}
|
||||
onShadowChange={setShadowIntensity}
|
||||
onShadowChange={(v) => updateState({ shadowIntensity: v })}
|
||||
onShadowCommit={commitState}
|
||||
showBlur={showBlur}
|
||||
onBlurChange={setShowBlur}
|
||||
onBlurChange={(v) => pushState({ showBlur: v })}
|
||||
motionBlurEnabled={motionBlurEnabled}
|
||||
onMotionBlurChange={setMotionBlurEnabled}
|
||||
onMotionBlurChange={(v) => pushState({ motionBlurEnabled: v })}
|
||||
borderRadius={borderRadius}
|
||||
onBorderRadiusChange={setBorderRadius}
|
||||
onBorderRadiusChange={(v) => updateState({ borderRadius: v })}
|
||||
onBorderRadiusCommit={commitState}
|
||||
padding={padding}
|
||||
onPaddingChange={setPadding}
|
||||
onPaddingChange={(v) => updateState({ padding: v })}
|
||||
onPaddingCommit={commitState}
|
||||
cropRegion={cropRegion}
|
||||
onCropChange={setCropRegion}
|
||||
onCropChange={(r) => pushState({ cropRegion: r })}
|
||||
aspectRatio={aspectRatio}
|
||||
videoElement={videoPlaybackRef.current?.video || null}
|
||||
exportQuality={exportQuality}
|
||||
|
||||
@@ -26,6 +26,7 @@ interface VideoPlaybackProps {
|
||||
selectedZoomId: string | null;
|
||||
onSelectZoom: (id: string | null) => void;
|
||||
onZoomFocusChange: (id: string, focus: ZoomFocus) => void;
|
||||
onZoomFocusDragEnd?: () => void;
|
||||
isPlaying: boolean;
|
||||
showShadow?: boolean;
|
||||
shadowIntensity?: number;
|
||||
@@ -65,6 +66,7 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(({
|
||||
selectedZoomId,
|
||||
onSelectZoom,
|
||||
onZoomFocusChange,
|
||||
onZoomFocusDragEnd,
|
||||
isPlaying,
|
||||
showShadow,
|
||||
shadowIntensity = 0,
|
||||
@@ -293,6 +295,7 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(({
|
||||
} catch {
|
||||
|
||||
}
|
||||
onZoomFocusDragEnd?.();
|
||||
};
|
||||
|
||||
const handleOverlayPointerUp = (event: React.PointerEvent<HTMLDivElement>) => {
|
||||
|
||||
@@ -860,17 +860,20 @@ export default function TimelineEditor({
|
||||
return;
|
||||
}
|
||||
|
||||
if (e.key === 'f' || e.key === 'F') {
|
||||
addKeyframe();
|
||||
}
|
||||
if (e.key === 'z' || e.key === 'Z') {
|
||||
handleAddZoom();
|
||||
}
|
||||
if (e.key === 't' || e.key === 'T') {
|
||||
handleAddTrim();
|
||||
}
|
||||
if (e.key === 'a' || e.key === 'A') {
|
||||
handleAddAnnotation();
|
||||
// Single-letter shortcuts only when no modifier key is held
|
||||
if (!e.ctrlKey && !e.metaKey && !e.altKey) {
|
||||
if (e.key === 'f' || e.key === 'F') {
|
||||
addKeyframe();
|
||||
}
|
||||
if (e.key === 'z' || e.key === 'Z') {
|
||||
handleAddZoom();
|
||||
}
|
||||
if (e.key === 't' || e.key === 'T') {
|
||||
handleAddTrim();
|
||||
}
|
||||
if (e.key === 'a' || e.key === 'A') {
|
||||
handleAddAnnotation();
|
||||
}
|
||||
}
|
||||
|
||||
// Tab: Cycle through overlapping annotations at current time
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
import { useCallback, useRef, useState } from "react";
|
||||
import type { ZoomRegion, TrimRegion, AnnotationRegion, CropRegion } from "@/components/video-editor/types";
|
||||
import { DEFAULT_CROP_REGION } from "@/components/video-editor/types";
|
||||
import type { AspectRatio } from "@/utils/aspectRatioUtils";
|
||||
|
||||
// Undoable state — selection IDs are intentionally excluded (undoing a
|
||||
// selection change would feel surprising to the user).
|
||||
export interface EditorState {
|
||||
zoomRegions: ZoomRegion[];
|
||||
trimRegions: TrimRegion[];
|
||||
annotationRegions: AnnotationRegion[];
|
||||
cropRegion: CropRegion;
|
||||
wallpaper: string;
|
||||
shadowIntensity: number;
|
||||
showBlur: boolean;
|
||||
motionBlurEnabled: boolean;
|
||||
borderRadius: number;
|
||||
padding: number;
|
||||
aspectRatio: AspectRatio;
|
||||
}
|
||||
|
||||
export const INITIAL_EDITOR_STATE: EditorState = {
|
||||
zoomRegions: [],
|
||||
trimRegions: [],
|
||||
annotationRegions: [],
|
||||
cropRegion: DEFAULT_CROP_REGION,
|
||||
wallpaper: "/wallpapers/wallpaper1.jpg",
|
||||
shadowIntensity: 0,
|
||||
showBlur: false,
|
||||
motionBlurEnabled: false,
|
||||
borderRadius: 0,
|
||||
padding: 50,
|
||||
aspectRatio: "16:9",
|
||||
};
|
||||
|
||||
type StateUpdate = Partial<EditorState> | ((prev: EditorState) => Partial<EditorState>);
|
||||
|
||||
interface History {
|
||||
past: EditorState[];
|
||||
present: EditorState;
|
||||
future: EditorState[];
|
||||
}
|
||||
|
||||
const MAX_HISTORY = 80;
|
||||
|
||||
function resolve(present: EditorState, update: StateUpdate): EditorState {
|
||||
const partial = typeof update === "function" ? update(present) : update;
|
||||
return { ...present, ...partial };
|
||||
}
|
||||
|
||||
function withCheckpoint(history: History, newPresent: EditorState): History {
|
||||
return {
|
||||
past: [...history.past.slice(-(MAX_HISTORY - 1)), history.present],
|
||||
present: newPresent,
|
||||
future: [],
|
||||
};
|
||||
}
|
||||
|
||||
export function useEditorHistory(initial: EditorState = INITIAL_EDITOR_STATE) {
|
||||
const [history, setHistory] = useState<History>({ past: [], present: initial, future: [] });
|
||||
|
||||
// Tracks whether a live-update series (e.g. slider drag) is in progress.
|
||||
// The first updateState call saves the pre-interaction state as a checkpoint.
|
||||
const dirtyRef = useRef(false);
|
||||
|
||||
const pushState = useCallback((update: StateUpdate) => {
|
||||
setHistory((prev) => withCheckpoint(prev, resolve(prev.present, update)));
|
||||
dirtyRef.current = false;
|
||||
}, []);
|
||||
|
||||
const updateState = useCallback((update: StateUpdate) => {
|
||||
const isFirst = !dirtyRef.current;
|
||||
dirtyRef.current = true;
|
||||
setHistory((prev) => {
|
||||
const next = resolve(prev.present, update);
|
||||
return isFirst ? withCheckpoint(prev, next) : { ...prev, present: next };
|
||||
});
|
||||
}, []);
|
||||
|
||||
const commitState = useCallback(() => {
|
||||
dirtyRef.current = false;
|
||||
}, []);
|
||||
|
||||
const undo = useCallback(() => {
|
||||
setHistory((prev) => {
|
||||
if (!prev.past.length) return prev;
|
||||
const previous = prev.past[prev.past.length - 1];
|
||||
return { past: prev.past.slice(0, -1), present: previous, future: [prev.present, ...prev.future] };
|
||||
});
|
||||
dirtyRef.current = false;
|
||||
}, []);
|
||||
|
||||
const redo = useCallback(() => {
|
||||
setHistory((prev) => {
|
||||
if (!prev.future.length) return prev;
|
||||
const [next, ...remainingFuture] = prev.future;
|
||||
return { past: [...prev.past, prev.present], present: next, future: remainingFuture };
|
||||
});
|
||||
dirtyRef.current = false;
|
||||
}, []);
|
||||
|
||||
return {
|
||||
state: history.present,
|
||||
pushState,
|
||||
updateState,
|
||||
commitState,
|
||||
undo,
|
||||
redo,
|
||||
canUndo: history.past.length > 0,
|
||||
canRedo: history.future.length > 0,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user