diff --git a/components/editor/AudioEditor.tsx b/components/editor/AudioEditor.tsx index f295dd1..428b73e 100644 --- a/components/editor/AudioEditor.tsx +++ b/components/editor/AudioEditor.tsx @@ -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(null); const [zoom, setZoom] = React.useState(1); const [masterVolume, setMasterVolume] = React.useState(0.8); + const [clipboard, setClipboard] = React.useState(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} /> diff --git a/components/tracks/Track.tsx b/components/tracks/Track.tsx index 876832d..5fee5bd 100644 --- a/components/tracks/Track.tsx +++ b/components/tracks/Track.tsx @@ -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(null); const containerRef = React.useRef(null); @@ -63,6 +65,10 @@ export function Track({ const [themeKey, setThemeKey] = React.useState(0); const inputRef = React.useRef(null); + // Selection state + const [isSelecting, setIsSelecting] = React.useState(false); + const [selectionStart, setSelectionStart] = React.useState(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) => { - if (!onSeek || !duration) return; + const handleCanvasMouseDown = (e: React.MouseEvent) => { + 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) => { + 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) => { const file = e.target.files?.[0]; if (!file || !onLoadAudio) return; @@ -422,8 +499,10 @@ export function Track({ {track.audioBuffer ? ( ) : ( !track.collapsed && ( diff --git a/components/tracks/TrackList.tsx b/components/tracks/TrackList.tsx index 48ff501..7f9115a 100644 --- a/components/tracks/TrackList.tsx +++ b/components/tracks/TrackList.tsx @@ -20,6 +20,7 @@ export interface TrackListProps { onRemoveTrack: (trackId: string) => void; onUpdateTrack: (trackId: string, updates: Partial) => 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 + } /> ))} diff --git a/lib/audio/buffer-utils.ts b/lib/audio/buffer-utils.ts index cfa7919..3c8c37a 100644 --- a/lib/audio/buffer-utils.ts +++ b/lib/audio/buffer-utils.ts @@ -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); +} diff --git a/lib/audio/track-utils.ts b/lib/audio/track-utils.ts index 3a2af88..ab5e951 100644 --- a/lib/audio/track-utils.ts +++ b/lib/audio/track-utils.ts @@ -37,6 +37,7 @@ export function createTrack(name?: string, color?: TrackColor): Track { effectChain: createEffectChain(`${trackName} Effects`), collapsed: false, selected: false, + selection: null, }; } diff --git a/lib/history/commands/multi-track-edit-command.ts b/lib/history/commands/multi-track-edit-command.ts new file mode 100644 index 0000000..6e55533 --- /dev/null +++ b/lib/history/commands/multi-track-edit-command.ts @@ -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); +} diff --git a/lib/hooks/useMultiTrack.ts b/lib/hooks/useMultiTrack.ts index aaff2c9..cff6faa 100644 --- a/lib/hooks/useMultiTrack.ts +++ b/lib/hooks/useMultiTrack.ts @@ -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) { diff --git a/types/track.ts b/types/track.ts index 6ee0c1e..cd4f3d7 100644 --- a/types/track.ts +++ b/types/track.ts @@ -3,6 +3,7 @@ */ import type { EffectChain } from '@/lib/audio/effects/chain'; +import type { Selection } from './selection'; export interface Track { id: string; @@ -24,6 +25,9 @@ export interface Track { // UI state collapsed: boolean; selected: boolean; + + // Selection (for editing operations) + selection: Selection | null; } export interface TrackState {