summaryrefslogtreecommitdiff
path: root/tools/timeline_editor/timeline-viewport.js
diff options
context:
space:
mode:
authorskal <pascal.massimino@gmail.com>2026-02-15 13:04:49 +0100
committerskal <pascal.massimino@gmail.com>2026-02-15 13:04:49 +0100
commitf7227fb28aabd1899832cd769fe72692ea4890e6 (patch)
treef78e58e60c2a3ee2dd3747f38e48a0cd4109eba2 /tools/timeline_editor/timeline-viewport.js
parentd040d7e04622d89e9110f699e87f3bc1d7bc385d (diff)
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 <noreply@anthropic.com>
Diffstat (limited to 'tools/timeline_editor/timeline-viewport.js')
-rw-r--r--tools/timeline_editor/timeline-viewport.js133
1 files changed, 133 insertions, 0 deletions
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;
+ }
+}