Files
2026-04-06 23:24:11 +00:00

738 lines
30 KiB
Markdown

---
phase: 04-timeline-editor
plan: 02
type: execute
wave: 2
depends_on: [04-01]
files_modified:
- lightsync/frontend/timeline/commands.js
- lightsync/frontend/timeline/history.js
- lightsync/frontend/timeline/timeline.js
- lightsync/frontend/app.js
- lightsync/api/timeline.py
- lightsync/main.py
autonomous: true
requirements: [TL-02, TL-03, TL-06]
must_haves:
truths:
- "Animation blocks can be dragged from the sidebar palette and placed on a track"
- "Blocks can be moved by dragging the center region"
- "Blocks can be resized by dragging left or right edge handles (8px hit zone)"
- "Blocks can be deleted with the Delete key"
- "Undo (Ctrl+Z) and Redo (Ctrl+Shift+Z) work for all mutations with at least 50 steps"
- "Block overlap on the same track is rejected"
artifacts:
- path: "lightsync/frontend/timeline/commands.js"
provides: "Command classes: PlaceBlockCommand, MoveBlockCommand, ResizeBlockCommand, DeleteBlockCommand"
exports: ["PlaceBlockCommand", "MoveBlockCommand", "ResizeBlockCommand", "DeleteBlockCommand"]
min_lines: 80
- path: "lightsync/frontend/timeline/history.js"
provides: "CommandHistory with undoStack, redoStack, execute(), undo(), redo()"
exports: ["CommandHistory"]
min_lines: 30
- path: "lightsync/api/timeline.py"
provides: "Block CRUD REST endpoints"
contains: "router = APIRouter"
key_links:
- from: "lightsync/frontend/timeline/commands.js"
to: "timeline.js tracks state"
via: "Commands mutate track.cues arrays directly"
pattern: "track\\.cues"
- from: "lightsync/frontend/timeline/timeline.js"
to: "lightsync/frontend/timeline/history.js"
via: "timeline calls history.execute(cmd) for all mutations"
pattern: "history\\.execute"
- from: "lightsync/frontend/timeline/commands.js"
to: "/api/shows/{show_id}/tracks/{device_id}/blocks"
via: "async fetch calls on execute/undo for backend sync"
pattern: "api/shows"
- from: "lightsync/api/timeline.py"
to: "lightsync/models/show.py"
via: "CueModel creation and mutation"
pattern: "CueModel"
---
<objective>
Add block interaction capabilities — drag-and-place from palette, move, resize, delete — all wrapped in a command pattern with undo/redo support (min 50 steps). Includes backend block CRUD endpoints.
Purpose: This is the core editing capability. Without it, the timeline is a static display. The command pattern ensures all mutations are reversible.
Output: Fully interactive block manipulation with undo/redo, backed by REST API persistence.
</objective>
<execution_context>
@$HOME/.claude/get-shit-done/workflows/execute-plan.md
@$HOME/.claude/get-shit-done/templates/summary.md
</execution_context>
<context>
@.planning/PROJECT.md
@.planning/ROADMAP.md
@.planning/phases/04-timeline-editor/04-CONTEXT.md
@.planning/phases/04-timeline-editor/04-RESEARCH.md
@.planning/phases/04-timeline-editor/04-UI-SPEC.md
@.planning/phases/04-timeline-editor/04-01-SUMMARY.md
@lightsync/models/show.py
@lightsync/frontend/timeline/timeline.js
@lightsync/frontend/app.js
@lightsync/api/shows.py
@lightsync/main.py
<interfaces>
<!-- From timeline.js (Plan 01 output) -->
export class TimelineCanvas {
constructor(canvasEl, audioEl)
timeToX(t), xToTime(x), trackIndexToY(i), yToTrackIndex(y)
render()
startRenderLoop()
loadTracks(devices)
tracks: [{device_id, device_name, strip_type, cues: []}]
selectedBlock: CueModel | null
_dragState: object | null
_onSelectBlock: function | null
snapEnabled: boolean
beatTimes: float[]
calibrationOffset: float
pixelsPerSecond: number
scrollX: number
}
<!-- From show.py -->
class CueModel(BaseModel):
id: UUID
timestamp: float
duration: float = 4.0
mode: Literal["animation", "frame_sequence"] = "animation"
animation: str | None = None
params: dict[str, Any] = {}
class ShowModel(BaseModel):
tracks: list[TrackModel] # TrackModel has device_id: UUID, cues: list[CueModel]
<!-- From shows.py -->
router = APIRouter(tags=["shows"])
# Endpoints: GET/POST /api/shows, GET /api/shows/{show_id}
# state.show_store.save(show), state.show_store.load(show_id)
<!-- From main.py -->
# app.include_router(shows.router, prefix="/api/shows")
# Module-level: show_store, registry
</interfaces>
</context>
<tasks>
<task type="auto">
<name>Task 1: Command pattern + history + backend block CRUD</name>
<files>lightsync/frontend/timeline/commands.js, lightsync/frontend/timeline/history.js, lightsync/api/timeline.py, lightsync/main.py</files>
<read_first>lightsync/frontend/timeline/timeline.js, lightsync/models/show.py, lightsync/api/shows.py, lightsync/main.py, lightsync/frontend/app.js</read_first>
<action>
**1. Create `lightsync/frontend/timeline/history.js` — CommandHistory class:**
```javascript
export class CommandHistory {
constructor(maxSteps = 50) {
this.undoStack = [];
this.redoStack = [];
this.maxSteps = maxSteps;
}
execute(cmd) {
cmd.execute();
this.undoStack.push(cmd);
if (this.undoStack.length > this.maxSteps) this.undoStack.shift();
this.redoStack = []; // clear redo on new action (prevents branching corruption)
}
undo() {
const cmd = this.undoStack.pop();
if (!cmd) return false;
cmd.undo();
this.redoStack.push(cmd);
return true;
}
redo() {
const cmd = this.redoStack.pop();
if (!cmd) return false;
cmd.execute();
this.undoStack.push(cmd);
return true;
}
get canUndo() { return this.undoStack.length > 0; }
get canRedo() { return this.redoStack.length > 0; }
}
```
**2. Create `lightsync/frontend/timeline/commands.js` — Command classes:**
Each command stores the data needed for execute and undo. Commands mutate the `tracks` array (passed by reference from TimelineCanvas). Backend sync is fire-and-forget fetch (no await during rapid undo/redo).
Generate a UUID for new blocks using `crypto.randomUUID()`.
Helper function for backend sync (top of file):
```javascript
function syncBlock(showId, deviceId, block, method) {
if (!showId) return; // no show loaded yet — skip sync
const base = `/api/shows/${showId}/tracks/${deviceId}/blocks`;
const opts = { method, headers: { 'Content-Type': 'application/json' } };
if (method === 'POST' || method === 'PATCH') {
opts.body = JSON.stringify(block);
}
const url = method === 'POST' ? base : `${base}/${block.id}`;
fetch(url, opts).catch(e => console.warn('[sync]', e));
}
```
**PlaceBlockCommand:**
```javascript
export class PlaceBlockCommand {
constructor(tracks, block) {
this.tracks = tracks;
this.block = block; // {id, timestamp, duration, animation, params, device_id}
}
execute() {
const track = this.tracks.find(t => t.device_id === this.block.device_id);
if (track) track.cues.push(this.block);
syncBlock(this._showId, this.block.device_id, this.block, 'POST');
}
undo() {
const track = this.tracks.find(t => t.device_id === this.block.device_id);
if (track) track.cues = track.cues.filter(c => c.id !== this.block.id);
syncBlock(this._showId, this.block.device_id, this.block, 'DELETE');
}
}
```
**MoveBlockCommand:** Stores `oldTimestamp` and `newTimestamp`.
```javascript
export class MoveBlockCommand {
constructor(tracks, block, oldTimestamp, newTimestamp) {
this.tracks = tracks;
this.block = block;
this.oldTimestamp = oldTimestamp;
this.newTimestamp = newTimestamp;
}
execute() {
this.block.timestamp = this.newTimestamp;
syncBlock(this._showId, this.block.device_id, this.block, 'PATCH');
}
undo() {
this.block.timestamp = this.oldTimestamp;
syncBlock(this._showId, this.block.device_id, this.block, 'PATCH');
}
}
```
**ResizeBlockCommand:** Stores old/new values for both `timestamp` and `duration` (left-edge resize changes both, right-edge resize changes only duration).
```javascript
export class ResizeBlockCommand {
constructor(tracks, block, oldTimestamp, oldDuration, newTimestamp, newDuration) {
this.tracks = tracks;
this.block = block;
this.oldTimestamp = oldTimestamp;
this.oldDuration = oldDuration;
this.newTimestamp = newTimestamp;
this.newDuration = newDuration;
}
execute() {
this.block.timestamp = this.newTimestamp;
this.block.duration = this.newDuration;
syncBlock(this._showId, this.block.device_id, this.block, 'PATCH');
}
undo() {
this.block.timestamp = this.oldTimestamp;
this.block.duration = this.oldDuration;
syncBlock(this._showId, this.block.device_id, this.block, 'PATCH');
}
}
```
**DeleteBlockCommand:**
```javascript
export class DeleteBlockCommand {
constructor(tracks, block) {
this.tracks = tracks;
this.block = block;
}
execute() {
const track = this.tracks.find(t => t.device_id === this.block.device_id);
if (track) track.cues = track.cues.filter(c => c.id !== this.block.id);
syncBlock(this._showId, this.block.device_id, this.block, 'DELETE');
}
undo() {
const track = this.tracks.find(t => t.device_id === this.block.device_id);
if (track) track.cues.push(this.block);
syncBlock(this._showId, this.block.device_id, this.block, 'POST');
}
}
```
All command classes must also export a `setShowId(id)` module-level function that sets `_showId` on future commands (or store it as module-level variable used by `syncBlock`). Alternatively, make `_showId` a module-level `let currentShowId = null;` and export `setCurrentShowId(id)`.
**3. Create `lightsync/api/timeline.py` — Block CRUD router:**
```python
from uuid import UUID
from fastapi import APIRouter, HTTPException
from pydantic import BaseModel
from typing import Any, Optional
import lightsync.main as state
router = APIRouter(tags=["timeline"])
class BlockCreate(BaseModel):
id: str # UUID as string from frontend crypto.randomUUID()
timestamp: float
duration: float = 4.0
animation: str | None = None
params: dict[str, Any] = {}
class BlockUpdate(BaseModel):
timestamp: float | None = None
duration: float | None = None
animation: str | None = None
params: dict[str, Any] | None = None
@router.post("/shows/{show_id}/tracks/{device_id}/blocks", status_code=201)
async def create_block(show_id: str, device_id: str, block: BlockCreate):
show = await state.show_store.load(show_id)
if show is None:
raise HTTPException(404, "Show not found")
track = next((t for t in show.tracks if str(t.device_id) == device_id), None)
if track is None:
# Auto-create track for this device
from lightsync.models.show import TrackModel, CueModel
cue = CueModel(
id=UUID(block.id), timestamp=block.timestamp,
duration=block.duration, animation=block.animation,
params=block.params
)
track = TrackModel(device_id=UUID(device_id), cues=[cue])
show.tracks.append(track)
else:
from lightsync.models.show import CueModel
cue = CueModel(
id=UUID(block.id), timestamp=block.timestamp,
duration=block.duration, animation=block.animation,
params=block.params
)
track.cues.append(cue)
await state.show_store.save(show)
return {"status": "ok"}
@router.patch("/shows/{show_id}/tracks/{device_id}/blocks/{block_id}")
async def update_block(show_id: str, device_id: str, block_id: str, update: BlockUpdate):
show = await state.show_store.load(show_id)
if show is None:
raise HTTPException(404, "Show not found")
track = next((t for t in show.tracks if str(t.device_id) == device_id), None)
if track is None:
raise HTTPException(404, "Track not found")
cue = next((c for c in track.cues if str(c.id) == block_id), None)
if cue is None:
raise HTTPException(404, "Block not found")
if update.timestamp is not None:
cue.timestamp = update.timestamp
if update.duration is not None:
cue.duration = update.duration
if update.animation is not None:
cue.animation = update.animation
if update.params is not None:
cue.params = update.params
await state.show_store.save(show)
return {"status": "ok"}
@router.delete("/shows/{show_id}/tracks/{device_id}/blocks/{block_id}")
async def delete_block(show_id: str, device_id: str, block_id: str):
show = await state.show_store.load(show_id)
if show is None:
raise HTTPException(404, "Show not found")
track = next((t for t in show.tracks if str(t.device_id) == device_id), None)
if track is None:
raise HTTPException(404, "Track not found")
track.cues = [c for c in track.cues if str(c.id) != block_id]
await state.show_store.save(show)
return {"status": "ok"}
```
**4. Register router in `lightsync/main.py`:**
Add import: `from lightsync.api import timeline`
Add router: `app.include_router(timeline.router, prefix="/api")`
Place the import alongside existing `from lightsync.api import devices, shows, audio, ws` and the `include_router` alongside existing router registrations.
</action>
<verify>
<automated>cd /home/claude/led2 && test -f lightsync/frontend/timeline/commands.js && test -f lightsync/frontend/timeline/history.js && test -f lightsync/api/timeline.py && grep -q "PlaceBlockCommand" lightsync/frontend/timeline/commands.js && grep -q "MoveBlockCommand" lightsync/frontend/timeline/commands.js && grep -q "ResizeBlockCommand" lightsync/frontend/timeline/commands.js && grep -q "DeleteBlockCommand" lightsync/frontend/timeline/commands.js && grep -q "class CommandHistory" lightsync/frontend/timeline/history.js && grep -q "undoStack" lightsync/frontend/timeline/history.js && grep -q "redoStack" lightsync/frontend/timeline/history.js && grep -q "timeline" lightsync/main.py && python -c "from lightsync.api.timeline import router; print('router OK')" && echo "ALL PASS"</automated>
</verify>
<acceptance_criteria>
- lightsync/frontend/timeline/history.js exports CommandHistory with execute(), undo(), redo(), canUndo, canRedo
- history.js constructor takes maxSteps=50 and enforces it via shift()
- history.js redo clears redoStack on new execute (branching protection)
- lightsync/frontend/timeline/commands.js exports PlaceBlockCommand, MoveBlockCommand, ResizeBlockCommand, DeleteBlockCommand
- Each command has execute() and undo() methods
- commands.js contains syncBlock helper that does fire-and-forget fetch to /api/shows/{showId}/tracks/{deviceId}/blocks
- lightsync/api/timeline.py contains POST /shows/{show_id}/tracks/{device_id}/blocks endpoint
- lightsync/api/timeline.py contains PATCH /shows/{show_id}/tracks/{device_id}/blocks/{block_id} endpoint
- lightsync/api/timeline.py contains DELETE /shows/{show_id}/tracks/{device_id}/blocks/{block_id} endpoint
- lightsync/main.py imports timeline router and includes it with prefix="/api"
- `python -c "from lightsync.api.timeline import router"` succeeds
</acceptance_criteria>
<done>Command pattern (4 command classes) + CommandHistory + backend block CRUD endpoints registered and importable</done>
</task>
<task type="auto">
<name>Task 2: Wire mouse interactions into TimelineCanvas</name>
<files>lightsync/frontend/timeline/timeline.js, lightsync/frontend/app.js</files>
<read_first>lightsync/frontend/timeline/timeline.js, lightsync/frontend/timeline/commands.js, lightsync/frontend/timeline/history.js, lightsync/frontend/app.js, lightsync/frontend/index.html</read_first>
<action>
**1. Add interaction methods to `timeline.js`:**
Import at top of timeline.js:
```javascript
import { CommandHistory } from './history.js';
import { PlaceBlockCommand, MoveBlockCommand, ResizeBlockCommand, DeleteBlockCommand, setCurrentShowId } from './commands.js';
```
Add to constructor:
```javascript
this.history = new CommandHistory(50);
this.showId = null; // set when a show is loaded
```
**Add `hitTest(mouseX, mouseY)` method:**
```javascript
hitTest(mouseX, mouseY) {
const EDGE = 8;
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 * 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;
}
```
**Add overlap check method:**
```javascript
_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;
});
}
```
**Add snap helper:**
```javascript
snapToBeat(t) {
if (!this.snapEnabled || this.beatTimes.length === 0) return t;
const SNAP_THRESHOLD = 0.1; // 100ms
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;
}
```
**Add `_setupInteractions()` method** — call from constructor AFTER _resizeCanvas():
```javascript
_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;
// Check if in header or time axis area — ignore
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';
}
});
// Delete key
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
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();
}
});
}
```
**Add `_startMove(e, block, startMX)` method:**
```javascript
_startMove(e, block, startMX) {
const oldTimestamp = block.timestamp;
const startTime = block.timestamp;
const startScrollX = this.scrollX;
this.canvas.style.cursor = 'grabbing';
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';
// Snap
block.timestamp = this.snapToBeat(block.timestamp);
block.timestamp = Math.max(0, block.timestamp);
// Check overlap
const track = this.tracks.find(t => t.device_id === block.device_id);
if (track && this._hasOverlap(track, block, block.timestamp, block.duration)) {
block.timestamp = oldTimestamp; // revert
return;
}
if (block.timestamp !== oldTimestamp) {
// Reset to old for command to execute properly
block.timestamp = oldTimestamp;
const cmd = new MoveBlockCommand(this.tracks, block, oldTimestamp, this.snapToBeat(Math.max(0, startTime + (block.timestamp - oldTimestamp) || (this.snapToBeat(Math.max(0, startTime + (0))) ))));
// Simpler: just compute final time
const finalTime = this.snapToBeat(Math.max(0, startTime + ((e.clientX - e.clientX) / this.pixelsPerSecond)));
// Actually: just record the snapped value before reverting
}
};
// Simpler approach — store snapped result:
let finalTimestamp = oldTimestamp;
const onDrag2 = (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 onUp2 = () => {
document.removeEventListener('mousemove', onDrag2);
document.removeEventListener('mouseup', onUp2);
this.canvas.style.cursor = 'default';
const snapped = this.snapToBeat(Math.max(0, block.timestamp));
const track = this.tracks.find(t => t.device_id === block.device_id);
if (track && this._hasOverlap(track, block, snapped, block.duration)) {
block.timestamp = oldTimestamp;
return;
}
block.timestamp = oldTimestamp; // revert for command
if (snapped !== oldTimestamp) {
const cmd = new MoveBlockCommand(this.tracks, block, oldTimestamp, snapped);
this.history.execute(cmd);
}
};
document.addEventListener('mousemove', onDrag2);
document.addEventListener('mouseup', onUp2, { once: true });
}
```
IMPORTANT: The above `_startMove` has redundant code from drafting. The executor MUST implement it cleanly as a single version. The logic is:
1. Record `oldTimestamp = block.timestamp`
2. On mousemove: update `block.timestamp` live (visual feedback only)
3. On mouseup: compute `snapped = snapToBeat(max(0, block.timestamp))`, check overlap, revert `block.timestamp = oldTimestamp`, then if snapped !== oldTimestamp execute `MoveBlockCommand(tracks, block, oldTimestamp, snapped)`
**Add `_startResize(e, block, handle, startMX)` method:**
Same pattern. Record `oldTimestamp`, `oldDuration`. On mousemove: if handle is 'resize-right', adjust duration = max(0.5, (mx - blockStartX) / pixelsPerSecond). If handle is 'resize-left', adjust both timestamp and duration so the right edge stays fixed. On mouseup: snap, check overlap, revert, execute `ResizeBlockCommand`. Minimum duration is 0.5 seconds.
**Add `handleDrop(animationType, mouseX, mouseY)` method** — called when an animation palette item is dropped on canvas:
```javascript
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
if (this._hasOverlap(track, {id: null}, time, duration)) return; // reject overlap
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);
}
```
**2. Wire drag-from-palette in app.js:**
In the `initTimeline()` function (after palette creation), add drag handling for the animation palette items:
```javascript
// Drag from palette to canvas
const palette = document.getElementById('animation-palette');
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';
// Draw ghost preview — update drag state for rendering
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;
});
```
Also set `draggable="true"` on palette items (already done in Plan 01 HTML generation).
</action>
<verify>
<automated>cd /home/claude/led2 && grep -q "hitTest" lightsync/frontend/timeline/timeline.js && grep -q "handleDrop" lightsync/frontend/timeline/timeline.js && grep -q "_startMove" lightsync/frontend/timeline/timeline.js && grep -q "_startResize" lightsync/frontend/timeline/timeline.js && grep -q "snapToBeat" lightsync/frontend/timeline/timeline.js && grep -q "_hasOverlap" lightsync/frontend/timeline/timeline.js && grep -q "_setupInteractions" lightsync/frontend/timeline/timeline.js && grep -q "Ctrl.*Z\|ctrlKey" lightsync/frontend/timeline/timeline.js && grep -q "dragstart\|dragover\|drop" lightsync/frontend/app.js && echo "ALL PASS"</automated>
</verify>
<acceptance_criteria>
- timeline.js imports CommandHistory from history.js
- timeline.js imports all 4 command classes from commands.js
- timeline.js constructor creates `this.history = new CommandHistory(50)`
- timeline.js has hitTest() method with 8px EDGE for resize handles
- timeline.js has _hasOverlap() method checking time range collisions
- timeline.js has snapToBeat() with 0.1s threshold and calibrationOffset
- timeline.js has _setupInteractions() with mousedown, mousemove, keydown listeners
- timeline.js has _startMove() attaching mousemove/mouseup to document (not canvas)
- timeline.js has _startResize() with minimum 0.5s duration
- timeline.js has handleDrop() creating new block with crypto.randomUUID()
- Keydown listener handles Delete key, Ctrl+Z (undo), Ctrl+Shift+Z (redo), Ctrl+Y (redo)
- app.js has dragstart listener on palette, dragover/dragleave/drop on canvas
- Ghost block state set during dragover, cleared on dragleave/drop
</acceptance_criteria>
<done>Full block interaction: drag-and-place from palette, move by dragging center, resize by dragging edges, delete with Delete key, undo/redo with Ctrl+Z/Ctrl+Shift+Z. Overlap prevention on same track. All mutations go through CommandHistory.</done>
</task>
</tasks>
<verification>
1. Docker rebuild and restart
2. Add a device via sidebar, load audio
3. Drag animation from ANIMATIONS palette onto a track — block appears
4. Click block center and drag — block moves, snaps to nearest beat on release
5. Drag block right edge — block resizes
6. Press Delete with block selected — block disappears
7. Press Ctrl+Z — deleted block reappears (undo)
8. Press Ctrl+Shift+Z — block disappears again (redo)
9. Attempt to place block overlapping existing — placement rejected
10. Rapidly perform 50+ operations, then undo all — verify all revert correctly
</verification>
<success_criteria>
- Blocks can be placed on tracks via drag-and-drop from palette
- Blocks can be moved and resized with snap-to-beat
- Delete key removes selected block
- Undo/redo works for all operations with at least 50 steps
- Block overlap is prevented
- Backend block CRUD endpoints accept requests
</success_criteria>
<output>
After completion, create `.planning/phases/04-timeline-editor/04-02-SUMMARY.md`
</output>