feat: implement 100ms snapping for sound placement and enhance zoom controls in Sequencer
This commit is contained in:
@@ -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) => (
|
||||||
|
|||||||
@@ -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 => {
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
Reference in New Issue
Block a user