Files
sbd2-frontend/src/components/sequencer/SequencerCanvas.tsx

351 lines
12 KiB
TypeScript

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, useEffect } from 'react'
interface SequencerCanvasProps {
tracks: Track[]
duration: number
zoom: number
currentTime: number
isPlaying: boolean
onScroll?: () => void
draggedItem?: any // Current dragged item from parent
dragOverInfo?: {trackId: string, x: number} | null // Drag over position info
onRemoveSound: (soundId: string, trackId: string) => void
timeIntervals: {minorIntervals: number[], majorIntervals: number[], minorInterval: number, majorInterval: number}
}
interface TrackRowProps {
track: Track
duration: number
zoom: number
isPlaying: boolean
currentTime: number
draggedItem?: any // Current dragged item
dragOverInfo?: {trackId: string, x: number} | null // Drag over position info
onRemoveSound: (soundId: string, trackId: string) => void
timeIntervals: {minorIntervals: number[], majorIntervals: number[], minorInterval: number, majorInterval: number}
}
interface PlacedSoundItemProps {
sound: PlacedSound
zoom: number
trackId: string
onRemove: (soundId: string) => void
minorInterval: number
}
function PlacedSoundItem({ sound, zoom, trackId, onRemove, minorInterval }: PlacedSoundItemProps) {
const {
attributes,
listeners,
setNodeRef,
transform,
isDragging,
} = useDraggable({
id: sound.id,
data: {
type: 'placed-sound',
...sound,
trackId,
},
})
const style = transform ? {
transform: `translate3d(${transform.x}px, ${transform.y}px, 0)`,
} : undefined
const width = (sound.duration / 1000) * zoom // Convert ms to seconds for zoom calculation
// Ensure placed sounds are positioned at snapped locations using current minor interval
const startTimeSeconds = sound.startTime / 1000
const snappedStartTime = Math.round(startTimeSeconds / minorInterval) * minorInterval
const left = Math.max(0, snappedStartTime) * zoom
const formatTime = (seconds: number): string => {
const mins = Math.floor(seconds / 60)
const secs = Math.floor(seconds % 60)
return `${mins}:${secs.toString().padStart(2, '0')}`
}
return (
<div
ref={setNodeRef}
style={{
...style,
position: 'absolute',
left: `${left}px`,
width: `${width}px`,
top: '8px',
bottom: '8px',
}}
{...listeners}
{...attributes}
className={`
bg-primary/20 border-2 border-primary/40 rounded
flex items-center justify-between px-2 text-xs
cursor-grab active:cursor-grabbing
hover:bg-primary/30 hover:border-primary/60
group transition-colors
${isDragging ? 'opacity-50 z-10' : 'z-0'}
`}
title={`${sound.name} (${formatTime(sound.duration / 1000)})`}
>
<div className="flex items-center gap-1 min-w-0 flex-1">
<Volume2 className="h-3 w-3 flex-shrink-0 text-primary" />
<span className="truncate font-medium text-primary">
{sound.name}
</span>
</div>
<Button
variant="ghost"
size="sm"
onClick={(e) => {
e.stopPropagation()
onRemove(sound.id)
}}
className="h-6 w-6 p-0 opacity-0 group-hover:opacity-100 transition-opacity text-destructive hover:text-destructive hover:bg-destructive/10"
title="Remove sound"
>
<Trash2 className="h-3 w-3" />
</Button>
</div>
)
}
function TrackRow({ track, duration, zoom, isPlaying, currentTime, draggedItem, dragOverInfo, onRemoveSound, timeIntervals }: TrackRowProps) {
const playheadPosition = (currentTime / 1000) * zoom // Convert ms to seconds for zoom calculation
const { isOver, setNodeRef: setDropRef } = useDroppable({
id: `track-${track.id}`,
data: {
type: 'track',
trackId: track.id,
},
})
const handleRemoveSound = (soundId: string) => {
onRemoveSound(soundId, track.id)
}
const { minorIntervals, majorIntervals } = timeIntervals
return (
<div className="relative" style={{ height: '80px' }}>
<div
ref={setDropRef}
id={`track-${track.id}`}
className={`
h-full border-b border-border/50
relative overflow-hidden
${isOver ? 'bg-accent/30' : 'bg-muted/10'}
transition-colors
`}
>
{/* Grid lines for time markers */}
<div className="absolute inset-0 pointer-events-none">
{/* Minor grid lines */}
{minorIntervals.map((time) => (
<div
key={`minor-${time}`}
className="absolute top-0 bottom-0 w-px bg-border/30"
style={{ left: `${time * zoom}px` }}
/>
))}
{/* Major grid lines */}
{majorIntervals.map((time) => (
<div
key={`major-${time}`}
className="absolute top-0 bottom-0 w-px bg-border/60"
style={{ left: `${time * zoom}px` }}
/>
))}
</div>
{/* Precise drag preview (dragOverInfo.x is already snapped) */}
{draggedItem && dragOverInfo && dragOverInfo.trackId === track.id && (() => {
const soundDurationMs = draggedItem.type === 'sound'
? draggedItem.sound.duration // Already in ms
: draggedItem.duration // Already in ms
const soundDurationSeconds = soundDurationMs / 1000
// dragOverInfo.x is already snapped in the parent component
const startTimeSeconds = dragOverInfo.x / zoom
const endTimeSeconds = startTimeSeconds + soundDurationSeconds
const durationSeconds = duration / 1000
const isValidPosition = startTimeSeconds >= 0 && endTimeSeconds <= durationSeconds
return (
<div
className={`absolute top-2 bottom-2 border-2 border-dashed rounded pointer-events-none z-10 flex items-center px-2 ${
isValidPosition
? 'border-primary/60 bg-primary/10'
: 'border-red-500/60 bg-red-500/10'
}`}
style={{
left: `${Math.max(0, dragOverInfo.x)}px`,
width: `${Math.max(60, soundDurationSeconds * zoom)}px`,
}}
>
<div className={`text-xs truncate font-medium ${
isValidPosition ? 'text-primary/80' : 'text-red-500/80'
}`}>
{draggedItem.type === 'sound'
? (draggedItem.sound.name || draggedItem.sound.filename)
: draggedItem.name
}
{!isValidPosition && ' (Invalid)'}
</div>
</div>
)
})()}
{/* Playhead */}
{isPlaying && (
<div
className="absolute top-0 bottom-0 w-0.5 bg-red-500 pointer-events-none z-30"
style={{ left: `${playheadPosition}px` }}
/>
)}
{/* Placed sounds */}
{track.sounds.map((sound) => (
<PlacedSoundItem
key={sound.id}
sound={sound}
zoom={zoom}
trackId={track.id}
onRemove={handleRemoveSound}
minorInterval={timeIntervals.minorInterval}
/>
))}
</div>
</div>
)
}
export const SequencerCanvas = forwardRef<HTMLDivElement, SequencerCanvasProps>(({
tracks,
duration,
zoom,
currentTime,
isPlaying,
onScroll,
draggedItem,
dragOverInfo,
onRemoveSound,
timeIntervals,
}, ref) => {
const totalWidth = (duration / 1000) * zoom // Convert ms to seconds for zoom calculation
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 { minorIntervals, majorIntervals } = timeIntervals
const handleTracksScroll = (e: React.UIEvent<HTMLDivElement>) => {
// Sync timeline horizontal scroll with tracks
if (timelineRef.current) {
const scrollLeft = e.currentTarget.scrollLeft
// Only update if different to prevent scroll fighting
if (Math.abs(timelineRef.current.scrollLeft - scrollLeft) > 1) {
timelineRef.current.scrollLeft = scrollLeft
}
}
// Call the original scroll handler for vertical sync
onScroll?.()
}
return (
<div ref={setCanvasDropRef} className="h-full flex flex-col overflow-hidden">
{/* Time ruler */}
<div className="h-8 bg-muted/50 border-b border-border/50 flex-shrink-0 overflow-hidden">
<div
ref={timelineRef}
className="h-full overflow-x-auto [&::-webkit-scrollbar]:hidden"
style={{
scrollbarWidth: 'none',
msOverflowStyle: 'none'
}}
>
<div className="relative h-full" style={{ width: `${totalWidth}px` }}>
{/* Minor time markers */}
{minorIntervals.map((time) => (
<div key={`ruler-minor-${time}`} className="absolute top-0 bottom-0" style={{ left: `${time * zoom}px` }}>
<div className="absolute top-0 w-px h-2 bg-border/40" />
</div>
))}
{/* Major time markers with labels */}
{majorIntervals.map((time) => {
const formatTime = (seconds: number): string => {
if (seconds < 60) {
// For times under 1 minute, show seconds with decimal places if needed
return seconds < 10 && seconds % 1 !== 0
? seconds.toFixed(1) + 's'
: Math.floor(seconds) + 's'
} else {
// For times over 1 minute, show MM:SS format
const mins = Math.floor(seconds / 60)
const secs = Math.floor(seconds % 60)
return `${mins}:${secs.toString().padStart(2, '0')}`
}
}
return (
<div key={`ruler-major-${time}`} className="absolute top-0 bottom-0" style={{ left: `${time * zoom}px` }}>
<div className="absolute top-0 w-px h-3 bg-border/60" />
<div className="absolute top-4 text-xs text-muted-foreground font-mono whitespace-nowrap">
{formatTime(time)}
</div>
</div>
)
})}
{/* Playhead in ruler */}
{isPlaying && (
<div
className="absolute top-0 bottom-0 w-0.5 bg-red-500 z-30"
style={{ left: `${(currentTime / 1000) * zoom}px` }}
/>
)}
</div>
</div>
</div>
{/* Tracks */}
<div
ref={ref}
className="flex-1 overflow-auto"
onScroll={handleTracksScroll}
>
<div style={{ width: `${totalWidth}px`, paddingBottom: '52px' }}>
{tracks.map((track) => (
<TrackRow
key={track.id}
track={track}
duration={duration}
zoom={zoom}
isPlaying={isPlaying}
currentTime={currentTime}
draggedItem={draggedItem}
dragOverInfo={dragOverInfo}
onRemoveSound={onRemoveSound}
timeIntervals={timeIntervals}
/>
))}
</div>
</div>
</div>
)
})
SequencerCanvas.displayName = 'SequencerCanvas'