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
34 changes: 33 additions & 1 deletion src/components/VideoEditor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ import { getPresetById } from "@/lib/presets";
import { cn } from "@/lib/utils";
import {
Layers, Crop, Scissors, RotateCw, Volume2, Type,
SlidersHorizontal, Zap, AlertTriangle, Github, Copy
SlidersHorizontal, Zap, AlertTriangle, Github, Copy, Undo2, Redo2,
} from "lucide-react";
import OnboardingTour from "./OnboardingTour";
import { useKeyboardShortcuts } from "@/hooks/useKeyboardShortcuts";
Expand Down Expand Up @@ -153,6 +153,14 @@ function KeyboardShortcutsPanel() {
keys: [<Kbd key="question">?</Kbd>],
label: "Toggle this panel",
},
{
keys: [<Kbd key="ctrl2">Ctrl</Kbd>, <span key="p1" className="text-[var(--muted)] text-xs">+</span>, <Kbd key="z">Z</Kbd>],
label: "Undo",
},
{
keys: [<Kbd key="ctrl3">Ctrl</Kbd>, <span key="p2" className="text-[var(--muted)] text-xs">+</span>, <Kbd key="shift2">Shift</Kbd>, <span key="p3" className="text-[var(--muted)] text-xs">+</span>, <Kbd key="z2">Z</Kbd>],
label: "Redo",
},
];

