diff --git a/src/components/sequencer/SequencerCanvas.tsx b/src/components/sequencer/SequencerCanvas.tsx index 719c092..ab8037e 100644 --- a/src/components/sequencer/SequencerCanvas.tsx +++ b/src/components/sequencer/SequencerCanvas.tsx @@ -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, useEffect } from 'react' +import { forwardRef, useRef, useEffect } from 'react' interface SequencerCanvasProps { tracks: Track[] @@ -15,6 +15,9 @@ interface SequencerCanvasProps { 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} + onZoomChange?: (newZoom: number, mouseX?: number) => void + minZoom?: number + maxZoom?: number } interface TrackRowProps { @@ -34,10 +37,9 @@ interface PlacedSoundItemProps { zoom: number trackId: string onRemove: (soundId: string) => void - minorInterval: number } -function PlacedSoundItem({ sound, zoom, trackId, onRemove, minorInterval }: PlacedSoundItemProps) { +function PlacedSoundItem({ sound, zoom, trackId, onRemove }: PlacedSoundItemProps) { const { attributes, listeners, @@ -58,9 +60,10 @@ function PlacedSoundItem({ sound, zoom, trackId, onRemove, minorInterval }: Plac } : 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 + // Ensure placed sounds are positioned at 100ms snapped locations 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 formatTime = (seconds: number): string => { @@ -218,7 +221,6 @@ function TrackRow({ track, duration, zoom, isPlaying, currentTime, draggedItem, zoom={zoom} trackId={track.id} onRemove={handleRemoveSound} - minorInterval={timeIntervals.minorInterval} /> ))} @@ -237,6 +239,9 @@ export const SequencerCanvas = forwardRef( dragOverInfo, onRemoveSound, timeIntervals, + onZoomChange, + minZoom = 10, + maxZoom = 200, }, ref) => { const totalWidth = (duration / 1000) * zoom // Convert ms to seconds for zoom calculation const timelineRef = useRef(null) @@ -264,6 +269,54 @@ export const SequencerCanvas = forwardRef( 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 (
{/* Time ruler */} @@ -275,6 +328,7 @@ export const SequencerCanvas = forwardRef( scrollbarWidth: 'none', msOverflowStyle: 'none' }} + data-scroll-container >
{/* Minor time markers */} @@ -326,6 +380,7 @@ export const SequencerCanvas = forwardRef( ref={ref} className="flex-1 overflow-auto" onScroll={handleTracksScroll} + data-scroll-container >
{tracks.map((track) => ( diff --git a/src/components/sequencer/TimelineControls.tsx b/src/components/sequencer/TimelineControls.tsx index ab46dea..46f6fac 100644 --- a/src/components/sequencer/TimelineControls.tsx +++ b/src/components/sequencer/TimelineControls.tsx @@ -53,11 +53,11 @@ export function TimelineControls({ } const increaseZoom = () => { - onZoomChange(Math.min(maxZoom, zoom + 10)) + onZoomChange(Math.min(maxZoom, zoom + 5)) } const decreaseZoom = () => { - onZoomChange(Math.max(minZoom, zoom - 10)) + onZoomChange(Math.max(minZoom, zoom - 5)) } const formatTime = (seconds: number): string => { diff --git a/src/pages/SequencerPage.tsx b/src/pages/SequencerPage.tsx index 81d8b29..f2bee81 100644 --- a/src/pages/SequencerPage.tsx +++ b/src/pages/SequencerPage.tsx @@ -30,7 +30,7 @@ interface SequencerState { const INITIAL_DURATION = 30000 // 30 seconds in milliseconds const INITIAL_ZOOM = 40 // 40 pixels per second -const MIN_ZOOM = 10 +const MIN_ZOOM = 5 const MAX_ZOOM = 200 export function SequencerPage() { @@ -124,12 +124,13 @@ export function SequencerPage() { return { minorIntervals, majorIntervals, minorInterval: selectedIntervals.minor, majorInterval: selectedIntervals.major } }, []) - // Helper function to snap time to the current minor interval - const snapToGrid = useCallback((timeInSeconds: number, zoom: number, duration: number): number => { - const { minorInterval } = getTimeIntervals(zoom, duration) - const snappedTime = Math.round(timeInSeconds / minorInterval) * minorInterval - return Math.max(0, snappedTime) // Ensure non-negative - }, [getTimeIntervals]) + // Helper function to snap time to 100ms intervals + const snapToGrid = useCallback((timeInSeconds: number): number => { + const snapIntervalMs = 100 // 100ms snap interval + const timeInMs = Math.max(0, timeInSeconds * 1000) // Ensure non-negative + const snappedMs = Math.round(timeInMs / snapIntervalMs) * snapIntervalMs + 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 useEffect(() => { @@ -146,9 +147,9 @@ export function SequencerPage() { currentMousePos.y <= rect.bottom ) { 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 snappedTimeSeconds = snapToGrid(rawTimeSeconds, state.zoom, state.duration) + const snappedTimeSeconds = snapToGrid(rawTimeSeconds) const snappedX = snappedTimeSeconds * state.zoom setDragOverInfo({ trackId: track.id, x: Math.max(0, snappedX) }) return @@ -160,7 +161,7 @@ export function SequencerPage() { } else { setDragOverInfo(null) } - }, [draggedItem, currentMousePos, state.tracks, state.zoom, state.duration, snapToGrid]) + }, [draggedItem, currentMousePos, state.tracks, state.zoom, snapToGrid]) const handleDragEnd = useCallback((event: DragEndEvent) => { const { active, over } = event @@ -304,22 +305,47 @@ export function SequencerPage() { })) } - const handlePlay = () => { - setState(prev => ({ ...prev, isPlaying: !prev.isPlaying })) - } + // const handlePlay = () => { + // setState(prev => ({ ...prev, isPlaying: !prev.isPlaying })) + // } - const handleStop = () => { - setState(prev => ({ ...prev, isPlaying: false, currentTime: 0 })) - } + // const handleStop = () => { + // setState(prev => ({ ...prev, isPlaying: false, currentTime: 0 })) + // } - const handleReset = () => { - setState(prev => ({ ...prev, currentTime: 0 })) - } + // const handleReset = () => { + // setState(prev => ({ ...prev, currentTime: 0 })) + // } const handleZoomChange = (value: number) => { 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) => { setState(prev => ({ ...prev, duration })) } @@ -425,6 +451,9 @@ export function SequencerPage() { dragOverInfo={dragOverInfo} onRemoveSound={handleRemoveSound} timeIntervals={getTimeIntervals(state.zoom, state.duration)} + onZoomChange={handleZoomChangeWithPosition} + minZoom={MIN_ZOOM} + maxZoom={MAX_ZOOM} />