diff --git a/components/editor/AudioEditor.tsx b/components/editor/AudioEditor.tsx index c35adba..2f00613 100644 --- a/components/editor/AudioEditor.tsx +++ b/components/editor/AudioEditor.tsx @@ -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(null); const [clipboard, setClipboard] = React.useState(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() { + {/* History Controls */} + + + History + + + + + + {/* Zoom Controls */} diff --git a/components/editor/EditControls.tsx b/components/editor/EditControls.tsx index 22036bc..0807342 100644 --- a/components/editor/EditControls.tsx +++ b/components/editor/EditControls.tsx @@ -125,7 +125,7 @@ export function EditControls({ {/* Keyboard Shortcuts Info */}
-

Keyboard Shortcuts:

+

Edit Shortcuts:

• Shift+Drag: Select region

• Ctrl+A: Select all

• Ctrl+X: Cut

diff --git a/components/editor/HistoryControls.tsx b/components/editor/HistoryControls.tsx new file mode 100644 index 0000000..5251756 --- /dev/null +++ b/components/editor/HistoryControls.tsx @@ -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 ( +
+ {/* History Info */} + {historyState.historySize > 0 && ( +
+
+ +
+

History Available

+

+ {historyState.historySize} action{historyState.historySize !== 1 ? 's' : ''} in history +

+ {historyState.undoDescription && ( +

+ Next undo: {historyState.undoDescription} +

+ )} + {historyState.redoDescription && ( +

+ Next redo: {historyState.redoDescription} +

+ )} +
+
+
+ )} + + {/* Control Buttons */} +
+ + + + + {onClear && historyState.historySize > 0 && ( + + )} +
+ + {/* Keyboard Shortcuts Info */} +
+

History Shortcuts:

+

• Ctrl+Z: Undo last action

+

• Ctrl+Y or Ctrl+Shift+Z: Redo last action

+

+ History tracks up to 50 edit operations +

+
+
+ ); +} diff --git a/lib/history/command.ts b/lib/history/command.ts new file mode 100644 index 0000000..e94fcdb --- /dev/null +++ b/lib/history/command.ts @@ -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(); + } +} diff --git a/lib/history/commands/edit-command.ts b/lib/history/commands/edit-command.ts new file mode 100644 index 0000000..dbb7ee6 --- /dev/null +++ b/lib/history/commands/edit-command.ts @@ -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, + }); +} diff --git a/lib/history/history-manager.ts b/lib/history/history-manager.ts new file mode 100644 index 0000000..4c04a3d --- /dev/null +++ b/lib/history/history-manager.ts @@ -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(); + } +} diff --git a/lib/hooks/useHistory.ts b/lib/hooks/useHistory.ts new file mode 100644 index 0000000..9b6cf3e --- /dev/null +++ b/lib/hooks/useHistory.ts @@ -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(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, + }; +}