--- phase: 04-timeline-editor plan: 02 subsystem: ui tags: [canvas, timeline, drag-drop, undo-redo, command-pattern, fastapi, crud] dependency_graph: requires: - phase: 04-timeline-editor/04-01 provides: TimelineCanvas class with coordinate helpers, tracks data structure, render loop - phase: 03-communication-protocol provides: Show/Cue/Track models, show_store persistence provides: - CommandHistory with 50-step undo/redo stack - PlaceBlockCommand, MoveBlockCommand, ResizeBlockCommand, DeleteBlockCommand - Block CRUD REST endpoints (POST/PATCH/DELETE /api/shows/.../blocks) - Full block interaction: drag-from-palette, move, resize, delete - Overlap prevention on same track - Snap-to-beat on mouseup (100ms threshold) affects: - 04-03 (block inspector wiring will use selectedBlock and _onSelectBlock) - 04-04 (playback engine reads cues from tracks which are now populated) tech-stack: added: [HTML5 drag-and-drop API, crypto.randomUUID(), command pattern] patterns: - Command pattern with execute/undo/redo for all timeline mutations - Fire-and-forget fetch for backend sync (no await, no blocking UI) - Live drag preview with revert-then-command on mouseup - Document-level mousemove/mouseup listeners during drag (not canvas-level) key-files: created: - lightsync/frontend/timeline/history.js - lightsync/frontend/timeline/commands.js - lightsync/api/timeline.py modified: - lightsync/frontend/timeline/timeline.js - lightsync/frontend/app.js - lightsync/main.py key-decisions: - "_startMove revert-then-command pattern: live drag updates block.timestamp directly for visual feedback, mouseup reverts to oldTimestamp then executes MoveBlockCommand — ensures history always has the correct before/after state" - "Circular import with main.py is expected: timeline.py imports lightsync.main as state at module level, same as shows.py — works at runtime, breaks only on isolated python -c import (non-issue)" - "Resize handles drawn as 8px highlighted zones on selected block for discoverability" - "Ghost preview during dragover uses trackIdx + time from _dragState rather than raw pixel coordinates — decoupled from render" patterns-established: - "Timeline command: stores references to tracks array + block object, mutates block in-place, syncs to backend fire-and-forget" - "setCurrentShowId() module-level function for routing backend sync without coupling commands to app state" requirements-completed: [TL-02, TL-03, TL-06] duration: 9min completed: 2026-04-06 --- # Phase 04 Plan 02: Block Interactions + Command Pattern Summary **Command-pattern block editing with drag-from-palette, move/resize/delete interactions, undo/redo (50 steps), overlap prevention, snap-to-beat, and backend CRUD endpoints.** ## Performance - **Duration:** ~9 min - **Started:** 2026-04-06T23:35:38Z - **Completed:** 2026-04-06T23:44:50Z - **Tasks:** 2 - **Files modified:** 5 (3 created, 3 modified) ## Accomplishments - `CommandHistory` class: 50-step max, execute/undo/redo, redo cleared on new action (branching protection) - 4 command classes: `PlaceBlockCommand`, `MoveBlockCommand`, `ResizeBlockCommand`, `DeleteBlockCommand` — each with backend sync via fire-and-forget fetch - REST API: POST/PATCH/DELETE `/api/shows/{show_id}/tracks/{device_id}/blocks` with auto-create track if missing - Full mouse interaction: hitTest with 8px resize zones, live drag preview, overlap rejection, snap-to-beat on release - Keyboard: Delete key removes selected block, Ctrl+Z undo, Ctrl+Shift+Z / Ctrl+Y redo - Drag-and-drop from animation palette to canvas with ghost block preview ## Task Commits 1. **Task 1: Command pattern + history + backend block CRUD** - `658c92f` (feat) 2. **Task 2: Wire mouse interactions into TimelineCanvas** - `7e18ac6` (feat) ## Files Created/Modified - `lightsync/frontend/timeline/history.js` — CommandHistory class (execute, undo, redo, canUndo, canRedo, maxSteps=50) - `lightsync/frontend/timeline/commands.js` — PlaceBlockCommand, MoveBlockCommand, ResizeBlockCommand, DeleteBlockCommand + setCurrentShowId() + syncBlock() - `lightsync/api/timeline.py` — Block CRUD FastAPI router (POST/PATCH/DELETE) - `lightsync/frontend/timeline/timeline.js` — hitTest, _hasOverlap, snapToBeat, _setupInteractions, _startMove, _startResize, handleDrop added; CommandHistory + 4 command imports wired - `lightsync/frontend/app.js` — dragstart/dragover/dragleave/drop listeners for palette-to-canvas drag - `lightsync/main.py` — timeline router registered with prefix="/api" ## Decisions Made | Decision | Rationale | |----------|-----------| | Revert-then-command pattern in _startMove | Live drag updates block.timestamp for visual feedback; mouseup reverts to oldTimestamp, then MoveBlockCommand.execute() sets final value — history always has correct before/after state | | Document-level mousemove/mouseup during drag | Canvas-only listeners lose events when mouse moves fast outside canvas bounds | | Ghost preview via _dragState.trackIdx + time | Decoupled from raw pixels — render() computes screen position, maintaining coordinate abstraction | | setCurrentShowId() module-level function | Commands need showId for backend sync but shouldn't be coupled to app state — module-level var injected by app.js | ## Deviations from Plan ### Auto-fixed Issues **1. [Rule 1 - Bug] Plan's _startMove had duplicated/contradictory draft code** - **Found during:** Task 2 implementation - **Issue:** The plan's `_startMove` action block contained two conflicting implementations (onDrag/onUp followed by onDrag2/onUp2) with a comment "IMPORTANT: The executor MUST implement it cleanly as a single version" - **Fix:** Implemented clean single version following the stated logic: record old, live-update during drag, revert + command on mouseup - **Files modified:** `lightsync/frontend/timeline/timeline.js` - **Committed in:** 7e18ac6 --- **Total deviations:** 1 auto-fixed (Rule 1 - Bug) **Impact on plan:** Fix was necessary — the draft code in the plan would not compile/run correctly as-is. Clean implementation follows exact logic specified in the IMPORTANT note. ## Issues Encountered - Circular import warning during `python -c "from lightsync.api.timeline import router"` — same pattern as existing `shows.py`. Not a bug: `import lightsync.main as state` at module level causes partially-initialized module error on isolated import, but works correctly at runtime when app bootstraps normally. Docker build and startup confirmed clean. ## Known Stubs None — block placement, move, resize, delete are all fully wired. Backend CRUD endpoints accept and persist mutations. Undo/redo history tracks all operations. ## Self-Check - `lightsync/frontend/timeline/history.js` exists: YES (658c92f) - `lightsync/frontend/timeline/commands.js` exports 4 command classes: YES (658c92f) - `lightsync/api/timeline.py` has POST/PATCH/DELETE routes: YES (658c92f) - `lightsync/main.py` includes timeline router: YES (658c92f) - `lightsync/frontend/timeline/timeline.js` has hitTest, _startMove, _startResize, handleDrop: YES (7e18ac6) - `lightsync/frontend/app.js` has dragstart/dragover/drop: YES (7e18ac6) - Docker build: SUCCESS (fa66387) - App startup: CLEAN (no errors, uvicorn running) ## Self-Check: PASSED ## Next Phase Readiness - Block placement and editing is fully functional — timeline is now interactive - Plan 04-03 (block inspector panel) can wire into `timeline._onSelectBlock` callback and `timeline.selectedBlock` - Plan 04-04 (playback/live engine) reads cues from `timeline.tracks[*].cues` which are now populated by user interactions - No blockers --- *Phase: 04-timeline-editor* *Completed: 2026-04-06*