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

@@ -3,6 +3,7 @@
import * as React from 'react';
import { cn } from '@/lib/utils/cn';
import { generateMinMaxPeaks } from '@/lib/waveform/peaks';
import type { Selection } from '@/types/selection';
export interface WaveformProps {
audioBuffer: AudioBuffer | null;
@@ -14,6 +15,8 @@ export interface WaveformProps {
zoom?: number;
scrollOffset?: number;
amplitudeScale?: number;
selection?: Selection | null;
onSelectionChange?: (selection: Selection | null) => void;
}
export function Waveform({
@@ -26,11 +29,15 @@ export function Waveform({
zoom = 1,
scrollOffset = 0,
amplitudeScale = 1,
selection = null,
onSelectionChange,
}: WaveformProps) {
const canvasRef = React.useRef<HTMLCanvasElement>(null);
const containerRef = React.useRef<HTMLDivElement>(null);
const [width, setWidth] = React.useState(800);
const [isDragging, setIsDragging] = React.useState(false);
const [isSelecting, setIsSelecting] = React.useState(false);
const [selectionStart, setSelectionStart] = React.useState<number | null>(null);
// Handle resize
React.useEffect(() => {
@@ -128,6 +135,38 @@ export function Waveform({
ctx.lineTo(width, middle);
ctx.stroke();
// Draw selection
if (selection) {
const selectionStartX = ((selection.start / duration) * visibleWidth) - scrollOffset;
const selectionEndX = ((selection.end / duration) * visibleWidth) - scrollOffset;
if (selectionEndX >= 0 && selectionStartX <= width) {
const clampedStart = Math.max(0, selectionStartX);
const clampedEnd = Math.min(width, selectionEndX);
ctx.fillStyle = 'rgba(59, 130, 246, 0.3)';
ctx.fillRect(clampedStart, 0, clampedEnd - clampedStart, height);
// Selection borders
ctx.strokeStyle = '#3b82f6';
ctx.lineWidth = 2;
if (selectionStartX >= 0 && selectionStartX <= width) {
ctx.beginPath();
ctx.moveTo(selectionStartX, 0);
ctx.lineTo(selectionStartX, height);
ctx.stroke();
}
if (selectionEndX >= 0 && selectionEndX <= width) {
ctx.beginPath();
ctx.moveTo(selectionEndX, 0);
ctx.lineTo(selectionEndX, height);
ctx.stroke();
}
}
}
// Draw playhead
if (progressX >= 0 && progressX <= width) {
ctx.strokeStyle = '#ef4444';
@@ -137,7 +176,7 @@ export function Waveform({
ctx.lineTo(progressX, height);
ctx.stroke();
}
}, [audioBuffer, width, height, currentTime, duration, zoom, scrollOffset, amplitudeScale]);
}, [audioBuffer, width, height, currentTime, duration, zoom, scrollOffset, amplitudeScale, selection]);
const handleClick = (e: React.MouseEvent<HTMLCanvasElement>) => {
if (!onSeek || !duration || isDragging) return;
@@ -157,34 +196,64 @@ export function Waveform({
};
const handleMouseDown = (e: React.MouseEvent<HTMLCanvasElement>) => {
if (!onSeek || !duration) return;
setIsDragging(true);
handleClick(e);
};
const handleMouseMove = (e: React.MouseEvent<HTMLCanvasElement>) => {
if (!isDragging || !onSeek || !duration) return;
if (!duration) return;
const canvas = canvasRef.current;
if (!canvas) return;
const rect = canvas.getBoundingClientRect();
const x = e.clientX - rect.left;
// Account for zoom and scroll
const visibleWidth = width * zoom;
const actualX = x + scrollOffset;
const clickedTime = (actualX / visibleWidth) * duration;
onSeek(Math.max(0, Math.min(duration, clickedTime)));
// Shift key for selection
if (e.shiftKey && onSelectionChange) {
setIsSelecting(true);
setSelectionStart(clickedTime);
onSelectionChange({ start: clickedTime, end: clickedTime });
} else if (onSeek) {
// Regular dragging for scrubbing
setIsDragging(true);
onSeek(clickedTime);
}
};
const handleMouseMove = (e: React.MouseEvent<HTMLCanvasElement>) => {
if (!duration) return;
const canvas = canvasRef.current;
if (!canvas) return;
const rect = canvas.getBoundingClientRect();
const x = e.clientX - rect.left;
const visibleWidth = width * zoom;
const actualX = x + scrollOffset;
const currentTime = (actualX / visibleWidth) * duration;
const clampedTime = Math.max(0, Math.min(duration, currentTime));
// Handle selection dragging
if (isSelecting && onSelectionChange && selectionStart !== null) {
const start = Math.min(selectionStart, clampedTime);
const end = Math.max(selectionStart, clampedTime);
onSelectionChange({ start, end });
}
// Handle scrubbing
else if (isDragging && onSeek) {
onSeek(clampedTime);
}
};
const handleMouseUp = () => {
setIsDragging(false);
setIsSelecting(false);
setSelectionStart(null);
};
const handleMouseLeave = () => {
setIsDragging(false);
setIsSelecting(false);
setSelectionStart(null);
};
return (
@@ -198,7 +267,7 @@ export function Waveform({
onMouseLeave={handleMouseLeave}
className={cn(
'w-full rounded-lg border border-border',
isDragging ? 'cursor-grabbing' : 'cursor-pointer'
isDragging ? 'cursor-grabbing' : isSelecting ? 'cursor-text' : 'cursor-pointer'
)}
style={{ height: `${height}px` }}
/>