feat: implement multi-track waveform selection and editing with undo/redo
Added comprehensive selection and editing capabilities to multi-track editor: - Visual selection overlay with Shift+drag interaction on waveforms - Multi-track edit commands (cut, copy, paste, delete, duplicate) - Full keyboard shortcut support (Ctrl+X/C/V/D, Delete, Ctrl+Z/Y) - Complete undo/redo integration via command pattern - Per-track selection state with localStorage persistence - Audio buffer manipulation utilities (extract, insert, delete, duplicate segments) - Toast notifications for all edit operations - Red playhead to distinguish from blue selection overlay All edit operations are fully undoable and integrated with the existing history manager system. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -14,15 +14,30 @@ import { useToast } from '@/components/ui/Toast';
|
||||
import { TrackList } from '@/components/tracks/TrackList';
|
||||
import { ImportTrackDialog } from '@/components/tracks/ImportTrackDialog';
|
||||
import { formatDuration } from '@/lib/audio/decoder';
|
||||
import { useHistory } from '@/lib/hooks/useHistory';
|
||||
import {
|
||||
createMultiTrackCutCommand,
|
||||
createMultiTrackCopyCommand,
|
||||
createMultiTrackDeleteCommand,
|
||||
createMultiTrackPasteCommand,
|
||||
createMultiTrackDuplicateCommand,
|
||||
} from '@/lib/history/commands/multi-track-edit-command';
|
||||
import { extractBufferSegment } from '@/lib/audio/buffer-utils';
|
||||
|
||||
export function AudioEditor() {
|
||||
const [importDialogOpen, setImportDialogOpen] = React.useState(false);
|
||||
const [selectedTrackId, setSelectedTrackId] = React.useState<string | null>(null);
|
||||
const [zoom, setZoom] = React.useState(1);
|
||||
const [masterVolume, setMasterVolume] = React.useState(0.8);
|
||||
const [clipboard, setClipboard] = React.useState<AudioBuffer | null>(null);
|
||||
|
||||
const { addToast } = useToast();
|
||||
|
||||
// Command history for undo/redo
|
||||
const { execute: executeCommand, undo, redo, state: historyState } = useHistory();
|
||||
const canUndo = historyState.canUndo;
|
||||
const canRedo = historyState.canRedo;
|
||||
|
||||
// Multi-track hooks
|
||||
const {
|
||||
tracks,
|
||||
@@ -135,6 +150,146 @@ export function AudioEditor() {
|
||||
updateTrack(selectedTrack.id, { effectChain: updatedChain });
|
||||
};
|
||||
|
||||
// Selection handler
|
||||
const handleSelectionChange = (trackId: string, selection: { start: number; end: number } | null) => {
|
||||
updateTrack(trackId, { selection });
|
||||
};
|
||||
|
||||
// Edit handlers
|
||||
const handleCut = React.useCallback(() => {
|
||||
const track = tracks.find((t) => t.selection);
|
||||
if (!track || !track.audioBuffer || !track.selection) return;
|
||||
|
||||
// Extract to clipboard
|
||||
const extracted = extractBufferSegment(
|
||||
track.audioBuffer,
|
||||
track.selection.start,
|
||||
track.selection.end
|
||||
);
|
||||
setClipboard(extracted);
|
||||
|
||||
// Execute cut command
|
||||
const command = createMultiTrackCutCommand(
|
||||
track.id,
|
||||
track.audioBuffer,
|
||||
track.selection,
|
||||
(trackId, buffer, selection) => {
|
||||
updateTrack(trackId, { audioBuffer: buffer, selection });
|
||||
}
|
||||
);
|
||||
executeCommand(command);
|
||||
|
||||
addToast({
|
||||
title: 'Cut',
|
||||
description: 'Selection cut to clipboard',
|
||||
variant: 'success',
|
||||
duration: 2000,
|
||||
});
|
||||
}, [tracks, executeCommand, updateTrack, addToast]);
|
||||
|
||||
const handleCopy = React.useCallback(() => {
|
||||
const track = tracks.find((t) => t.selection);
|
||||
if (!track || !track.audioBuffer || !track.selection) return;
|
||||
|
||||
// Extract to clipboard
|
||||
const extracted = extractBufferSegment(
|
||||
track.audioBuffer,
|
||||
track.selection.start,
|
||||
track.selection.end
|
||||
);
|
||||
setClipboard(extracted);
|
||||
|
||||
// Execute copy command (doesn't modify buffer, just for undo history)
|
||||
const command = createMultiTrackCopyCommand(
|
||||
track.id,
|
||||
track.audioBuffer,
|
||||
track.selection,
|
||||
(trackId, buffer, selection) => {
|
||||
updateTrack(trackId, { audioBuffer: buffer, selection });
|
||||
}
|
||||
);
|
||||
executeCommand(command);
|
||||
|
||||
addToast({
|
||||
title: 'Copy',
|
||||
description: 'Selection copied to clipboard',
|
||||
variant: 'success',
|
||||
duration: 2000,
|
||||
});
|
||||
}, [tracks, executeCommand, updateTrack, addToast]);
|
||||
|
||||
const handlePaste = React.useCallback(() => {
|
||||
if (!clipboard || !selectedTrackId) return;
|
||||
|
||||
const track = tracks.find((t) => t.id === selectedTrackId);
|
||||
if (!track) return;
|
||||
|
||||
// Paste at current time or at end of buffer
|
||||
const pastePosition = currentTime || track.audioBuffer?.duration || 0;
|
||||
|
||||
const command = createMultiTrackPasteCommand(
|
||||
track.id,
|
||||
track.audioBuffer,
|
||||
clipboard,
|
||||
pastePosition,
|
||||
(trackId, buffer, selection) => {
|
||||
updateTrack(trackId, { audioBuffer: buffer, selection });
|
||||
}
|
||||
);
|
||||
executeCommand(command);
|
||||
|
||||
addToast({
|
||||
title: 'Paste',
|
||||
description: 'Clipboard content pasted',
|
||||
variant: 'success',
|
||||
duration: 2000,
|
||||
});
|
||||
}, [clipboard, selectedTrackId, tracks, currentTime, executeCommand, updateTrack, addToast]);
|
||||
|
||||
const handleDelete = React.useCallback(() => {
|
||||
const track = tracks.find((t) => t.selection);
|
||||
if (!track || !track.audioBuffer || !track.selection) return;
|
||||
|
||||
const command = createMultiTrackDeleteCommand(
|
||||
track.id,
|
||||
track.audioBuffer,
|
||||
track.selection,
|
||||
(trackId, buffer, selection) => {
|
||||
updateTrack(trackId, { audioBuffer: buffer, selection });
|
||||
}
|
||||
);
|
||||
executeCommand(command);
|
||||
|
||||
addToast({
|
||||
title: 'Delete',
|
||||
description: 'Selection deleted',
|
||||
variant: 'success',
|
||||
duration: 2000,
|
||||
});
|
||||
}, [tracks, executeCommand, updateTrack, addToast]);
|
||||
|
||||
const handleDuplicate = React.useCallback(() => {
|
||||
const track = tracks.find((t) => t.selection);
|
||||
if (!track || !track.audioBuffer || !track.selection) return;
|
||||
|
||||
const command = createMultiTrackDuplicateCommand(
|
||||
track.id,
|
||||
track.audioBuffer,
|
||||
track.selection,
|
||||
(trackId, buffer, selection) => {
|
||||
updateTrack(trackId, { audioBuffer: buffer, selection });
|
||||
}
|
||||
);
|
||||
executeCommand(command);
|
||||
|
||||
addToast({
|
||||
title: 'Duplicate',
|
||||
description: 'Selection duplicated',
|
||||
variant: 'success',
|
||||
duration: 2000,
|
||||
});
|
||||
}, [tracks, executeCommand, updateTrack, addToast]);
|
||||
|
||||
// Zoom controls
|
||||
const handleZoomIn = () => {
|
||||
setZoom((prev) => Math.min(20, prev + 1));
|
||||
@@ -265,6 +420,59 @@ export function AudioEditor() {
|
||||
|
||||
if (isTyping) return;
|
||||
|
||||
// Ctrl/Cmd+Z: Undo
|
||||
if ((e.ctrlKey || e.metaKey) && e.key === 'z' && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
if (canUndo) {
|
||||
undo();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Ctrl/Cmd+Shift+Z or Ctrl/Cmd+Y: Redo
|
||||
if (((e.ctrlKey || e.metaKey) && e.key === 'z' && e.shiftKey) || ((e.ctrlKey || e.metaKey) && e.key === 'y')) {
|
||||
e.preventDefault();
|
||||
if (canRedo) {
|
||||
redo();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Ctrl/Cmd+X: Cut
|
||||
if ((e.ctrlKey || e.metaKey) && e.key === 'x') {
|
||||
e.preventDefault();
|
||||
handleCut();
|
||||
return;
|
||||
}
|
||||
|
||||
// Ctrl/Cmd+C: Copy
|
||||
if ((e.ctrlKey || e.metaKey) && e.key === 'c') {
|
||||
e.preventDefault();
|
||||
handleCopy();
|
||||
return;
|
||||
}
|
||||
|
||||
// Ctrl/Cmd+V: Paste
|
||||
if ((e.ctrlKey || e.metaKey) && e.key === 'v') {
|
||||
e.preventDefault();
|
||||
handlePaste();
|
||||
return;
|
||||
}
|
||||
|
||||
// Ctrl/Cmd+D: Duplicate
|
||||
if ((e.ctrlKey || e.metaKey) && e.key === 'd') {
|
||||
e.preventDefault();
|
||||
handleDuplicate();
|
||||
return;
|
||||
}
|
||||
|
||||
// Delete or Backspace: Delete selection
|
||||
if (e.key === 'Delete' || e.key === 'Backspace') {
|
||||
e.preventDefault();
|
||||
handleDelete();
|
||||
return;
|
||||
}
|
||||
|
||||
// Escape: Clear selection
|
||||
if (e.key === 'Escape') {
|
||||
e.preventDefault();
|
||||
@@ -274,7 +482,7 @@ export function AudioEditor() {
|
||||
|
||||
window.addEventListener('keydown', handleKeyDown);
|
||||
return () => window.removeEventListener('keydown', handleKeyDown);
|
||||
}, [togglePlayPause]);
|
||||
}, [togglePlayPause, canUndo, canRedo, undo, redo, handleCut, handleCopy, handlePaste, handleDelete, handleDuplicate]);
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -331,6 +539,7 @@ export function AudioEditor() {
|
||||
onRemoveTrack={handleRemoveTrack}
|
||||
onUpdateTrack={updateTrack}
|
||||
onSeek={seek}
|
||||
onSelectionChange={handleSelectionChange}
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user