From f414d6800e7894468ff4ebcd39cc2ed0195fedaf Mon Sep 17 00:00:00 2001 From: skal Date: Thu, 5 Feb 2026 22:04:05 +0100 Subject: feat(timeline-editor): Add editable names and effect resize handles MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Feature #1: Editable Sequence Names - Added "Name" field to sequence property panel - User can set custom sequence names (replaces generic "Sequence N") - Name stored in seq.name property - Persists in demo.seq file when saved - Displays in large centered overlay on timeline - Empty name falls back to "Sequence N" display Property Panel: [Name ] ← NEW: editable text field [Start Time ] [Priority ] [Apply] Usage: Select sequence → Edit name in property panel → Apply Feature #2: Effect Resize Handles - Added draggable handles at left/right edges of selected effects - Left handle: Adjusts start time (end time fixed) - Right handle: Adjusts end time (start time fixed) - Handles only visible on selected effects - Smooth dragging with snap-to-beat support Visual Design: - 6px wide teal handles (rgba(78, 201, 176, 0.8)) - Cursor: ew-resize (horizontal resize) - Hover: Expands to 8px, full opacity - z-index: 10 (above effect content) - Border radius matches effect corners Handle Dragging Algorithm: 1. Click handle → startHandleDrag(type: 'left'|'right') 2. mousemove → onHandleDrag: - Calculate newTime from mouse position - Apply snap-to-beat if enabled - Convert to sequence-relative time - If left handle: effect.startTime = min(newTime, endTime - 0.1) - If right handle: effect.endTime = max(startTime + 0.1, newTime) 3. mouseup → stopHandleDrag 4. renderTimeline() → recalculates sequence bounds Technical Details: - Global state: isDraggingHandle, handleType - Separate drag handlers from effect move - Minimum effect duration: 0.1 seconds - Prevents handle overlap (start < end enforced) - Works with beat snapping when enabled - Property panel updates in real-time CSS: .effect-handle { position: absolute; width: 6px; height: 100%; cursor: ew-resize; display: none (only on .effect.selected) } Event Flow: Click handle → stopPropagation (don't trigger effect drag) ↓ startHandleDrag → set isDraggingHandle, handleType ↓ mousemove → onHandleDrag → adjust start/end time ↓ mouseup → stopHandleDrag → cleanup ↓ renderTimeline → sequence bounds recalculate ↓ updateProperties → panel shows new times User Experience: - Select effect → handles appear at edges - Drag left handle → shorten/lengthen from start - Drag right handle → shorten/lengthen from end - Visual feedback: effect resizes in real-time - Sequence box adjusts to fit resized effect - Property panel shows updated times immediately --- tools/timeline_editor/index.html | 116 ++++++++++++++++++++++++++++++++++++++- 1 file changed, 113 insertions(+), 3 deletions(-) (limited to 'tools/timeline_editor/index.html') diff --git a/tools/timeline_editor/index.html b/tools/timeline_editor/index.html index fb813a5..f5277d8 100644 --- a/tools/timeline_editor/index.html +++ b/tools/timeline_editor/index.html @@ -234,6 +234,36 @@ color: #d4d4d4; } + .effect-handle { + position: absolute; + top: 0; + width: 6px; + height: 100%; + background: rgba(78, 201, 176, 0.8); + cursor: ew-resize; + display: none; + z-index: 10; + } + + .effect.selected .effect-handle { + display: block; + } + + .effect-handle.left { + left: 0; + border-radius: 3px 0 0 3px; + } + + .effect-handle.right { + right: 0; + border-radius: 0 3px 3px 0; + } + + .effect-handle:hover { + background: rgba(78, 201, 176, 1); + width: 8px; + } + .properties-panel { position: fixed; top: 80px; @@ -416,6 +446,8 @@ let isDragging = false; let dragOffset = { x: 0, y: 0 }; let lastActiveSeqIndex = -1; + let isDraggingHandle = false; + let handleType = null; // 'left' or 'right' // DOM elements const timeline = document.getElementById('timeline'); @@ -701,7 +733,11 @@ } // Show only class name, full info on hover - effectDiv.innerHTML = `${effect.className}`; + effectDiv.innerHTML = ` +
+ ${effect.className} +
+ `; effectDiv.title = `${effect.className}\n${timeDisplay}\nPriority: ${effect.priority}\n${effect.args || '(no args)'}`; if (selectedItem && selectedItem.type === 'effect' && @@ -709,9 +745,26 @@ effectDiv.classList.add('selected'); } - effectDiv.addEventListener('mousedown', (e) => { + // Handle resizing (only for selected effects) + const leftHandle = effectDiv.querySelector('.effect-handle.left'); + const rightHandle = effectDiv.querySelector('.effect-handle.right'); + + leftHandle.addEventListener('mousedown', (e) => { + e.stopPropagation(); + startHandleDrag(e, 'left', seqIndex, effectIndex); + }); + + rightHandle.addEventListener('mousedown', (e) => { e.stopPropagation(); - startDrag(e, 'effect', seqIndex, effectIndex); + startHandleDrag(e, 'right', seqIndex, effectIndex); + }); + + effectDiv.addEventListener('mousedown', (e) => { + // Only drag if not clicking on a handle + if (!e.target.classList.contains('effect-handle')) { + e.stopPropagation(); + startDrag(e, 'effect', seqIndex, effectIndex); + } }); effectDiv.addEventListener('click', (e) => { e.stopPropagation(); @@ -782,6 +835,58 @@ document.removeEventListener('mouseup', stopDrag); } + // Handle dragging (for resizing effects) + function startHandleDrag(e, type, seqIndex, effectIndex) { + e.preventDefault(); + isDraggingHandle = true; + handleType = type; + selectedItem = { type: 'effect', seqIndex, effectIndex, index: seqIndex }; + renderTimeline(); + updateProperties(); + + document.addEventListener('mousemove', onHandleDrag); + document.addEventListener('mouseup', stopHandleDrag); + } + + function onHandleDrag(e) { + if (!isDraggingHandle || !selectedItem) return; + + const timelineRect = timeline.getBoundingClientRect(); + const newX = e.clientX - timelineRect.left; + let newTime = Math.max(0, newX / pixelsPerSecond); + + // Snap to beat when in beat mode + if (showBeats) { + const beatDuration = 60.0 / bpm; + const nearestBeat = Math.round(newTime / beatDuration); + newTime = nearestBeat * beatDuration; + } + + const seq = sequences[selectedItem.seqIndex]; + const effect = seq.effects[selectedItem.effectIndex]; + + // Convert to relative time + const relativeTime = newTime - seq.startTime; + + if (handleType === 'left') { + // Adjust start time, keep end time fixed + effect.startTime = Math.max(0, Math.min(Math.round(relativeTime * 100) / 100, effect.endTime - 0.1)); + } else if (handleType === 'right') { + // Adjust end time, keep start time fixed + effect.endTime = Math.max(effect.startTime + 0.1, Math.round(relativeTime * 100) / 100); + } + + renderTimeline(); + updateProperties(); + } + + function stopHandleDrag() { + isDraggingHandle = false; + handleType = null; + document.removeEventListener('mousemove', onHandleDrag); + document.removeEventListener('mouseup', stopHandleDrag); + } + // Selection function selectItem(type, seqIndex, effectIndex = null) { selectedItem = { type, index: seqIndex, seqIndex, effectIndex }; @@ -802,6 +907,10 @@ if (selectedItem.type === 'sequence') { const seq = sequences[selectedItem.index]; propertiesContent.innerHTML = ` +
+ + +
@@ -845,6 +954,7 @@ if (selectedItem.type === 'sequence') { const seq = sequences[selectedItem.index]; + seq.name = document.getElementById('propName').value; seq.startTime = parseFloat(document.getElementById('propStartTime').value); seq.priority = parseInt(document.getElementById('propPriority').value); } else if (selectedItem.type === 'effect') { -- cgit v1.2.3