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:
@@ -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 && (
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user