feat: implement 100ms snapping for sound placement and enhance zoom controls in Sequencer

This commit is contained in:
JSC
2025-09-13 22:29:37 +02:00
parent 92444fb023
commit 2babeba49e
3 changed files with 111 additions and 27 deletions

View File

@@ -2,7 +2,7 @@ import { useDroppable, useDraggable } from '@dnd-kit/core'
import type { Track, PlacedSound } from '@/pages/SequencerPage' import type { Track, PlacedSound } from '@/pages/SequencerPage'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { Trash2, Volume2 } from 'lucide-react' import { Trash2, Volume2 } from 'lucide-react'
import { useState, forwardRef, useRef, useEffect } from 'react' import { forwardRef, useRef, useEffect } from 'react'
interface SequencerCanvasProps { interface SequencerCanvasProps {
tracks: Track[] tracks: Track[]
@@ -15,6 +15,9 @@ interface SequencerCanvasProps {
dragOverInfo?: {trackId: string, x: number} | null // Drag over position info dragOverInfo?: {trackId: string, x: number} | null // Drag over position info
onRemoveSound: (soundId: string, trackId: string) => void onRemoveSound: (soundId: string, trackId: string) => void
timeIntervals: {minorIntervals: number[], majorIntervals: number[], minorInterval: number, majorInterval: number} timeIntervals: {minorIntervals: number[], majorIntervals: number[], minorInterval: number, majorInterval: number}
onZoomChange?: (newZoom: number, mouseX?: number) => void
minZoom?: number
maxZoom?: number
} }
interface TrackRowProps { interface TrackRowProps {
@@ -34,10 +37,9 @@ interface PlacedSoundItemProps {
zoom: number zoom: number
trackId: string trackId: string
onRemove: (soundId: string) => void onRemove: (soundId: string) => void
minorInterval: number
} }
function PlacedSoundItem({ sound, zoom, trackId, onRemove, minorInterval }: PlacedSoundItemProps) { function PlacedSoundItem({ sound, zoom, trackId, onRemove }: PlacedSoundItemProps) {
const { const {
attributes, attributes,
listeners, listeners,
@@ -58,9 +60,10 @@ function PlacedSoundItem({ sound, zoom, trackId, onRemove, minorInterval }: Plac
} : undefined } : undefined
const width = (sound.duration / 1000) * zoom // Convert ms to seconds for zoom calculation 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 // Ensure placed sounds are positioned at 100ms snapped locations
const startTimeSeconds = sound.startTime / 1000 const startTimeSeconds = sound.startTime / 1000
const snappedStartTime = Math.round(startTimeSeconds / minorInterval) * minorInterval const snapIntervalMs = 100 // 100ms snap interval
const snappedStartTime = Math.round((startTimeSeconds * 1000) / snapIntervalMs) * snapIntervalMs / 1000
const left = Math.max(0, snappedStartTime) * zoom const left = Math.max(0, snappedStartTime) * zoom
const formatTime = (seconds: number): string => { const formatTime = (seconds: number): string => {
@@ -218,7 +221,6 @@ function TrackRow({ track, duration, zoom, isPlaying, currentTime, draggedItem,
zoom={zoom} zoom={zoom}
trackId={track.id} trackId={track.id}
onRemove={handleRemoveSound} onRemove={handleRemoveSound}
minorInterval={timeIntervals.minorInterval}
/> />
))} ))}
</div> </div>
@@ -237,6 +239,9 @@ export const SequencerCanvas = forwardRef<HTMLDivElement, SequencerCanvasProps>(
dragOverInfo, dragOverInfo,
onRemoveSound, onRemoveSound,
timeIntervals, timeIntervals,
onZoomChange,
minZoom = 10,
maxZoom = 200,
}, ref) => { }, ref) => {
const totalWidth = (duration / 1000) * zoom // Convert ms to seconds for zoom calculation const totalWidth = (duration / 1000) * zoom // Convert ms to seconds for zoom calculation
const timelineRef = useRef<HTMLDivElement>(null) const timelineRef = useRef<HTMLDivElement>(null)
@@ -264,6 +269,54 @@ export const SequencerCanvas = forwardRef<HTMLDivElement, SequencerCanvasProps>(
onScroll?.() onScroll?.()
} }
// Handle mouse wheel zoom with Ctrl key using native event listeners
useEffect(() => {
if (!onZoomChange) return
const handleWheel = (e: WheelEvent) => {
if (!e.ctrlKey) return
e.preventDefault()
e.stopPropagation()
// Use the same discrete steps as the zoom buttons (+5/-5)
const zoomStep = 5
const delta = e.deltaY > 0 ? -zoomStep : zoomStep // Inverted for natural feel
const newZoom = Math.min(Math.max(zoom + delta, minZoom), maxZoom)
if (newZoom !== zoom) {
// Get mouse position relative to the scrollable content
const target = e.target as HTMLElement
const scrollContainer = target.closest('[data-scroll-container]') as HTMLElement
if (scrollContainer) {
const rect = scrollContainer.getBoundingClientRect()
const mouseX = e.clientX - rect.left + scrollContainer.scrollLeft
onZoomChange(newZoom, mouseX)
}
}
}
// Add wheel event listeners to both timeline and tracks
const timelineElement = timelineRef.current
const tracksElement = ref && typeof ref === 'object' && ref.current ? ref.current : null
if (timelineElement) {
timelineElement.addEventListener('wheel', handleWheel, { passive: false })
}
if (tracksElement) {
tracksElement.addEventListener('wheel', handleWheel, { passive: false })
}
return () => {
if (timelineElement) {
timelineElement.removeEventListener('wheel', handleWheel)
}
if (tracksElement) {
tracksElement.removeEventListener('wheel', handleWheel)
}
}
}, [onZoomChange, zoom, minZoom, maxZoom, ref])
return ( return (
<div ref={setCanvasDropRef} className="h-full flex flex-col overflow-hidden"> <div ref={setCanvasDropRef} className="h-full flex flex-col overflow-hidden">
{/* Time ruler */} {/* Time ruler */}
@@ -275,6 +328,7 @@ export const SequencerCanvas = forwardRef<HTMLDivElement, SequencerCanvasProps>(
scrollbarWidth: 'none', scrollbarWidth: 'none',
msOverflowStyle: 'none' msOverflowStyle: 'none'
}} }}
data-scroll-container
> >
<div className="relative h-full" style={{ width: `${totalWidth}px` }}> <div className="relative h-full" style={{ width: `${totalWidth}px` }}>
{/* Minor time markers */} {/* Minor time markers */}
@@ -326,6 +380,7 @@ export const SequencerCanvas = forwardRef<HTMLDivElement, SequencerCanvasProps>(
ref={ref} ref={ref}
className="flex-1 overflow-auto" className="flex-1 overflow-auto"
onScroll={handleTracksScroll} onScroll={handleTracksScroll}
data-scroll-container
> >
<div style={{ width: `${totalWidth}px`, paddingBottom: '52px' }}> <div style={{ width: `${totalWidth}px`, paddingBottom: '52px' }}>
{tracks.map((track) => ( {tracks.map((track) => (

View File

@@ -53,11 +53,11 @@ export function TimelineControls({
} }
const increaseZoom = () => { const increaseZoom = () => {
onZoomChange(Math.min(maxZoom, zoom + 10)) onZoomChange(Math.min(maxZoom, zoom + 5))
} }
const decreaseZoom = () => { const decreaseZoom = () => {
onZoomChange(Math.max(minZoom, zoom - 10)) onZoomChange(Math.max(minZoom, zoom - 5))
} }
const formatTime = (seconds: number): string => { const formatTime = (seconds: number): string => {

View File

@@ -30,7 +30,7 @@ interface SequencerState {
const INITIAL_DURATION = 30000 // 30 seconds in milliseconds const INITIAL_DURATION = 30000 // 30 seconds in milliseconds
const INITIAL_ZOOM = 40 // 40 pixels per second const INITIAL_ZOOM = 40 // 40 pixels per second
const MIN_ZOOM = 10 const MIN_ZOOM = 5
const MAX_ZOOM = 200 const MAX_ZOOM = 200
export function SequencerPage() { export function SequencerPage() {
@@ -124,12 +124,13 @@ export function SequencerPage() {
return { minorIntervals, majorIntervals, minorInterval: selectedIntervals.minor, majorInterval: selectedIntervals.major } return { minorIntervals, majorIntervals, minorInterval: selectedIntervals.minor, majorInterval: selectedIntervals.major }
}, []) }, [])
// Helper function to snap time to the current minor interval // Helper function to snap time to 100ms intervals
const snapToGrid = useCallback((timeInSeconds: number, zoom: number, duration: number): number => { const snapToGrid = useCallback((timeInSeconds: number): number => {
const { minorInterval } = getTimeIntervals(zoom, duration) const snapIntervalMs = 100 // 100ms snap interval
const snappedTime = Math.round(timeInSeconds / minorInterval) * minorInterval const timeInMs = Math.max(0, timeInSeconds * 1000) // Ensure non-negative
return Math.max(0, snappedTime) // Ensure non-negative const snappedMs = Math.round(timeInMs / snapIntervalMs) * snapIntervalMs
}, [getTimeIntervals]) return Math.max(0, snappedMs / 1000) // Convert back to seconds, ensure non-negative
}, [])
// Update drag over info based on current mouse position and over target // Update drag over info based on current mouse position and over target
useEffect(() => { useEffect(() => {
@@ -146,9 +147,9 @@ export function SequencerPage() {
currentMousePos.y <= rect.bottom currentMousePos.y <= rect.bottom
) { ) {
const rawX = currentMousePos.x - rect.left const rawX = currentMousePos.x - rect.left
// Apply adaptive snapping to the drag over position // Apply 100ms snapping to the drag over position
const rawTimeSeconds = rawX / state.zoom const rawTimeSeconds = rawX / state.zoom
const snappedTimeSeconds = snapToGrid(rawTimeSeconds, state.zoom, state.duration) const snappedTimeSeconds = snapToGrid(rawTimeSeconds)
const snappedX = snappedTimeSeconds * state.zoom const snappedX = snappedTimeSeconds * state.zoom
setDragOverInfo({ trackId: track.id, x: Math.max(0, snappedX) }) setDragOverInfo({ trackId: track.id, x: Math.max(0, snappedX) })
return return
@@ -160,7 +161,7 @@ export function SequencerPage() {
} else { } else {
setDragOverInfo(null) setDragOverInfo(null)
} }
}, [draggedItem, currentMousePos, state.tracks, state.zoom, state.duration, snapToGrid]) }, [draggedItem, currentMousePos, state.tracks, state.zoom, snapToGrid])
const handleDragEnd = useCallback((event: DragEndEvent) => { const handleDragEnd = useCallback((event: DragEndEvent) => {
const { active, over } = event const { active, over } = event
@@ -304,22 +305,47 @@ export function SequencerPage() {
})) }))
} }
const handlePlay = () => { // const handlePlay = () => {
setState(prev => ({ ...prev, isPlaying: !prev.isPlaying })) // setState(prev => ({ ...prev, isPlaying: !prev.isPlaying }))
} // }
const handleStop = () => { // const handleStop = () => {
setState(prev => ({ ...prev, isPlaying: false, currentTime: 0 })) // setState(prev => ({ ...prev, isPlaying: false, currentTime: 0 }))
} // }
const handleReset = () => { // const handleReset = () => {
setState(prev => ({ ...prev, currentTime: 0 })) // setState(prev => ({ ...prev, currentTime: 0 }))
} // }
const handleZoomChange = (value: number) => { const handleZoomChange = (value: number) => {
setState(prev => ({ ...prev, zoom: value })) setState(prev => ({ ...prev, zoom: value }))
} }
// Handle mouse wheel zoom with position centering
const handleZoomChangeWithPosition = (newZoom: number, mouseX?: number) => {
if (mouseX !== undefined && sequencerCanvasRef.current) {
const oldZoom = state.zoom
const currentScrollLeft = sequencerCanvasRef.current.scrollLeft
// Calculate the time position that the mouse is pointing at
const timeAtMouse = (currentScrollLeft + mouseX) / oldZoom
// Calculate what the new scroll position should be to keep the same time under the mouse
const newScrollLeft = timeAtMouse * newZoom - mouseX
setState(prev => ({ ...prev, zoom: newZoom }))
// Apply the new scroll position after the zoom change
requestAnimationFrame(() => {
if (sequencerCanvasRef.current) {
sequencerCanvasRef.current.scrollLeft = Math.max(0, newScrollLeft)
}
})
} else {
setState(prev => ({ ...prev, zoom: newZoom }))
}
}
const handleDurationChange = (duration: number) => { const handleDurationChange = (duration: number) => {
setState(prev => ({ ...prev, duration })) setState(prev => ({ ...prev, duration }))
} }
@@ -425,6 +451,9 @@ export function SequencerPage() {
dragOverInfo={dragOverInfo} dragOverInfo={dragOverInfo}
onRemoveSound={handleRemoveSound} onRemoveSound={handleRemoveSound}
timeIntervals={getTimeIntervals(state.zoom, state.duration)} timeIntervals={getTimeIntervals(state.zoom, state.duration)}
onZoomChange={handleZoomChangeWithPosition}
minZoom={MIN_ZOOM}
maxZoom={MAX_ZOOM}
/> />
</div> </div>
</div> </div>