feat: implement Phase 4 - selection and editing features

Added comprehensive audio editing capabilities:
- Region selection with Shift+drag on waveform
- Visual selection feedback with blue overlay and borders
- AudioBuffer manipulation utilities (cut, copy, paste, delete, trim)
- EditControls UI component with edit buttons
- Keyboard shortcuts (Ctrl+A, Ctrl+X, Ctrl+C, Ctrl+V, Delete, Escape)
- Clipboard management for cut/copy/paste operations
- Updated useAudioPlayer hook with loadBuffer method

New files:
- types/selection.ts - Selection and ClipboardData interfaces
- lib/audio/buffer-utils.ts - AudioBuffer manipulation utilities
- components/editor/EditControls.tsx - Edit controls UI

Modified files:
- components/editor/Waveform.tsx - Added selection support
- components/editor/AudioEditor.tsx - Integrated edit operations
- lib/hooks/useAudioPlayer.ts - Added loadBuffer method

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2025-11-17 15:50:42 +01:00
parent 5cf9a69056
commit ed9ac0b24f
6 changed files with 656 additions and 12 deletions

View File

@@ -6,19 +6,32 @@ import { AudioInfo } from './AudioInfo';
import { Waveform } from './Waveform';
import { PlaybackControls } from './PlaybackControls';
import { ZoomControls } from './ZoomControls';
import { EditControls } from './EditControls';
import { useAudioPlayer } from '@/lib/hooks/useAudioPlayer';
import { useToast } from '@/components/ui/Toast';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/Card';
import { Slider } from '@/components/ui/Slider';
import { Loader2 } from 'lucide-react';
import type { Selection, ClipboardData } from '@/types/selection';
import {
extractBufferSegment,
deleteBufferSegment,
insertBufferSegment,
trimBuffer,
} from '@/lib/audio/buffer-utils';
export function AudioEditor() {
// Zoom and scroll state
const [zoom, setZoom] = React.useState(1);
const [scrollOffset, setScrollOffset] = React.useState(0);
const [amplitudeScale, setAmplitudeScale] = React.useState(1);
// Selection state
const [selection, setSelection] = React.useState<Selection | null>(null);
const [clipboard, setClipboard] = React.useState<ClipboardData | null>(null);
const {
loadFile,
loadBuffer,
clearFile,
play,
pause,
@@ -64,6 +77,8 @@ export function AudioEditor() {
setZoom(1);
setScrollOffset(0);
setAmplitudeScale(1);
setSelection(null);
setClipboard(null);
addToast({
title: 'Audio cleared',
description: 'Audio file has been removed',
@@ -72,6 +87,150 @@ export function AudioEditor() {
});
};
// Edit operations
const handleCut = () => {
if (!selection || !audioBuffer) return;
try {
// Copy to clipboard
const clipData = extractBufferSegment(audioBuffer, selection.start, selection.end);
setClipboard({
buffer: clipData,
start: selection.start,
end: selection.end,
duration: selection.end - selection.start,
});
// Delete from buffer
const newBuffer = deleteBufferSegment(audioBuffer, selection.start, selection.end);
loadBuffer(newBuffer);
setSelection(null);
addToast({
title: 'Cut',
description: 'Selection cut to clipboard',
variant: 'success',
duration: 2000,
});
} catch (error) {
addToast({
title: 'Error',
description: 'Failed to cut selection',
variant: 'error',
duration: 3000,
});
}
};
const handleCopy = () => {
if (!selection || !audioBuffer) return;
try {
const clipData = extractBufferSegment(audioBuffer, selection.start, selection.end);
setClipboard({
buffer: clipData,
start: selection.start,
end: selection.end,
duration: selection.end - selection.start,
});
addToast({
title: 'Copied',
description: 'Selection copied to clipboard',
variant: 'success',
duration: 2000,
});
} catch (error) {
addToast({
title: 'Error',
description: 'Failed to copy selection',
variant: 'error',
duration: 3000,
});
}
};
const handlePaste = () => {
if (!clipboard || !audioBuffer) return;
try {
const insertTime = currentTime;
const newBuffer = insertBufferSegment(audioBuffer, clipboard.buffer, insertTime);
loadBuffer(newBuffer);
addToast({
title: 'Pasted',
description: 'Clipboard pasted at current position',
variant: 'success',
duration: 2000,
});
} catch (error) {
addToast({
title: 'Error',
description: 'Failed to paste clipboard',
variant: 'error',
duration: 3000,
});
}
};
const handleDelete = () => {
if (!selection || !audioBuffer) return;
try {
const newBuffer = deleteBufferSegment(audioBuffer, selection.start, selection.end);
loadBuffer(newBuffer);
setSelection(null);
addToast({
title: 'Deleted',
description: 'Selection deleted',
variant: 'success',
duration: 2000,
});
} catch (error) {
addToast({
title: 'Error',
description: 'Failed to delete selection',
variant: 'error',
duration: 3000,
});
}
};
const handleTrim = () => {
if (!selection || !audioBuffer) return;
try {
const newBuffer = trimBuffer(audioBuffer, selection.start, selection.end);
loadBuffer(newBuffer);
setSelection(null);
addToast({
title: 'Trimmed',
description: 'Audio trimmed to selection',
variant: 'success',
duration: 2000,
});
} catch (error) {
addToast({
title: 'Error',
description: 'Failed to trim audio',
variant: 'error',
duration: 3000,
});
}
};
const handleSelectAll = () => {
if (!audioBuffer) return;
setSelection({ start: 0, end: duration });
};
const handleClearSelection = () => {
setSelection(null);
};
// Zoom controls
const handleZoomIn = () => {
setZoom((prev) => Math.min(20, prev + 1));
@@ -96,6 +255,55 @@ export function AudioEditor() {
}
}, [zoom, audioBuffer]);
// Keyboard shortcuts
React.useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
// Prevent shortcuts if typing in an input
if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement) {
return;
}
// Ctrl+A: Select all
if (e.ctrlKey && e.key === 'a') {
e.preventDefault();
handleSelectAll();
}
// Ctrl+X: Cut
if (e.ctrlKey && e.key === 'x') {
e.preventDefault();
handleCut();
}
// Ctrl+C: Copy
if (e.ctrlKey && e.key === 'c') {
e.preventDefault();
handleCopy();
}
// Ctrl+V: Paste
if (e.ctrlKey && e.key === 'v') {
e.preventDefault();
handlePaste();
}
// Delete: Delete selection
if (e.key === 'Delete') {
e.preventDefault();
handleDelete();
}
// Escape: Clear selection
if (e.key === 'Escape') {
e.preventDefault();
handleClearSelection();
}
};
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, [selection, clipboard, audioBuffer, currentTime]);
// Show error toast
React.useEffect(() => {
if (error) {
@@ -151,6 +359,8 @@ export function AudioEditor() {
zoom={zoom}
scrollOffset={scrollOffset}
amplitudeScale={amplitudeScale}
selection={selection}
onSelectionChange={setSelection}
/>
{/* Horizontal scroll for zoomed waveform */}
@@ -171,6 +381,25 @@ export function AudioEditor() {
</CardContent>
</Card>
{/* Edit Controls */}
<Card>
<CardHeader>
<CardTitle>Edit</CardTitle>
</CardHeader>
<CardContent>
<EditControls
selection={selection}
hasClipboard={clipboard !== null}
onCut={handleCut}
onCopy={handleCopy}
onPaste={handlePaste}
onDelete={handleDelete}
onTrim={handleTrim}
onClearSelection={handleClearSelection}
/>
</CardContent>
</Card>
{/* Zoom Controls */}
<Card>
<CardHeader>