738 lines
30 KiB
Markdown
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>
|