return (
Expand Down Expand Up @@ -211,6 +219,7 @@ export default function VideoEditor() {
recommendedPreset,
currentTime,
toggleSound,
undo, redo, canUndo, canRedo,
} = useVideoEditor();

useKeyboardShortcuts({
Expand All @@ -222,6 +231,7 @@ export default function VideoEditor() {
status,
cancelExport,
onToggleShortcutsModal: () => {},
undo, redo,
});

const [copied, setCopied] = useState(false);
Expand Down Expand Up @@ -704,6 +714,28 @@ return () => {
</AccordionSection>

<div className="pt-2 flex justify-center items-center gap-6">
<button
type="button"
onClick={undo}
disabled={!canUndo}
aria-label="Undo last change"
title="Undo (Ctrl+Z)"
className="flex items-center gap-1.5 text-xs font-heading font-bold uppercase tracking-widest text-film-500 hover:text-film-600 transition-all cursor-pointer disabled:opacity-30 disabled:cursor-not-allowed disabled:hover:text-film-500"
>
<Undo2 size={12} />
Undo
</button>
<button
type="button"
onClick={redo}
disabled={!canRedo}
aria-label="Redo last undone change"
title="Redo (Ctrl+Shift+Z)"
className="flex items-center gap-1.5 text-xs font-heading font-bold uppercase tracking-widest text-film-500 hover:text-film-600 transition-all cursor-pointer disabled:opacity-30 disabled:cursor-not-allowed disabled:hover:text-film-500"
>
<Redo2 size={12} />
Redo
</button>
<button
type="button"
onClick={handleCopyLink}
Expand Down
104 changes: 104 additions & 0 deletions src/hooks/__tests__/useUndoRedo.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
import { describe, it, expect } from "vitest";
import { renderHook, act } from "@testing-library/react";
import { useUndoRedo } from "../useUndoRedo";

describe("useUndoRedo", () => {
it("starts with canUndo and canRedo false", () => {
const { result } = renderHook(() => useUndoRedo({ a: 1 }));
expect(result.current.canUndo).toBe(false);
expect(result.current.canRedo).toBe(false);
});

it("push enables canUndo and undo returns the previous snapshot", () => {
const { result } = renderHook(() => useUndoRedo({ a: 1 }));

act(() => {
result.current.push({ a: 2 });
});
expect(result.current.canUndo).toBe(true);

let undone: { a: number } | null = null;
act(() => {
undone = result.current.undo();
});
expect(undone).toEqual({ a: 1 });
});

it("redo replays the snapshot that was just undone", () => {
const { result } = renderHook(() => useUndoRedo({ a: 1 }));

act(() => {
result.current.push({ a: 2 });
});
act(() => {
result.current.undo();
});
expect(result.current.canRedo).toBe(true);

let redone: { a: number } | null = null;
act(() => {
redone = result.current.redo();
});
expect(redone).toEqual({ a: 2 });
});

it("pushing after an undo clears the redo stack (new branch)", () => {
const { result } = renderHook(() => useUndoRedo({ a: 1 }));

act(() => result.current.push({ a: 2 }));
act(() => result.current.undo());
act(() => result.current.push({ a: 3 }));

expect(result.current.canRedo).toBe(false);
});

it("ignores a push that is identical to the current snapshot", () => {
const { result } = renderHook(() => useUndoRedo({ a: 1 }));

act(() => result.current.push({ a: 1 })); // same value, no-op
expect(result.current.canUndo).toBe(false);
});

it("caps history depth at maxHistory", () => {
const { result } = renderHook(() =>
useUndoRedo({ n: 0 }, { maxHistory: 2 })
);

act(() => result.current.push({ n: 1 }));
act(() => result.current.push({ n: 2 }));
act(() => result.current.push({ n: 3 }));

// Only 2 steps back should be possible — the n:0 entry should have been dropped.
act(() => result.current.undo());
act(() => result.current.undo());
expect(result.current.canUndo).toBe(false);
});

it("reset clears both stacks and sets a new current snapshot", () => {
const { result } = renderHook(() => useUndoRedo({ a: 1 }));

act(() => result.current.push({ a: 2 }));
act(() => result.current.reset({ a: 99 }));

expect(result.current.canUndo).toBe(false);
expect(result.current.canRedo).toBe(false);
});

it("undo on an empty stack returns null", () => {
const { result } = renderHook(() => useUndoRedo({ a: 1 }));
let undone: unknown = "not-null";
act(() => {
undone = result.current.undo();
});
expect(undone).toBeNull();
});

it("redo on an empty stack returns null", () => {
const { result } = renderHook(() => useUndoRedo({ a: 1 }));
let redone: unknown = "not-null";
act(() => {
redone = result.current.redo();
});
expect(redone).toBeNull();
});
});
23 changes: 22 additions & 1 deletion src/hooks/useKeyboardShortcuts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ interface UseKeyboardShortcutsProps {
status: ExportStatus;
cancelExport: () => void;
onToggleShortcutsModal: () => void;
undo: () => void;
redo: () => void;
}

export function useKeyboardShortcuts({
Expand All @@ -22,6 +24,8 @@ export function useKeyboardShortcuts({
status,
cancelExport,
onToggleShortcutsModal,
undo,
redo,
}: UseKeyboardShortcutsProps) {
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
Expand All @@ -42,6 +46,23 @@ export function useKeyboardShortcuts({
return;
}

// Undo: Ctrl/Cmd+Z (without Shift)
if (isCtrlOrCmd && !e.shiftKey && e.key.toLowerCase() === "z") {
e.preventDefault();
if (file) undo();
return;
}

// Redo: Ctrl/Cmd+Shift+Z, or Ctrl+Y (Windows convention)
if (
(isCtrlOrCmd && e.shiftKey && e.key.toLowerCase() === "z") ||
(isCtrlOrCmd && !isMac && e.key.toLowerCase() === "y")
) {
e.preventDefault();
if (file) redo();
return;
}

if (!file) return;

switch (e.key) {
Expand Down Expand Up @@ -75,5 +96,5 @@ export function useKeyboardShortcuts({

window.addEventListener("keydown", handleKeyDown);
return () => window.removeEventListener("keydown", handleKeyDown);
}, [file, recipe, resetSettings, updateRecipe, handleExport, status, cancelExport, onToggleShortcutsModal]);
}, [file, recipe, resetSettings, updateRecipe, handleExport, status, cancelExport, onToggleShortcutsModal, undo, redo]);
}
98 changes: 98 additions & 0 deletions src/hooks/useUndoRedo.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
"use client";

import { useCallback, useRef, useState } from "react";

const DEFAULT_MAX_HISTORY = 25;

export interface UseUndoRedoOptions<T> {
maxHistory?: number;
}

export interface UseUndoRedoResult<T> {
/** Push a new snapshot. No-op if deepEqual to the current top of stack. */
push: (snapshot: T) => void;
/** Move back one step. Returns the snapshot to apply, or null if nothing to undo. */
undo: () => T | null;
/** Move forward one step. Returns the snapshot to apply, or null if nothing to redo. */
redo: () => T | null;
canUndo: boolean;
canRedo: boolean;
/** Clear all history and reset to a single base snapshot (e.g. on file reset/load). */
reset: (snapshot: T) => void;
}

/**
* Generic undo/redo history stack.
* Stores snapshots by value (caller should pass plain serializable objects).
*/
export function useUndoRedo<T>(
initial: T,
options: UseUndoRedoOptions<T> = {}
): UseUndoRedoResult<T> {
const maxHistory = options.maxHistory ?? DEFAULT_MAX_HISTORY;

// past: oldest -> newest, NOT including current
// current: the present snapshot
// future: nearest -> furthest redo targets
const pastRef = useRef<T[]>([]);
const currentRef = useRef<T>(initial);
const futureRef = useRef<T[]>([]);

// Mirror counts into state purely to trigger re-renders for canUndo/canRedo/buttons.
const [, forceRender] = useState(0);
const bump = useCallback(() => forceRender((n) => n + 1), []);

const push = useCallback(
(snapshot: T) => {
const serializedNew = JSON.stringify(snapshot);
const serializedCurrent = JSON.stringify(currentRef.current);
if (serializedNew === serializedCurrent) return; // no-op, avoid duplicate entries

pastRef.current.push(currentRef.current);
if (pastRef.current.length > maxHistory) {
pastRef.current.shift(); // cap history depth
}
currentRef.current = snapshot;
futureRef.current = []; // new branch invalidates redo stack
bump();
},
[maxHistory, bump]
);

const undo = useCallback((): T | null => {
if (pastRef.current.length === 0) return null;
const previous = pastRef.current.pop()!;
futureRef.current.unshift(currentRef.current);
currentRef.current = previous;
bump();
return previous;
}, [bump]);

const redo = useCallback((): T | null => {
if (futureRef.current.length === 0) return null;
const next = futureRef.current.shift()!;
pastRef.current.push(currentRef.current);
currentRef.current = next;
bump();
return next;
}, [bump]);

const reset = useCallback(
(snapshot: T) => {
pastRef.current = [];
futureRef.current = [];
currentRef.current = snapshot;
bump();
},
[bump]
);

return {
push,
undo,
redo,
canUndo: pastRef.current.length > 0,
canRedo: futureRef.current.length > 0,
reset,
};
}
Loading