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:
2025-11-18 13:05:05 +01:00
parent beb7085c89
commit 74879a42cf
8 changed files with 511 additions and 8 deletions

View File

@@ -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>

View File

@@ -30,6 +30,7 @@ export interface TrackProps {
onRemoveEffect?: (effectId: string) => void;
onUpdateEffect?: (effectId: string, parameters: any) => void;
onAddEffect?: (effectType: EffectType) => void;
onSelectionChange?: (selection: { start: number; end: number } | null) => void;
}
export function Track({
@@ -52,6 +53,7 @@ export function Track({
onRemoveEffect,
onUpdateEffect,
onAddEffect,
onSelectionChange,
}: TrackProps) {
const canvasRef = React.useRef<HTMLCanvasElement>(null);
const containerRef = React.useRef<HTMLDivElement>(null);
@@ -63,6 +65,10 @@ export function Track({
const [themeKey, setThemeKey] = React.useState(0);
const inputRef = React.useRef<HTMLInputElement>(null);
// Selection state
const [isSelecting, setIsSelecting] = React.useState(false);
const [selectionStart, setSelectionStart] = React.useState<number | null>(null);
const handleNameClick = () => {
setIsEditingName(true);
setNameInput(String(track.name || 'Untitled Track'));
@@ -175,27 +181,98 @@ export function Track({
ctx.lineTo(width, height / 2);
ctx.stroke();
// Draw selection overlay
if (track.selection && duration > 0) {
const selStartX = (track.selection.start / duration) * width;
const selEndX = (track.selection.end / duration) * width;
// Draw selection background
ctx.fillStyle = 'rgba(59, 130, 246, 0.2)';
ctx.fillRect(selStartX, 0, selEndX - selStartX, height);
// Draw selection borders
ctx.strokeStyle = 'rgba(59, 130, 246, 0.8)';
ctx.lineWidth = 2;
// Start border
ctx.beginPath();
ctx.moveTo(selStartX, 0);
ctx.lineTo(selStartX, height);
ctx.stroke();
// End border
ctx.beginPath();
ctx.moveTo(selEndX, 0);
ctx.lineTo(selEndX, height);
ctx.stroke();
}
// Draw playhead
if (duration > 0) {
const playheadX = (currentTime / duration) * width;
ctx.strokeStyle = 'rgba(59, 130, 246, 0.8)';
ctx.strokeStyle = 'rgba(239, 68, 68, 0.8)';
ctx.lineWidth = 2;
ctx.beginPath();
ctx.moveTo(playheadX, 0);
ctx.lineTo(playheadX, height);
ctx.stroke();
}
}, [track.audioBuffer, track.color, track.collapsed, track.height, zoom, currentTime, duration, themeKey]);
}, [track.audioBuffer, track.color, track.collapsed, track.height, zoom, currentTime, duration, themeKey, track.selection]);
const handleCanvasClick = (e: React.MouseEvent<HTMLCanvasElement>) => {
if (!onSeek || !duration) return;
const handleCanvasMouseDown = (e: React.MouseEvent<HTMLCanvasElement>) => {
if (!duration) return;
const rect = e.currentTarget.getBoundingClientRect();
const x = e.clientX - rect.left;
const clickTime = (x / rect.width) * duration;
onSeek(clickTime);
// Shift+drag to create selection
if (e.shiftKey) {
setIsSelecting(true);
setSelectionStart(clickTime);
onSelectionChange?.({ start: clickTime, end: clickTime });
} else {
// Regular click clears selection and seeks
onSelectionChange?.(null);
if (onSeek) {
onSeek(clickTime);
}
}
};
const handleCanvasMouseMove = (e: React.MouseEvent<HTMLCanvasElement>) => {
if (!isSelecting || selectionStart === null || !duration) return;
const rect = e.currentTarget.getBoundingClientRect();
const x = e.clientX - rect.left;
const currentTime = (x / rect.width) * duration;
// Clamp to valid time range
const clampedTime = Math.max(0, Math.min(duration, currentTime));
// Update selection (ensure start < end)
const start = Math.min(selectionStart, clampedTime);
const end = Math.max(selectionStart, clampedTime);
onSelectionChange?.({ start, end });
};
const handleCanvasMouseUp = () => {
setIsSelecting(false);
};
// Handle mouse leaving canvas during selection
React.useEffect(() => {
const handleGlobalMouseUp = () => {
if (isSelecting) {
setIsSelecting(false);
}
};
window.addEventListener('mouseup', handleGlobalMouseUp);
return () => window.removeEventListener('mouseup', handleGlobalMouseUp);
}, [isSelecting]);
const handleFileChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file || !onLoadAudio) return;
@@ -422,8 +499,10 @@ export function Track({
{track.audioBuffer ? (
<canvas
ref={canvasRef}
className="absolute inset-0 w-full h-full cursor-pointer"
onClick={handleCanvasClick}
className="absolute inset-0 w-full h-full cursor-crosshair"
onMouseDown={handleCanvasMouseDown}
onMouseMove={handleCanvasMouseMove}
onMouseUp={handleCanvasMouseUp}
/>
) : (
!track.collapsed && (

View File

@@ -20,6 +20,7 @@ export interface TrackListProps {
onRemoveTrack: (trackId: string) => void;
onUpdateTrack: (trackId: string, updates: Partial<TrackType>) => void;
onSeek?: (time: number) => void;
onSelectionChange?: (trackId: string, selection: { start: number; end: number } | null) => void;
}
export function TrackList({
@@ -34,6 +35,7 @@ export function TrackList({
onRemoveTrack,
onUpdateTrack,
onSeek,
onSelectionChange,
}: TrackListProps) {
const [importDialogOpen, setImportDialogOpen] = React.useState(false);
@@ -144,6 +146,11 @@ export function TrackList({
};
onUpdateTrack(track.id, { effectChain: updatedChain });
}}
onSelectionChange={
onSelectionChange
? (selection) => onSelectionChange(track.id, selection)
: undefined
}
/>
))}
</div>