351 lines
12 KiB
TypeScript
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' |