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:
@@ -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();
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
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(g.x, g.y, g.w, g.h);
|
||||
ctx.fillRect(x, y + 1, w, TRACK_HEIGHT - 2);
|
||||
ctx.strokeStyle = '#00ffff';
|
||||
ctx.lineWidth = 1;
|
||||
ctx.strokeRect(g.x, g.y, g.w, g.h);
|
||||
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) {
|
||||
|
||||
Reference in New Issue
Block a user