// timeline-viewport.js - Viewport zoom/scroll control export class ViewportController { constructor(state, dom, renderCallback) { this.state = state; this.dom = dom; this.renderCallback = renderCallback; // Constants this.TIMELINE_LEFT_PADDING = 20; this.SCROLL_VIEWPORT_FRACTION = 0.4; this.SMOOTH_SCROLL_SPEED = 0.1; this.VERTICAL_SCROLL_SPEED = 0.3; this.init(); } init() { // Zoom controls this.dom.zoomSlider.addEventListener('input', e => this.handleZoomSlider(e)); // Scroll sync this.dom.timelineContent.addEventListener('scroll', () => this.handleScroll()); // Wheel handling - capture at container level to override all child elements const wheelHandler = e => this.handleWheel(e); this.dom.timelineContainer.addEventListener('wheel', wheelHandler, { passive: false, capture: true }); // Prevent wheel bubbling from UI containers outside timeline document.querySelector('header').addEventListener('wheel', e => e.stopPropagation()); this.dom.propertiesPanel.addEventListener('wheel', e => e.stopPropagation()); document.querySelector('.zoom-controls').addEventListener('wheel', e => e.stopPropagation()); document.querySelector('.stats').addEventListener('wheel', e => e.stopPropagation()); // Waveform hover tracking this.dom.waveformContainer.addEventListener('mouseenter', () => this.showWaveformCursor()); this.dom.waveformContainer.addEventListener('mouseleave', () => this.hideWaveformCursor()); this.dom.waveformContainer.addEventListener('mousemove', e => this.updateWaveformCursor(e)); } handleZoomSlider(e) { this.state.pixelsPerBeat = parseInt(e.target.value); this.dom.zoomLevel.textContent = `${this.state.pixelsPerBeat}%`; this.renderCallback('zoom'); } handleScroll() { const scrollLeft = this.dom.timelineContent.scrollLeft; this.dom.cpuLoadCanvas.style.left = `-${scrollLeft}px`; this.dom.waveformCanvas.style.left = `-${scrollLeft}px`; document.getElementById('timeMarkers').style.transform = `translateX(-${scrollLeft}px)`; this.updateIndicatorPosition(this.timeToBeats(this.state.playbackOffset), false); } handleWheel(e) { e.preventDefault(); // Zoom with ctrl/cmd if (e.ctrlKey || e.metaKey) { this.handleZoomWheel(e); return; } // Horizontal scroll this.dom.timelineContent.scrollLeft += e.deltaY; // Auto-scroll to active sequence this.autoScrollToSequence(); } handleZoomWheel(e) { const rect = this.dom.timelineContent.getBoundingClientRect(); const mouseX = e.clientX - rect.left; const scrollLeft = this.dom.timelineContent.scrollLeft; const timeUnderCursor = (scrollLeft + mouseX) / this.state.pixelsPerBeat; const zoomDelta = e.deltaY > 0 ? -10 : 10; const newPixelsPerBeat = Math.max(10, Math.min(500, this.state.pixelsPerBeat + zoomDelta)); if (newPixelsPerBeat !== this.state.pixelsPerBeat) { this.state.pixelsPerBeat = newPixelsPerBeat; this.dom.zoomSlider.value = this.state.pixelsPerBeat; this.dom.zoomLevel.textContent = `${this.state.pixelsPerBeat}%`; this.renderCallback('zoomWheel'); this.dom.timelineContent.scrollLeft = timeUnderCursor * newPixelsPerBeat - mouseX; this.updateIndicatorPosition(this.timeToBeats(this.state.playbackOffset), false); } } autoScrollToSequence() { const currentScrollLeft = this.dom.timelineContent.scrollLeft; const viewportWidth = this.dom.timelineContent.clientWidth; const slack = (viewportWidth / this.state.pixelsPerBeat) * 0.1; const currentTime = (currentScrollLeft / this.state.pixelsPerBeat) + slack; let targetSeqIndex = 0; for (let i = 0; i < this.state.sequences.length; i++) { if (this.state.sequences[i].startTime <= currentTime) targetSeqIndex = i; else break; } if (targetSeqIndex !== this.state.lastActiveSeqIndex && this.state.sequences.length > 0) { this.state.lastActiveSeqIndex = targetSeqIndex; const seqDivs = this.dom.timeline.querySelectorAll('.sequence'); if (seqDivs[targetSeqIndex]) { seqDivs[targetSeqIndex].classList.add('active-flash'); setTimeout(() => seqDivs[targetSeqIndex]?.classList.remove('active-flash'), 600); } } const targetScrollTop = this.state.sequences[targetSeqIndex]?._yPosition || 0; const currentScrollTop = this.dom.timelineContent.scrollTop; const scrollDiff = targetScrollTop - currentScrollTop; if (Math.abs(scrollDiff) > 5) { this.dom.timelineContent.scrollTop += scrollDiff * this.VERTICAL_SCROLL_SPEED; } } updateIndicatorPosition(beats, smoothScroll = false) { const timelineX = beats * this.state.pixelsPerBeat; const scrollLeft = this.dom.timelineContent.scrollLeft; this.dom.playbackIndicator.style.left = `${timelineX - scrollLeft + this.TIMELINE_LEFT_PADDING}px`; if (smoothScroll) { const targetScroll = timelineX - this.dom.timelineContent.clientWidth * this.SCROLL_VIEWPORT_FRACTION; const scrollDiff = targetScroll - scrollLeft; if (Math.abs(scrollDiff) > 5) { this.dom.timelineContent.scrollLeft += scrollDiff * this.SMOOTH_SCROLL_SPEED; } } } showWaveformCursor() { if (!this.state.audioBuffer) return; this.dom.waveformCursor.style.display = 'block'; this.dom.waveformTooltip.style.display = 'block'; } hideWaveformCursor() { this.dom.waveformCursor.style.display = 'none'; this.dom.waveformTooltip.style.display = 'none'; } updateWaveformCursor(e) { if (!this.state.audioBuffer) return; const rect = this.dom.waveformContainer.getBoundingClientRect(); const mouseX = e.clientX - rect.left; const scrollLeft = this.dom.timelineContent.scrollLeft; const timeBeats = (scrollLeft + mouseX) / this.state.pixelsPerBeat; const timeSeconds = timeBeats * this.state.secondsPerBeat; // Position cursor this.dom.waveformCursor.style.left = `${mouseX}px`; // Position and update tooltip const tooltipText = `${timeSeconds.toFixed(3)}s (${timeBeats.toFixed(2)}b)`; this.dom.waveformTooltip.textContent = tooltipText; // Position tooltip above cursor, offset to the right const tooltipX = mouseX + 10; const tooltipY = 5; this.dom.waveformTooltip.style.left = `${tooltipX}px`; this.dom.waveformTooltip.style.top = `${tooltipY}px`; } // Helper timeToBeats(seconds) { return seconds * this.state.beatsPerSecond; } }