feat: implement Phase 5 - undo/redo system with command pattern

Added comprehensive undo/redo functionality:
- Command pattern interface and base classes
- HistoryManager with 50-operation stack
- EditCommand for all edit operations (cut, delete, paste, trim)
- Full keyboard shortcuts (Ctrl+Z undo, Ctrl+Y/Ctrl+Shift+Z redo)
- HistoryControls UI component with visual feedback
- Integrated history system with all edit operations
- Toast notifications for undo/redo actions
- History state tracking and display

New files:
- lib/history/command.ts - Command interface and BaseCommand
- lib/history/history-manager.ts - HistoryManager class
- lib/history/commands/edit-command.ts - EditCommand and factory functions
- lib/hooks/useHistory.ts - React hook for history management
- components/editor/HistoryControls.tsx - History UI component

Modified files:
- components/editor/AudioEditor.tsx - Integrated history system
- components/editor/EditControls.tsx - Updated keyboard shortcuts display

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2025-11-17 17:08:31 +01:00
parent 55009593f7
commit 159da29082
7 changed files with 585 additions and 11 deletions

View File

@@ -7,7 +7,9 @@ import { Waveform } from './Waveform';
import { PlaybackControls } from './PlaybackControls';
import { ZoomControls } from './ZoomControls';
import { EditControls } from './EditControls';
import { HistoryControls } from './HistoryControls';
import { useAudioPlayer } from '@/lib/hooks/useAudioPlayer';
import { useHistory } from '@/lib/hooks/useHistory';
import { useToast } from '@/components/ui/Toast';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/Card';
import { Slider } from '@/components/ui/Slider';
@@ -19,6 +21,12 @@ import {
insertBufferSegment,
trimBuffer,
} from '@/lib/audio/buffer-utils';
import {
createCutCommand,
createDeleteCommand,
createPasteCommand,
createTrimCommand,
} from '@/lib/history/commands/edit-command';
export function AudioEditor() {
// Zoom and scroll state
@@ -29,6 +37,7 @@ export function AudioEditor() {
// Selection state
const [selection, setSelection] = React.useState<Selection | null>(null);
const [clipboard, setClipboard] = React.useState<ClipboardData | null>(null);
const {
loadFile,
loadBuffer,
@@ -51,6 +60,7 @@ export function AudioEditor() {
durationFormatted,
} = useAudioPlayer();
const { execute, undo, redo, clear: clearHistory, state: historyState } = useHistory(50);
const { addToast } = useToast();
const handleFileSelect = async (file: File) => {
@@ -79,6 +89,7 @@ export function AudioEditor() {
setAmplitudeScale(1);
setSelection(null);
setClipboard(null);
clearHistory();
addToast({
title: 'Audio cleared',
description: 'Audio file has been removed',
@@ -101,9 +112,11 @@ export function AudioEditor() {
duration: selection.end - selection.start,
});
// Delete from buffer
const newBuffer = deleteBufferSegment(audioBuffer, selection.start, selection.end);
loadBuffer(newBuffer);
// Create and execute cut command
const command = createCutCommand(audioBuffer, selection, (buffer) => {
loadBuffer(buffer);
});
execute(command);
setSelection(null);
addToast({
@@ -155,8 +168,12 @@ export function AudioEditor() {
try {
const insertTime = currentTime;
const newBuffer = insertBufferSegment(audioBuffer, clipboard.buffer, insertTime);
loadBuffer(newBuffer);
// Create and execute paste command
const command = createPasteCommand(audioBuffer, clipboard.buffer, insertTime, (buffer) => {
loadBuffer(buffer);
});
execute(command);
addToast({
title: 'Pasted',
@@ -178,8 +195,11 @@ export function AudioEditor() {
if (!selection || !audioBuffer) return;
try {
const newBuffer = deleteBufferSegment(audioBuffer, selection.start, selection.end);
loadBuffer(newBuffer);
// Create and execute delete command
const command = createDeleteCommand(audioBuffer, selection, (buffer) => {
loadBuffer(buffer);
});
execute(command);
setSelection(null);
addToast({
@@ -202,8 +222,11 @@ export function AudioEditor() {
if (!selection || !audioBuffer) return;
try {
const newBuffer = trimBuffer(audioBuffer, selection.start, selection.end);
loadBuffer(newBuffer);
// Create and execute trim command
const command = createTrimCommand(audioBuffer, selection, (buffer) => {
loadBuffer(buffer);
});
execute(command);
setSelection(null);
addToast({
@@ -263,6 +286,32 @@ export function AudioEditor() {
return;
}
// Ctrl+Z: Undo
if (e.ctrlKey && !e.shiftKey && e.key === 'z') {
e.preventDefault();
if (undo()) {
addToast({
title: 'Undo',
description: 'Last action undone',
variant: 'info',
duration: 1500,
});
}
}
// Ctrl+Y or Ctrl+Shift+Z: Redo
if ((e.ctrlKey && e.key === 'y') || (e.ctrlKey && e.shiftKey && e.key === 'z')) {
e.preventDefault();
if (redo()) {
addToast({
title: 'Redo',
description: 'Last action redone',
variant: 'info',
duration: 1500,
});
}
}
// Ctrl+A: Select all
if (e.ctrlKey && e.key === 'a') {
e.preventDefault();
@@ -302,7 +351,7 @@ export function AudioEditor() {
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, [selection, clipboard, audioBuffer, currentTime]);
}, [selection, clipboard, audioBuffer, currentTime, undo, redo, addToast]);
// Show error toast
React.useEffect(() => {
@@ -400,6 +449,21 @@ export function AudioEditor() {
</CardContent>
</Card>
{/* History Controls */}
<Card>
<CardHeader>
<CardTitle>History</CardTitle>
</CardHeader>
<CardContent>
<HistoryControls
historyState={historyState}
onUndo={undo}
onRedo={redo}
onClear={clearHistory}
/>
</CardContent>
</Card>
{/* Zoom Controls */}
<Card>
<CardHeader>

View File

@@ -125,7 +125,7 @@ export function EditControls({
{/* Keyboard Shortcuts Info */}
<div className="text-xs text-muted-foreground space-y-1 p-3 rounded-lg bg-muted/30">
<p className="font-medium mb-2">Keyboard Shortcuts:</p>
<p className="font-medium mb-2">Edit Shortcuts:</p>
<p> Shift+Drag: Select region</p>
<p> Ctrl+A: Select all</p>
<p> Ctrl+X: Cut</p>

View File

@@ -0,0 +1,107 @@
'use client';
import * as React from 'react';
import { Undo2, Redo2, History, Info } from 'lucide-react';
import { Button } from '@/components/ui/Button';
import { cn } from '@/lib/utils/cn';
import type { HistoryState } from '@/lib/history/history-manager';
export interface HistoryControlsProps {
historyState: HistoryState;
onUndo: () => void;
onRedo: () => void;
onClear?: () => void;
className?: string;
}
export function HistoryControls({
historyState,
onUndo,
onRedo,
onClear,
className,
}: HistoryControlsProps) {
return (
<div className={cn('space-y-4', className)}>
{/* History Info */}
{historyState.historySize > 0 && (
<div className="rounded-lg border border-info bg-info/10 p-3">
<div className="flex items-start gap-2">
<Info className="h-4 w-4 text-info-foreground mt-0.5 flex-shrink-0" />
<div className="flex-1 min-w-0 text-sm">
<p className="font-medium text-info-foreground">History Available</p>
<p className="text-info-foreground/90 mt-1">
{historyState.historySize} action{historyState.historySize !== 1 ? 's' : ''} in history
</p>
{historyState.undoDescription && (
<p className="text-xs text-info-foreground/75 mt-1">
Next undo: {historyState.undoDescription}
</p>
)}
{historyState.redoDescription && (
<p className="text-xs text-info-foreground/75 mt-1">
Next redo: {historyState.redoDescription}
</p>
)}
</div>
</div>
</div>
)}
{/* Control Buttons */}
<div className="grid grid-cols-2 gap-2">
<Button
variant="outline"
onClick={onUndo}
disabled={!historyState.canUndo}
title={
historyState.canUndo
? `Undo ${historyState.undoDescription} (Ctrl+Z)`
: 'Nothing to undo (Ctrl+Z)'
}
className="justify-start"
>
<Undo2 className="h-4 w-4 mr-2" />
Undo
</Button>
<Button
variant="outline"
onClick={onRedo}
disabled={!historyState.canRedo}
title={
historyState.canRedo
? `Redo ${historyState.redoDescription} (Ctrl+Y)`
: 'Nothing to redo (Ctrl+Y)'
}
className="justify-start"
>
<Redo2 className="h-4 w-4 mr-2" />
Redo
</Button>
{onClear && historyState.historySize > 0 && (
<Button
variant="outline"
onClick={onClear}
title="Clear history"
className="justify-start col-span-2"
>
<History className="h-4 w-4 mr-2" />
Clear History ({historyState.historySize})
</Button>
)}
</div>
{/* Keyboard Shortcuts Info */}
<div className="text-xs text-muted-foreground space-y-1 p-3 rounded-lg bg-muted/30">
<p className="font-medium mb-2">History Shortcuts:</p>
<p> Ctrl+Z: Undo last action</p>
<p> Ctrl+Y or Ctrl+Shift+Z: Redo last action</p>
<p className="mt-2 text-xs text-muted-foreground/75">
History tracks up to 50 edit operations
</p>
</div>
</div>
);
}

38
lib/history/command.ts Normal file
View File

@@ -0,0 +1,38 @@
/**
* Command Pattern for Undo/Redo System
*/
export interface Command {
/**
* Execute the command
*/
execute(): void;
/**
* Undo the command
*/
undo(): void;
/**
* Redo the command (default: call execute again)
*/
redo(): void;
/**
* Get a description of the command for UI display
*/
getDescription(): string;
}
/**
* Base command class with default redo implementation
*/
export abstract class BaseCommand implements Command {
abstract execute(): void;
abstract undo(): void;
abstract getDescription(): string;
redo(): void {
this.execute();
}
}

View File

@@ -0,0 +1,155 @@
/**
* Edit commands for audio buffer operations
*/
import { BaseCommand } from '../command';
import type { Selection } from '@/types/selection';
import {
extractBufferSegment,
deleteBufferSegment,
insertBufferSegment,
trimBuffer,
} from '@/lib/audio/buffer-utils';
export type EditCommandType = 'cut' | 'delete' | 'paste' | 'trim';
export interface EditCommandParams {
type: EditCommandType;
beforeBuffer: AudioBuffer;
afterBuffer: AudioBuffer;
selection?: Selection;
clipboardData?: AudioBuffer;
pastePosition?: number;
onApply: (buffer: AudioBuffer) => void;
}
/**
* Command for edit operations (cut, delete, paste, trim)
*/
export class EditCommand extends BaseCommand {
private type: EditCommandType;
private beforeBuffer: AudioBuffer;
private afterBuffer: AudioBuffer;
private selection?: Selection;
private clipboardData?: AudioBuffer;
private pastePosition?: number;
private onApply: (buffer: AudioBuffer) => void;
constructor(params: EditCommandParams) {
super();
this.type = params.type;
this.beforeBuffer = params.beforeBuffer;
this.afterBuffer = params.afterBuffer;
this.selection = params.selection;
this.clipboardData = params.clipboardData;
this.pastePosition = params.pastePosition;
this.onApply = params.onApply;
}
execute(): void {
this.onApply(this.afterBuffer);
}
undo(): void {
this.onApply(this.beforeBuffer);
}
getDescription(): string {
switch (this.type) {
case 'cut':
return 'Cut';
case 'delete':
return 'Delete';
case 'paste':
return 'Paste';
case 'trim':
return 'Trim';
default:
return 'Edit';
}
}
/**
* Get the type of edit operation
*/
getType(): EditCommandType {
return this.type;
}
/**
* Get the selection that was affected
*/
getSelection(): Selection | undefined {
return this.selection;
}
}
/**
* Factory functions to create edit commands
*/
export function createCutCommand(
buffer: AudioBuffer,
selection: Selection,
onApply: (buffer: AudioBuffer) => void
): EditCommand {
const afterBuffer = deleteBufferSegment(buffer, selection.start, selection.end);
return new EditCommand({
type: 'cut',
beforeBuffer: buffer,
afterBuffer,
selection,
onApply,
});
}
export function createDeleteCommand(
buffer: AudioBuffer,
selection: Selection,
onApply: (buffer: AudioBuffer) => void
): EditCommand {
const afterBuffer = deleteBufferSegment(buffer, selection.start, selection.end);
return new EditCommand({
type: 'delete',
beforeBuffer: buffer,
afterBuffer,
selection,
onApply,
});
}
export function createPasteCommand(
buffer: AudioBuffer,
clipboardData: AudioBuffer,
pastePosition: number,
onApply: (buffer: AudioBuffer) => void
): EditCommand {
const afterBuffer = insertBufferSegment(buffer, clipboardData, pastePosition);
return new EditCommand({
type: 'paste',
beforeBuffer: buffer,
afterBuffer,
clipboardData,
pastePosition,
onApply,
});
}
export function createTrimCommand(
buffer: AudioBuffer,
selection: Selection,
onApply: (buffer: AudioBuffer) => void
): EditCommand {
const afterBuffer = trimBuffer(buffer, selection.start, selection.end);
return new EditCommand({
type: 'trim',
beforeBuffer: buffer,
afterBuffer,
selection,
onApply,
});
}

View File

@@ -0,0 +1,156 @@
/**
* History Manager for Undo/Redo functionality
*/
import type { Command } from './command';
export interface HistoryState {
canUndo: boolean;
canRedo: boolean;
undoDescription: string | null;
redoDescription: string | null;
historySize: number;
}
export class HistoryManager {
private undoStack: Command[] = [];
private redoStack: Command[] = [];
private maxHistorySize: number;
private listeners: Set<() => void> = new Set();
constructor(maxHistorySize: number = 50) {
this.maxHistorySize = maxHistorySize;
}
/**
* Execute a command and add it to history
*/
execute(command: Command): void {
command.execute();
this.undoStack.push(command);
// Limit history size
if (this.undoStack.length > this.maxHistorySize) {
this.undoStack.shift();
}
// Clear redo stack when new command is executed
this.redoStack = [];
this.notifyListeners();
}
/**
* Undo the last command
*/
undo(): boolean {
if (!this.canUndo()) return false;
const command = this.undoStack.pop()!;
command.undo();
this.redoStack.push(command);
this.notifyListeners();
return true;
}
/**
* Redo the last undone command
*/
redo(): boolean {
if (!this.canRedo()) return false;
const command = this.redoStack.pop()!;
command.redo();
this.undoStack.push(command);
this.notifyListeners();
return true;
}
/**
* Check if undo is available
*/
canUndo(): boolean {
return this.undoStack.length > 0;
}
/**
* Check if redo is available
*/
canRedo(): boolean {
return this.redoStack.length > 0;
}
/**
* Get current history state
*/
getState(): HistoryState {
return {
canUndo: this.canUndo(),
canRedo: this.canRedo(),
undoDescription: this.getUndoDescription(),
redoDescription: this.getRedoDescription(),
historySize: this.undoStack.length,
};
}
/**
* Get description of next undo action
*/
getUndoDescription(): string | null {
if (!this.canUndo()) return null;
return this.undoStack[this.undoStack.length - 1].getDescription();
}
/**
* Get description of next redo action
*/
getRedoDescription(): string | null {
if (!this.canRedo()) return null;
return this.redoStack[this.redoStack.length - 1].getDescription();
}
/**
* Clear all history
*/
clear(): void {
this.undoStack = [];
this.redoStack = [];
this.notifyListeners();
}
/**
* Subscribe to history changes
*/
subscribe(listener: () => void): () => void {
this.listeners.add(listener);
return () => this.listeners.delete(listener);
}
/**
* Notify all listeners of history changes
*/
private notifyListeners(): void {
this.listeners.forEach((listener) => listener());
}
/**
* Get current history size
*/
getHistorySize(): number {
return this.undoStack.length;
}
/**
* Set maximum history size
*/
setMaxHistorySize(size: number): void {
this.maxHistorySize = size;
// Trim undo stack if needed
while (this.undoStack.length > this.maxHistorySize) {
this.undoStack.shift();
}
this.notifyListeners();
}
}

54
lib/hooks/useHistory.ts Normal file
View File

@@ -0,0 +1,54 @@
'use client';
import * as React from 'react';
import { HistoryManager } from '@/lib/history/history-manager';
import type { HistoryState } from '@/lib/history/history-manager';
import type { Command } from '@/lib/history/command';
export interface UseHistoryReturn {
execute: (command: Command) => void;
undo: () => boolean;
redo: () => boolean;
clear: () => void;
state: HistoryState;
}
export function useHistory(maxHistorySize: number = 50): UseHistoryReturn {
const [manager] = React.useState(() => new HistoryManager(maxHistorySize));
const [state, setState] = React.useState<HistoryState>(manager.getState());
React.useEffect(() => {
const unsubscribe = manager.subscribe(() => {
setState(manager.getState());
});
return unsubscribe;
}, [manager]);
const execute = React.useCallback(
(command: Command) => {
manager.execute(command);
},
[manager]
);
const undo = React.useCallback(() => {
return manager.undo();
}, [manager]);
const redo = React.useCallback(() => {
return manager.redo();
}, [manager]);
const clear = React.useCallback(() => {
manager.clear();
}, [manager]);
return {
execute,
undo,
redo,
clear,
state,
};
}