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
This commit is contained in:
Claude
2026-04-06 23:44:45 +00:00
parent 658c92f94d
commit 7e18ac6c84
2 changed files with 325 additions and 10 deletions

View File

@@ -304,6 +304,48 @@ function initTimeline() {
`).join(''); `).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(); timeline.startRenderLoop();
} }

View File

@@ -2,10 +2,16 @@
// Renders per-device tracks, waveform background, beat marks, cue blocks, // Renders per-device tracks, waveform background, beat marks, cue blocks,
// and a realtime playback cursor synced to an HTML5 audio element. // 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 PIXELS_PER_SECOND = 100; // default, adjustable via zoom
const TRACK_HEIGHT = 48; const TRACK_HEIGHT = 48;
const HEADER_WIDTH = 140; const HEADER_WIDTH = 140;
const TIME_AXIS_HEIGHT = 24; 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 { class TimelineCanvas {
constructor(canvasEl, audioEl) { constructor(canvasEl, audioEl) {
@@ -23,7 +29,9 @@ class TimelineCanvas {
this.snapEnabled = true; this.snapEnabled = true;
this.selectedBlock = null; this.selectedBlock = null;
this._dragState = 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(); this._resizeCanvas();
window.addEventListener('resize', () => this._resizeCanvas()); window.addEventListener('resize', () => this._resizeCanvas());
@@ -33,6 +41,8 @@ class TimelineCanvas {
e.preventDefault(); e.preventDefault();
this.scrollX = Math.max(0, this.scrollX + e.deltaX + e.deltaY); this.scrollX = Math.max(0, this.scrollX + e.deltaX + e.deltaY);
}, { passive: false }); }, { passive: false });
this._setupInteractions();
} }
// ── Coordinate helpers ──────────────────────────────────────────────────── // ── Coordinate helpers ────────────────────────────────────────────────────
@@ -157,17 +167,24 @@ class TimelineCanvas {
ctx.lineWidth = isSelected ? 1.5 : 1; ctx.lineWidth = isSelected ? 1.5 : 1;
ctx.strokeRect(x, y + 1, w, TRACK_HEIGHT - 2); 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 // Label
if (w > 20) { if (w > 20) {
ctx.save(); ctx.save();
ctx.beginPath(); 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.clip();
ctx.fillStyle = '#00ffff'; ctx.fillStyle = isSelected ? '#00ffff' : '#00cccc';
ctx.font = '10px monospace'; ctx.font = '10px monospace';
ctx.textAlign = 'left'; ctx.textAlign = 'left';
const label = (cue.animation || 'CUE').toUpperCase(); 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(); ctx.restore();
} }
} }
@@ -175,12 +192,17 @@ class TimelineCanvas {
// 6. Drag ghost // 6. Drag ghost
if (this._dragState && this._dragState.ghost) { if (this._dragState && this._dragState.ghost) {
const g = this._dragState.ghost; const trackIdx = this._dragState.trackIdx;
ctx.fillStyle = 'rgba(0, 255, 255, 0.15)'; if (trackIdx >= 0 && trackIdx < this.tracks.length) {
ctx.fillRect(g.x, g.y, g.w, g.h); const y = this.trackIndexToY(trackIdx);
ctx.strokeStyle = '#00ffff'; const x = this.timeToX(this._dragState.time);
ctx.lineWidth = 1; const w = (this._dragState.duration || 4.0) * this.pixelsPerSecond;
ctx.strokeRect(g.x, g.y, g.w, g.h); 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) // 7. Track headers (drawn over track rows)
@@ -292,6 +314,257 @@ class TimelineCanvas {
ctx.stroke(); 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 ────────────────────────────────────────────────────────── // ── Data loading ──────────────────────────────────────────────────────────
loadTracks(devices) { loadTracks(devices) {