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('');
|
`).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();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
Reference in New Issue
Block a user