From f7227fb28aabd1899832cd769fe72692ea4890e6 Mon Sep 17 00:00:00 2001 From: skal Date: Sun, 15 Feb 2026 13:04:49 +0100 Subject: refactor(timeline-editor): extract viewport and playback to ES6 modules Extract zoom/scroll/playback code from monolithic index.html into separate modules for better code organization: - timeline-viewport.js: Zoom, scroll sync, indicator positioning (133 lines) - timeline-playback.js: Audio loading, playback, waveform rendering (303 lines) - index.html: Reduced from 1093 to 853 lines (-22%) Requires HTTP server for ES6 module imports. Updated README with usage. Co-Authored-By: Claude Sonnet 4.5 --- tools/timeline_editor/timeline-viewport.js | 133 +++++++++++++++++++++++++++++ 1 file changed, 133 insertions(+) create mode 100644 tools/timeline_editor/timeline-viewport.js (limited to 'tools/timeline_editor/timeline-viewport.js') diff --git a/tools/timeline_editor/timeline-viewport.js b/tools/timeline_editor/timeline-viewport.js new file mode 100644 index 0000000..becf8e9 --- /dev/null +++ b/tools/timeline_editor/timeline-viewport.js @@ -0,0 +1,133 @@ +// 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 + const wheelHandler = e => this.handleWheel(e); + this.dom.timelineContent.addEventListener('wheel', wheelHandler, { passive: false }); + this.dom.waveformContainer.addEventListener('wheel', wheelHandler, { passive: false }); + + // Prevent wheel bubbling from UI containers + 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()); + } + + handleZoomSlider(e) { + this.state.pixelsPerSecond = parseInt(e.target.value); + this.dom.zoomLevel.textContent = `${this.state.pixelsPerSecond}%`; + 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.pixelsPerSecond; + + const zoomDelta = e.deltaY > 0 ? -10 : 10; + const newPixelsPerSecond = Math.max(10, Math.min(500, this.state.pixelsPerSecond + zoomDelta)); + + if (newPixelsPerSecond !== this.state.pixelsPerSecond) { + this.state.pixelsPerSecond = newPixelsPerSecond; + this.dom.zoomSlider.value = this.state.pixelsPerSecond; + this.dom.zoomLevel.textContent = `${this.state.pixelsPerSecond}%`; + this.renderCallback('zoomWheel'); + this.dom.timelineContent.scrollLeft = timeUnderCursor * newPixelsPerSecond - 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.pixelsPerSecond) * 0.1; + const currentTime = (currentScrollLeft / this.state.pixelsPerSecond) + 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.pixelsPerSecond; + 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; + } + } + } + + // Helper + timeToBeats(seconds) { + return seconds * this.state.bpm / 60.0; + } +} -- cgit v1.2.3