From 7e18ac6c84947306e5155c913ecb9dee02cc3992 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 6 Apr 2026 23:44:45 +0000 Subject: [PATCH] feat(04-02): wire block interactions into TimelineCanvas - Import CommandHistory and all 4 command classes from new modules - hitTest() with 8px EDGE for resize-left/resize-right/move zones - _hasOverlap() check for time range collision on same track - snapToBeat() with 0.1s threshold and calibrationOffset - _setupInteractions() with mousedown/mousemove/keydown listeners - _startMove(): live drag preview, snap on mouseup, overlap rejection - _startResize(): left/right edge handles, 0.5s min duration, snap - handleDrop(): place block from palette with crypto.randomUUID() - Keyboard: Delete, Ctrl+Z (undo), Ctrl+Shift+Z / Ctrl+Y (redo) - app.js: dragstart on palette, dragover/dragleave/drop on canvas - Ghost block rendered during dragover for visual feedback - Resize handle indicators drawn on selected block --- lightsync/frontend/app.js | 42 ++++ lightsync/frontend/timeline/timeline.js | 293 +++++++++++++++++++++++- 2 files changed, 325 insertions(+), 10 deletions(-) diff --git a/lightsync/frontend/app.js b/lightsync/frontend/app.js index 60e2932..494e5da 100644 --- a/lightsync/frontend/app.js +++ b/lightsync/frontend/app.js @@ -304,6 +304,48 @@ function initTimeline() { `).join(''); } + // Drag from palette to canvas + const canvasEl = document.getElementById('timeline-canvas'); + + palette?.addEventListener('dragstart', (e) => { + const tile = e.target.closest('.anim-tile'); + if (!tile) return; + e.dataTransfer.setData('text/plain', tile.dataset.anim); + e.dataTransfer.effectAllowed = 'copy'; + }); + + canvasEl?.addEventListener('dragover', (e) => { + e.preventDefault(); + e.dataTransfer.dropEffect = 'copy'; + // Show ghost preview for current drag position + const rect = canvasEl.getBoundingClientRect(); + const mx = e.clientX - rect.left; + const my = e.clientY - rect.top; + const trackIdx = timeline.yToTrackIndex(my); + const time = timeline.xToTime(mx); + timeline._dragState = { + ghost: true, + trackIdx: trackIdx, + time: Math.max(0, time), + duration: 4.0 + }; + }); + + canvasEl?.addEventListener('dragleave', () => { + timeline._dragState = null; + }); + + canvasEl?.addEventListener('drop', (e) => { + e.preventDefault(); + const animType = e.dataTransfer.getData('text/plain'); + if (!animType) return; + const rect = canvasEl.getBoundingClientRect(); + const mx = e.clientX - rect.left; + const my = e.clientY - rect.top; + timeline.handleDrop(animType, mx, my); + timeline._dragState = null; + }); + timeline.startRenderLoop(); } diff --git a/lightsync/frontend/timeline/timeline.js b/lightsync/frontend/timeline/timeline.js index a775dcf..0e73a87 100644 --- a/lightsync/frontend/timeline/timeline.js +++ b/lightsync/frontend/timeline/timeline.js @@ -2,10 +2,16 @@ // Renders per-device tracks, waveform background, beat marks, cue blocks, // and a realtime playback cursor synced to an HTML5 audio element. +import { CommandHistory } from './history.js'; +import { PlaceBlockCommand, MoveBlockCommand, ResizeBlockCommand, DeleteBlockCommand, setCurrentShowId } from './commands.js'; + const PIXELS_PER_SECOND = 100; // default, adjustable via zoom const TRACK_HEIGHT = 48; const HEADER_WIDTH = 140; const TIME_AXIS_HEIGHT = 24; +const EDGE = 8; // resize handle hit zone in pixels +const MIN_DURATION = 0.5; // minimum block duration in seconds +const SNAP_THRESHOLD = 0.1; // 100ms snap radius class TimelineCanvas { constructor(canvasEl, audioEl) { @@ -23,7 +29,9 @@ class TimelineCanvas { this.snapEnabled = true; this.selectedBlock = null; this._dragState = null; - this._onSelectBlock = null; // callback(cue, trackIndex) for inspector + this._onSelectBlock = null; // callback(cue) for inspector + this.history = new CommandHistory(50); + this.showId = null; // set when a show is loaded this._resizeCanvas(); window.addEventListener('resize', () => this._resizeCanvas()); @@ -33,6 +41,8 @@ class TimelineCanvas { e.preventDefault(); this.scrollX = Math.max(0, this.scrollX + e.deltaX + e.deltaY); }, { passive: false }); + + this._setupInteractions(); } // ── Coordinate helpers ──────────────────────────────────────────────────── @@ -157,17 +167,24 @@ class TimelineCanvas { ctx.lineWidth = isSelected ? 1.5 : 1; ctx.strokeRect(x, y + 1, w, TRACK_HEIGHT - 2); + // Resize handle indicators (visible on selected block) + if (isSelected) { + ctx.fillStyle = '#00ffff'; + ctx.fillRect(x, y + 1, EDGE, TRACK_HEIGHT - 2); + ctx.fillRect(x + w - EDGE, y + 1, EDGE, TRACK_HEIGHT - 2); + } + // Label if (w > 20) { ctx.save(); ctx.beginPath(); - ctx.rect(x + 2, y + 1, w - 4, TRACK_HEIGHT - 2); + ctx.rect(x + EDGE + 2, y + 1, w - EDGE * 2 - 4, TRACK_HEIGHT - 2); ctx.clip(); - ctx.fillStyle = '#00ffff'; + ctx.fillStyle = isSelected ? '#00ffff' : '#00cccc'; ctx.font = '10px monospace'; ctx.textAlign = 'left'; const label = (cue.animation || 'CUE').toUpperCase(); - ctx.fillText(label, x + 6, y + TRACK_HEIGHT / 2 + 4); + ctx.fillText(label, x + EDGE + 4, y + TRACK_HEIGHT / 2 + 4); ctx.restore(); } } @@ -175,12 +192,17 @@ class TimelineCanvas { // 6. Drag ghost if (this._dragState && this._dragState.ghost) { - const g = this._dragState.ghost; - ctx.fillStyle = 'rgba(0, 255, 255, 0.15)'; - ctx.fillRect(g.x, g.y, g.w, g.h); - ctx.strokeStyle = '#00ffff'; - ctx.lineWidth = 1; - ctx.strokeRect(g.x, g.y, g.w, g.h); + const trackIdx = this._dragState.trackIdx; + if (trackIdx >= 0 && trackIdx < this.tracks.length) { + const y = this.trackIndexToY(trackIdx); + const x = this.timeToX(this._dragState.time); + const w = (this._dragState.duration || 4.0) * this.pixelsPerSecond; + ctx.fillStyle = 'rgba(0, 255, 255, 0.15)'; + ctx.fillRect(x, y + 1, w, TRACK_HEIGHT - 2); + ctx.strokeStyle = '#00ffff'; + ctx.lineWidth = 1; + ctx.strokeRect(x, y + 1, w, TRACK_HEIGHT - 2); + } } // 7. Track headers (drawn over track rows) @@ -292,6 +314,257 @@ class TimelineCanvas { ctx.stroke(); } + // ── Hit testing ─────────────────────────────────────────────────────────── + + hitTest(mouseX, mouseY) { + for (const track of this.tracks) { + const trackIdx = this.tracks.indexOf(track); + const y = this.trackIndexToY(trackIdx); + if (mouseY < y || mouseY > y + TRACK_HEIGHT) continue; + // Iterate cues in reverse (last placed = on top) + for (let i = track.cues.length - 1; i >= 0; i--) { + const cue = track.cues[i]; + const x = this.timeToX(cue.timestamp); + const w = (cue.duration || 4.0) * this.pixelsPerSecond; + if (mouseX < x || mouseX > x + w) continue; + if (mouseX < x + EDGE) return { block: cue, handle: 'resize-left', trackIdx }; + if (mouseX > x + w - EDGE) return { block: cue, handle: 'resize-right', trackIdx }; + return { block: cue, handle: 'move', trackIdx }; + } + } + return null; + } + + // ── Overlap check ───────────────────────────────────────────────────────── + + _hasOverlap(track, block, startTime, duration) { + const end = startTime + duration; + return track.cues.some(c => { + if (c.id === block.id) return false; // skip self + const cEnd = c.timestamp + c.duration; + return startTime < cEnd && end > c.timestamp; + }); + } + + // ── Snap to beat ────────────────────────────────────────────────────────── + + snapToBeat(t) { + if (!this.snapEnabled || this.beatTimes.length === 0) return t; + let nearest = null, minDist = Infinity; + for (const beat of this.beatTimes) { + const adjusted = beat - this.calibrationOffset; + const d = Math.abs(t - adjusted); + if (d < minDist) { minDist = d; nearest = adjusted; } + } + return (nearest !== null && minDist < SNAP_THRESHOLD) ? nearest : t; + } + + // ── Interaction setup ───────────────────────────────────────────────────── + + _setupInteractions() { + const canvas = this.canvas; + + canvas.addEventListener('mousedown', (e) => { + const rect = canvas.getBoundingClientRect(); + const mx = e.clientX - rect.left; + const my = e.clientY - rect.top; + + // Ignore clicks in header or time axis area + if (mx < HEADER_WIDTH || my < TIME_AXIS_HEIGHT) return; + + const hit = this.hitTest(mx, my); + + if (!hit) { + // Clicked on empty canvas — deselect + this.selectedBlock = null; + if (this._onSelectBlock) this._onSelectBlock(null); + return; + } + + // Select the clicked block + this.selectedBlock = hit.block; + if (this._onSelectBlock) this._onSelectBlock(hit.block); + + if (hit.handle === 'move') { + this._startMove(e, hit.block, mx); + } else if (hit.handle === 'resize-left' || hit.handle === 'resize-right') { + this._startResize(e, hit.block, hit.handle, mx); + } + }); + + // Update cursor on hover + canvas.addEventListener('mousemove', (e) => { + if (this._dragState) return; // during drag, document handles mousemove + const rect = canvas.getBoundingClientRect(); + const mx = e.clientX - rect.left; + const my = e.clientY - rect.top; + const hit = this.hitTest(mx, my); + if (hit && (hit.handle === 'resize-left' || hit.handle === 'resize-right')) { + canvas.style.cursor = 'ew-resize'; + } else if (hit && hit.handle === 'move') { + canvas.style.cursor = 'grab'; + } else { + canvas.style.cursor = 'default'; + } + }); + + // Keyboard: Delete, Ctrl+Z (undo), Ctrl+Shift+Z / Ctrl+Y (redo) + document.addEventListener('keydown', (e) => { + if (e.key === 'Delete' && this.selectedBlock) { + const cmd = new DeleteBlockCommand(this.tracks, this.selectedBlock); + this.history.execute(cmd); + this.selectedBlock = null; + if (this._onSelectBlock) this._onSelectBlock(null); + } + // Undo: Ctrl+Z (not Ctrl+Shift+Z) + if (e.ctrlKey && !e.shiftKey && e.key === 'z') { + e.preventDefault(); + this.history.undo(); + } + // Redo: Ctrl+Shift+Z or Ctrl+Y + if ((e.ctrlKey && e.shiftKey && e.key === 'Z') || (e.ctrlKey && e.key === 'y')) { + e.preventDefault(); + this.history.redo(); + } + }); + } + + // ── Move interaction ────────────────────────────────────────────────────── + + _startMove(e, block, startMX) { + const oldTimestamp = block.timestamp; + const startTime = block.timestamp; + this.canvas.style.cursor = 'grabbing'; + this._dragState = { type: 'move' }; + + const onDrag = (ev) => { + const rect = this.canvas.getBoundingClientRect(); + const mx = ev.clientX - rect.left; + const deltaX = mx - startMX; + const deltaTime = deltaX / this.pixelsPerSecond; + block.timestamp = Math.max(0, startTime + deltaTime); + }; + + const onUp = () => { + document.removeEventListener('mousemove', onDrag); + document.removeEventListener('mouseup', onUp); + this.canvas.style.cursor = 'default'; + this._dragState = null; + + const snapped = this.snapToBeat(Math.max(0, block.timestamp)); + const track = this.tracks.find(t => t.device_id === block.device_id); + + // Revert live position for command to apply cleanly + block.timestamp = oldTimestamp; + + if (snapped === oldTimestamp) return; + + // Check overlap at final position + if (track && this._hasOverlap(track, block, snapped, block.duration)) return; + + const cmd = new MoveBlockCommand(this.tracks, block, oldTimestamp, snapped); + this.history.execute(cmd); + }; + + document.addEventListener('mousemove', onDrag); + document.addEventListener('mouseup', onUp, { once: true }); + } + + // ── Resize interaction ──────────────────────────────────────────────────── + + _startResize(e, block, handle, startMX) { + const oldTimestamp = block.timestamp; + const oldDuration = block.duration; + const rightEdgeTime = oldTimestamp + oldDuration; // fixed right edge for left-handle resize + const blockStartX = this.timeToX(oldTimestamp); + this._dragState = { type: 'resize' }; + + const onDrag = (ev) => { + const rect = this.canvas.getBoundingClientRect(); + const mx = ev.clientX - rect.left; + + if (handle === 'resize-right') { + // Right edge moves, left stays fixed + const newRightTime = Math.max(0, this.xToTime(mx)); + block.duration = Math.max(MIN_DURATION, newRightTime - oldTimestamp); + } else { + // Left edge moves, right edge stays fixed + const newLeftTime = Math.max(0, this.xToTime(mx)); + const newDuration = rightEdgeTime - newLeftTime; + if (newDuration >= MIN_DURATION) { + block.timestamp = newLeftTime; + block.duration = newDuration; + } + } + }; + + const onUp = () => { + document.removeEventListener('mousemove', onDrag); + document.removeEventListener('mouseup', onUp); + this.canvas.style.cursor = 'default'; + this._dragState = null; + + let newTimestamp = block.timestamp; + let newDuration = block.duration; + + // Snap left edge if resize-left + if (handle === 'resize-left') { + newTimestamp = this.snapToBeat(Math.max(0, newTimestamp)); + newDuration = rightEdgeTime - newTimestamp; + } + // Enforce minimum + if (newDuration < MIN_DURATION) { + newTimestamp = oldTimestamp; + newDuration = oldDuration; + } + + // Revert for command to apply cleanly + block.timestamp = oldTimestamp; + block.duration = oldDuration; + + const unchanged = newTimestamp === oldTimestamp && newDuration === oldDuration; + if (unchanged) return; + + const track = this.tracks.find(t => t.device_id === block.device_id); + if (track && this._hasOverlap(track, block, newTimestamp, newDuration)) return; + + const cmd = new ResizeBlockCommand( + this.tracks, block, oldTimestamp, oldDuration, newTimestamp, newDuration + ); + this.history.execute(cmd); + }; + + document.addEventListener('mousemove', onDrag); + document.addEventListener('mouseup', onUp, { once: true }); + } + + // ── Drop from palette ───────────────────────────────────────────────────── + + handleDrop(animationType, mouseX, mouseY) { + const trackIdx = this.yToTrackIndex(mouseY); + if (trackIdx < 0 || trackIdx >= this.tracks.length) return; + const track = this.tracks[trackIdx]; + const rawTime = Math.max(0, this.xToTime(mouseX)); + const time = this.snapToBeat(rawTime); + const duration = 4.0; // default duration + + // Reject if overlap + if (this._hasOverlap(track, { id: null }, time, duration)) return; + + const block = { + id: crypto.randomUUID(), + device_id: track.device_id, + timestamp: time, + duration: duration, + animation: animationType, + params: { color: '#00ffff', speed: 1.0, direction: 'forward', length: 0.3 } + }; + const cmd = new PlaceBlockCommand(this.tracks, block); + this.history.execute(cmd); + this.selectedBlock = block; + if (this._onSelectBlock) this._onSelectBlock(block); + } + // ── Data loading ────────────────────────────────────────────────────────── loadTracks(devices) {