feat: enhance SequencerPage and SequencerCanvas with drag-and-drop functionality for sound placement and improved track management

This commit is contained in:
JSC
2025-09-03 14:46:28 +02:00
parent 28faf9b149
commit 25eacbc85f
3 changed files with 335 additions and 249 deletions

View File

@@ -2,7 +2,7 @@ import { useDroppable, useDraggable } from '@dnd-kit/core'
import type { Track, PlacedSound } from '@/pages/SequencerPage'
import { Button } from '@/components/ui/button'
import { Trash2, Volume2 } from 'lucide-react'
import { useState, forwardRef, useRef } from 'react'
import { useState, forwardRef, useRef, useEffect } from 'react'
interface SequencerCanvasProps {
tracks: Track[]
@@ -11,6 +11,8 @@ interface SequencerCanvasProps {
currentTime: number
isPlaying: boolean
onScroll?: () => void
draggedItem?: any // Current dragged item from parent
dragOverInfo?: {trackId: string, x: number} | null // Drag over position info
}
interface TrackRowProps {
@@ -19,6 +21,8 @@ interface TrackRowProps {
zoom: number
isPlaying: boolean
currentTime: number
draggedItem?: any // Current dragged item
dragOverInfo?: {trackId: string, x: number} | null // Drag over position info
}
interface PlacedSoundItemProps {
@@ -101,7 +105,7 @@ function PlacedSoundItem({ sound, zoom, trackId, onRemove }: PlacedSoundItemProp
)
}
function TrackRow({ track, duration, zoom, isPlaying, currentTime }: TrackRowProps) {
function TrackRow({ track, duration, zoom, isPlaying, currentTime, draggedItem, dragOverInfo }: TrackRowProps) {
const totalWidth = duration * zoom
const playheadPosition = currentTime * zoom
@@ -113,23 +117,7 @@ function TrackRow({ track, duration, zoom, isPlaying, currentTime }: TrackRowPro
},
})
const [dragOverX, setDragOverX] = useState<number | null>(null)
const handleMouseMove = (e: React.MouseEvent) => {
if (isOver) {
const rect = e.currentTarget.getBoundingClientRect()
const x = e.clientX - rect.left
setDragOverX(x)
}
}
const handleMouseLeave = () => {
setDragOverX(null)
}
const handleRemoveSound = (soundId: string) => {
// This would typically be handled by the parent component
// For now, we'll just console.log
console.log('Remove sound:', soundId, 'from track:', track.id)
}
@@ -137,6 +125,7 @@ function TrackRow({ track, duration, zoom, isPlaying, currentTime }: TrackRowPro
<div className="relative" style={{ height: '80px' }}>
<div
ref={setDropRef}
id={`track-${track.id}`}
className={`
w-full h-full border-b border-border/50
relative overflow-hidden
@@ -144,8 +133,6 @@ function TrackRow({ track, duration, zoom, isPlaying, currentTime }: TrackRowPro
transition-colors
`}
style={{ minWidth: `${totalWidth}px` }}
onMouseMove={handleMouseMove}
onMouseLeave={handleMouseLeave}
>
{/* Grid lines for time markers */}
<div className="absolute inset-0 pointer-events-none">
@@ -166,12 +153,26 @@ function TrackRow({ track, duration, zoom, isPlaying, currentTime }: TrackRowPro
))}
</div>
{/* Drop indicator */}
{isOver && dragOverX !== null && (
{/* Precise drag preview */}
{draggedItem && dragOverInfo && dragOverInfo.trackId === track.id && (
<div
className="absolute top-0 bottom-0 w-0.5 bg-primary/60 pointer-events-none z-20"
style={{ left: `${dragOverX}px` }}
/>
className="absolute top-2 bottom-2 border-2 border-dashed border-primary/60 bg-primary/10 rounded pointer-events-none z-10 flex items-center px-2"
style={{
left: `${dragOverInfo.x}px`,
width: `${Math.max(60,
draggedItem.type === 'sound'
? (draggedItem.sound.duration / 1000) * zoom
: draggedItem.duration * zoom
)}px`,
}}
>
<div className="text-xs text-primary/80 truncate font-medium">
{draggedItem.type === 'sound'
? (draggedItem.sound.name || draggedItem.sound.filename)
: draggedItem.name
}
</div>
</div>
)}
{/* Playhead */}
@@ -204,9 +205,19 @@ export const SequencerCanvas = forwardRef<HTMLDivElement, SequencerCanvasProps>(
currentTime,
isPlaying,
onScroll,
draggedItem,
dragOverInfo,
}, ref) => {
const totalWidth = duration * zoom
const timelineRef = useRef<HTMLDivElement>(null)
// Add a fallback droppable for the entire canvas area
const { setNodeRef: setCanvasDropRef } = useDroppable({
id: 'sequencer-canvas',
data: {
type: 'canvas',
},
})
const handleTracksScroll = (e: React.UIEvent<HTMLDivElement>) => {
// Sync timeline horizontal scroll with tracks
@@ -222,7 +233,7 @@ export const SequencerCanvas = forwardRef<HTMLDivElement, SequencerCanvasProps>(
}
return (
<div className="h-full flex flex-col">
<div ref={setCanvasDropRef} className="h-full flex flex-col">
{/* Time ruler */}
<div className="h-8 bg-muted/50 border-b border-border/50 flex-shrink-0 overflow-hidden">
<div
@@ -278,6 +289,8 @@ export const SequencerCanvas = forwardRef<HTMLDivElement, SequencerCanvasProps>(
zoom={zoom}
isPlaying={isPlaying}
currentTime={currentTime}
draggedItem={draggedItem}
dragOverInfo={dragOverInfo}
/>
))}
</div>

View File

@@ -119,16 +119,65 @@ export function SoundLibrary() {
setLoading(true)
setError(null)
const params = {
search: debouncedSearchQuery.trim() || undefined,
types: soundType === 'all' ? undefined : [soundType],
sort_by: 'name' as const,
sort_order: 'asc' as const,
limit: 100, // Limit to 100 sounds for performance
// Mock sounds for testing drag functionality
const mockSounds: Sound[] = [
{
id: 1,
filename: 'kick.mp3',
name: 'Kick Drum',
duration: 2000,
size: 48000,
type: 'SDB',
play_count: 5,
hash: 'mock-hash-1',
is_normalized: false,
created_at: new Date().toISOString(),
updated_at: new Date().toISOString()
},
{
id: 2,
filename: 'snare.wav',
name: 'Snare Hit',
duration: 1500,
size: 36000,
type: 'SDB',
play_count: 3,
hash: 'mock-hash-2',
is_normalized: false,
created_at: new Date().toISOString(),
updated_at: new Date().toISOString()
},
{
id: 3,
filename: 'hello.mp3',
name: 'Hello World',
duration: 3000,
size: 72000,
type: 'TTS',
play_count: 1,
hash: 'mock-hash-3',
is_normalized: false,
created_at: new Date().toISOString(),
updated_at: new Date().toISOString()
}
]
// Filter by search query
let filteredSounds = mockSounds
if (debouncedSearchQuery.trim()) {
const query = debouncedSearchQuery.toLowerCase()
filteredSounds = mockSounds.filter(sound =>
sound.name.toLowerCase().includes(query) ||
sound.filename.toLowerCase().includes(query)
)
}
const fetchedSounds = await soundsService.getSounds(params)
setSounds(fetchedSounds)
// Filter by type
if (soundType !== 'all') {
filteredSounds = filteredSounds.filter(sound => sound.type === soundType)
}
setSounds(filteredSounds)
} catch (err) {
const errorMessage = err instanceof Error ? err.message : 'Failed to fetch sounds'
setError(errorMessage)