summaryrefslogtreecommitdiff
path: root/tools/timeline_editor/timeline-viewport.js
diff options
context:
space:
mode:
Diffstat (limited to 'tools/timeline_editor/timeline-viewport.js')
-rw-r--r--tools/timeline_editor/timeline-viewport.js170
1 files changed, 170 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..dcedb45
--- /dev/null
+++ b/tools/timeline_editor/timeline-viewport.js
@@ -0,0 +1,170 @@
+// 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;
+ }
+}