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:
@@ -165,3 +165,15 @@ export function concatenateBuffers(
|
||||
|
||||
return newBuffer;
|
||||
}
|
||||
|
||||
/**
|
||||
* Duplicate a segment of audio buffer (extract and insert it after the selection)
|
||||
*/
|
||||
export function duplicateBufferSegment(
|
||||
buffer: AudioBuffer,
|
||||
startTime: number,
|
||||
endTime: number
|
||||
): AudioBuffer {
|
||||
const segment = extractBufferSegment(buffer, startTime, endTime);
|
||||
return insertBufferSegment(buffer, segment, endTime);
|
||||
}
|
||||
|
||||
@@ -37,6 +37,7 @@ export function createTrack(name?: string, color?: TrackColor): Track {
|
||||
effectChain: createEffectChain(`${trackName} Effects`),
|
||||
collapsed: false,
|
||||
selected: false,
|
||||
selection: null,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
190
lib/history/commands/multi-track-edit-command.ts
Normal file
190
lib/history/commands/multi-track-edit-command.ts
Normal file
@@ -0,0 +1,190 @@
|
||||
/**
|
||||
* Multi-track edit commands for audio operations across tracks
|
||||
*/
|
||||
|
||||
import { BaseCommand } from '../command';
|
||||
import type { Track } from '@/types/track';
|
||||
import type { Selection } from '@/types/selection';
|
||||
import {
|
||||
extractBufferSegment,
|
||||
deleteBufferSegment,
|
||||
insertBufferSegment,
|
||||
duplicateBufferSegment,
|
||||
} from '@/lib/audio/buffer-utils';
|
||||
|
||||
export type MultiTrackEditType = 'cut' | 'copy' | 'delete' | 'paste' | 'duplicate';
|
||||
|
||||
export interface MultiTrackEditParams {
|
||||
type: MultiTrackEditType;
|
||||
trackId: string;
|
||||
beforeBuffer: AudioBuffer | null;
|
||||
afterBuffer: AudioBuffer | null;
|
||||
selection?: Selection;
|
||||
clipboardData?: AudioBuffer;
|
||||
pastePosition?: number;
|
||||
onApply: (trackId: string, buffer: AudioBuffer | null, selection: Selection | null) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Command for multi-track edit operations
|
||||
*/
|
||||
export class MultiTrackEditCommand extends BaseCommand {
|
||||
private type: MultiTrackEditType;
|
||||
private trackId: string;
|
||||
private beforeBuffer: AudioBuffer | null;
|
||||
private afterBuffer: AudioBuffer | null;
|
||||
private selection?: Selection;
|
||||
private clipboardData?: AudioBuffer;
|
||||
private pastePosition?: number;
|
||||
private onApply: (trackId: string, buffer: AudioBuffer | null, selection: Selection | null) => void;
|
||||
|
||||
constructor(params: MultiTrackEditParams) {
|
||||
super();
|
||||
this.type = params.type;
|
||||
this.trackId = params.trackId;
|
||||
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 {
|
||||
// For copy, don't modify the buffer, just update selection
|
||||
if (this.type === 'copy') {
|
||||
this.onApply(this.trackId, this.beforeBuffer, this.selection || null);
|
||||
} else {
|
||||
this.onApply(this.trackId, this.afterBuffer, null);
|
||||
}
|
||||
}
|
||||
|
||||
undo(): void {
|
||||
this.onApply(this.trackId, this.beforeBuffer, null);
|
||||
}
|
||||
|
||||
getDescription(): string {
|
||||
switch (this.type) {
|
||||
case 'cut':
|
||||
return 'Cut';
|
||||
case 'copy':
|
||||
return 'Copy';
|
||||
case 'delete':
|
||||
return 'Delete';
|
||||
case 'paste':
|
||||
return 'Paste';
|
||||
case 'duplicate':
|
||||
return 'Duplicate';
|
||||
default:
|
||||
return 'Edit';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Factory functions to create multi-track edit commands
|
||||
*/
|
||||
|
||||
export function createMultiTrackCutCommand(
|
||||
trackId: string,
|
||||
buffer: AudioBuffer,
|
||||
selection: Selection,
|
||||
onApply: (trackId: string, buffer: AudioBuffer | null, selection: Selection | null) => void
|
||||
): MultiTrackEditCommand {
|
||||
const afterBuffer = deleteBufferSegment(buffer, selection.start, selection.end);
|
||||
|
||||
return new MultiTrackEditCommand({
|
||||
type: 'cut',
|
||||
trackId,
|
||||
beforeBuffer: buffer,
|
||||
afterBuffer,
|
||||
selection,
|
||||
onApply,
|
||||
});
|
||||
}
|
||||
|
||||
export function createMultiTrackCopyCommand(
|
||||
trackId: string,
|
||||
buffer: AudioBuffer,
|
||||
selection: Selection,
|
||||
onApply: (trackId: string, buffer: AudioBuffer | null, selection: Selection | null) => void
|
||||
): MultiTrackEditCommand {
|
||||
// Copy doesn't modify the buffer
|
||||
return new MultiTrackEditCommand({
|
||||
type: 'copy',
|
||||
trackId,
|
||||
beforeBuffer: buffer,
|
||||
afterBuffer: buffer,
|
||||
selection,
|
||||
onApply,
|
||||
});
|
||||
}
|
||||
|
||||
export function createMultiTrackDeleteCommand(
|
||||
trackId: string,
|
||||
buffer: AudioBuffer,
|
||||
selection: Selection,
|
||||
onApply: (trackId: string, buffer: AudioBuffer | null, selection: Selection | null) => void
|
||||
): MultiTrackEditCommand {
|
||||
const afterBuffer = deleteBufferSegment(buffer, selection.start, selection.end);
|
||||
|
||||
return new MultiTrackEditCommand({
|
||||
type: 'delete',
|
||||
trackId,
|
||||
beforeBuffer: buffer,
|
||||
afterBuffer,
|
||||
selection,
|
||||
onApply,
|
||||
});
|
||||
}
|
||||
|
||||
export function createMultiTrackPasteCommand(
|
||||
trackId: string,
|
||||
buffer: AudioBuffer | null,
|
||||
clipboardData: AudioBuffer,
|
||||
pastePosition: number,
|
||||
onApply: (trackId: string, buffer: AudioBuffer | null, selection: Selection | null) => void
|
||||
): MultiTrackEditCommand {
|
||||
const targetBuffer = buffer || createSilentBuffer(clipboardData.sampleRate, clipboardData.numberOfChannels, pastePosition);
|
||||
const afterBuffer = insertBufferSegment(targetBuffer, clipboardData, pastePosition);
|
||||
|
||||
return new MultiTrackEditCommand({
|
||||
type: 'paste',
|
||||
trackId,
|
||||
beforeBuffer: buffer,
|
||||
afterBuffer,
|
||||
clipboardData,
|
||||
pastePosition,
|
||||
onApply,
|
||||
});
|
||||
}
|
||||
|
||||
export function createMultiTrackDuplicateCommand(
|
||||
trackId: string,
|
||||
buffer: AudioBuffer,
|
||||
selection: Selection,
|
||||
onApply: (trackId: string, buffer: AudioBuffer | null, selection: Selection | null) => void
|
||||
): MultiTrackEditCommand {
|
||||
const afterBuffer = duplicateBufferSegment(buffer, selection.start, selection.end);
|
||||
|
||||
return new MultiTrackEditCommand({
|
||||
type: 'duplicate',
|
||||
trackId,
|
||||
beforeBuffer: buffer,
|
||||
afterBuffer,
|
||||
selection,
|
||||
onApply,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper function to create a silent buffer
|
||||
*/
|
||||
function createSilentBuffer(sampleRate: number, numberOfChannels: number, duration: number): AudioBuffer {
|
||||
const audioContext = new OfflineAudioContext(
|
||||
numberOfChannels,
|
||||
Math.ceil(duration * sampleRate),
|
||||
sampleRate
|
||||
);
|
||||
return audioContext.createBuffer(numberOfChannels, Math.ceil(duration * sampleRate), sampleRate);
|
||||
}
|
||||
@@ -31,6 +31,7 @@ export function useMultiTrack() {
|
||||
name: String(t.name || 'Untitled Track'), // Ensure name is always a string
|
||||
audioBuffer: null, // Will need to be reloaded
|
||||
effectChain: t.effectChain || createEffectChain(`${t.name} Effects`), // Restore effect chain or create new
|
||||
selection: t.selection || null, // Initialize selection
|
||||
}));
|
||||
}
|
||||
} catch (error) {
|
||||
|
||||
Reference in New Issue
Block a user