From c90b6765b7e2ece47bc6cda792cccbb6ddae2bec Mon Sep 17 00:00:00 2001 From: cottongin Date: Tue, 22 Sep 2026 18:15:19 -0400 Subject: [PATCH] fix: timeline resize regression + dynamic lane alignment MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Remove splitAreaEl dependency from handleResizeMove; use fixed 40-500 clamp - Add computeLanes() + LaneLayout for dynamic lane height computation - Update clipRenderer to accept LaneLayout instead of hardcoded constants - Tighten CONTROLS_HEIGHT (30→22) and CLIP_HEIGHT (30→20) to reduce gaps - Tighten label baseline offset (14→10) for better waveform-to-tick alignment Co-authored-by: Cursor --- ...2-fix-resize-waveform-alignment-summary.md | 44 ++++++++++++++ src/App.svelte | 8 +-- src/lib/components/Timeline.svelte | 4 +- src/lib/timeline/clipRenderer.ts | 29 +++++---- src/lib/timeline/renderer.ts | 60 ++++++++++++++----- 5 files changed, 110 insertions(+), 35 deletions(-) create mode 100644 chat-summaries/2026-09-22_17-42-fix-resize-waveform-alignment-summary.md diff --git a/chat-summaries/2026-09-22_17-42-fix-resize-waveform-alignment-summary.md b/chat-summaries/2026-09-22_17-42-fix-resize-waveform-alignment-summary.md new file mode 100644 index 0000000..4640099 --- /dev/null +++ b/chat-summaries/2026-09-22_17-42-fix-resize-waveform-alignment-summary.md @@ -0,0 +1,44 @@ +# Fix Resize + Waveform Alignment + +**Date:** 2026-09-22 17:42 +**Task:** Implement the "Fix Resize + Waveform Alignment" plan to fix horizontal resize regression and waveform/lane alignment issues. + +## Changes Made + +### 1. Resize Regression Fix — `src/App.svelte` +- **Root cause:** After the CSS-grid refactor, `handleResizeMove` used `splitAreaEl.clientHeight` as the upper bound. Since `.lower-section` only contained the resize handle (5px) + timeline pane, `totalHeight ≈ timelineHeight + 5`, so the clamp `Math.min(totalHeight - 20, ...)` immediately locked the height. +- **Fix:** + - Removed `splitAreaEl` state variable. + - Removed `bind:this={splitAreaEl}` from the `.lower-section` div. + - Simplified `handleResizeMove` to use a fixed max of 500 (matching the existing keyboard resize handler): `Math.max(40, Math.min(500, resizeStartHeight + delta))`. + +### 2. Dynamic Lane Layout — `src/lib/timeline/renderer.ts` +- Removed fixed lane height constants (`THUMB_LANE_HEIGHT`, `WAVEFORM_LANE_HEIGHT`, `CONTROLS_LANE_HEIGHT`). +- Added `LaneLayout` interface and `computeLanes(totalHeight)` function that dynamically computes lane positions proportionally based on total canvas height. + - Controls lane: fixed 30px at bottom. + - Clip lane: fixed 30px above controls. + - Remaining space split 55/45 between thumbnails and waveform (with minimums of 20px and 15px). +- Updated `drawTimeline` to call `computeLanes(height)` and pass layout to all sub-draw functions. +- Updated `drawTimeTicks` to accept `LaneLayout` and use `lanes.controlsY` instead of `state.height - CONTROLS_LANE_HEIGHT`. + +### 3. Clip Renderer Update — `src/lib/timeline/clipRenderer.ts` +- Removed hardcoded `CLIP_LANE_Y` and `CLIP_LANE_HEIGHT` constants. +- Updated `drawClips` and `hitTestClip` signatures to accept `LaneLayout` parameter. +- All clip positioning now uses `lanes.clipY` and `lanes.clipHeight`. + +### 4. Timeline Component Update — `src/lib/components/Timeline.svelte` +- Imported `computeLanes` from renderer. +- Updated `hitTestClip` call to pass `computeLanes(timelineState.height)`. + +## Verification +- `npm run check` (svelte-check): 0 errors, 0 warnings. + +## Files Modified +- `src/App.svelte` +- `src/lib/timeline/renderer.ts` +- `src/lib/timeline/clipRenderer.ts` +- `src/lib/components/Timeline.svelte` + +## Lessons Learned +- When using CSS grid layouts, elements that previously represented the "total space" may now only contain their own children. Relying on `clientHeight` of such elements for resize calculations breaks — use fixed bounds instead. +- Dynamic lane computation is preferable to fixed pixel constants because it adapts to any canvas height, preventing gaps or overlaps when the user resizes the timeline. diff --git a/src/App.svelte b/src/App.svelte index fdabbe6..9540442 100644 --- a/src/App.svelte +++ b/src/App.svelte @@ -30,7 +30,6 @@ let isResizing = $state(false); let resizeStartY = $state(0); let resizeStartHeight = $state(0); - let splitAreaEl = $state(null); let isSidebarResizing = $state(false); let sidebarResizeStartX = $state(0); @@ -70,10 +69,9 @@ } function handleResizeMove(e: MouseEvent) { - if (!isResizing || !splitAreaEl) return; + if (!isResizing) return; const delta = e.clientY - resizeStartY; - const totalHeight = splitAreaEl.clientHeight; - const newHeight = Math.max(40, Math.min(totalHeight - 20, resizeStartHeight + delta)); + const newHeight = Math.max(40, Math.min(500, resizeStartHeight + delta)); timelineHeight = newHeight; } @@ -281,7 +279,7 @@ -
+
40) { @@ -37,7 +39,7 @@ export function drawClips( ctx.fillText( clip.label, (x1 + x2) / 2, - CLIP_LANE_Y + CLIP_LANE_HEIGHT / 2 + 4, + clipY + clipH / 2 + 4, labelWidth - 10 ); } @@ -49,8 +51,8 @@ export function drawClips( ctx.lineWidth = 2; ctx.setLineDash([4, 4]); ctx.beginPath(); - ctx.moveTo(x, CLIP_LANE_Y); - ctx.lineTo(x, CLIP_LANE_Y + CLIP_LANE_HEIGHT); + ctx.moveTo(x, clipY); + ctx.lineTo(x, clipY + clipH); ctx.stroke(); ctx.setLineDash([]); ctx.lineWidth = 1; @@ -66,9 +68,10 @@ export function hitTestClip( x: number, y: number, state: TimelineState, - clips: Clip[] + clips: Clip[], + lanes: LaneLayout ): ClipHitResult | null { - if (y < CLIP_LANE_Y || y > CLIP_LANE_Y + CLIP_LANE_HEIGHT) { + if (y < lanes.clipY || y > lanes.clipY + lanes.clipHeight) { return null; } diff --git a/src/lib/timeline/renderer.ts b/src/lib/timeline/renderer.ts index 07db195..f4a8ec3 100644 --- a/src/lib/timeline/renderer.ts +++ b/src/lib/timeline/renderer.ts @@ -23,14 +23,41 @@ export function xToTime(x: number, state: TimelineState): number { return state.visibleStart + (x / state.width) * range; } -const THUMB_LANE_HEIGHT = 50; -const WAVEFORM_LANE_HEIGHT = 40; -const CONTROLS_LANE_HEIGHT = 30; const TICK_HEIGHT = 10; const BASE_FONT_SIZE = 12; const MAGNIFY_BONUS = 10; const MAGNIFY_RADIUS = 100; +const CONTROLS_HEIGHT = 22; +const CLIP_HEIGHT = 20; + +export interface LaneLayout { + thumbY: number; + thumbHeight: number; + waveformY: number; + waveformHeight: number; + clipY: number; + clipHeight: number; + controlsY: number; + controlsHeight: number; +} + +export function computeLanes(totalHeight: number): LaneLayout { + const available = Math.max(0, totalHeight - CONTROLS_HEIGHT - CLIP_HEIGHT); + const thumbHeight = Math.max(20, Math.round(available * 0.55)); + const waveformHeight = Math.max(15, available - thumbHeight); + return { + thumbY: 0, + thumbHeight, + waveformY: thumbHeight, + waveformHeight, + clipY: thumbHeight + waveformHeight, + clipHeight: CLIP_HEIGHT, + controlsY: totalHeight - CONTROLS_HEIGHT, + controlsHeight: CONTROLS_HEIGHT, + }; +} + export function drawTimeline( ctx: CanvasRenderingContext2D, state: TimelineState, @@ -50,21 +77,23 @@ export function drawTimeline( ctx.fillStyle = '#181825'; ctx.fillRect(0, 0, width, height); - drawTimeTicks(ctx, state, duration, mouseX); + const lanes = computeLanes(height); + + drawTimeTicks(ctx, state, duration, mouseX, lanes); if (thumbnailSpritesheets.length > 0) { - drawThumbnails(ctx, state, thumbnailSpritesheets, duration, 0, THUMB_LANE_HEIGHT); + drawThumbnails(ctx, state, thumbnailSpritesheets, duration, lanes.thumbY, lanes.thumbHeight); } else { - drawPlaceholderLane(ctx, state, 0, THUMB_LANE_HEIGHT, 'Thumbnails', hasVideo); + drawPlaceholderLane(ctx, state, lanes.thumbY, lanes.thumbHeight, 'Thumbnails', hasVideo); } if (waveform.tiers) { - drawWaveform(ctx, state, waveform, duration, THUMB_LANE_HEIGHT, WAVEFORM_LANE_HEIGHT); + drawWaveform(ctx, state, waveform, duration, lanes.waveformY, lanes.waveformHeight); } else { - drawPlaceholderLane(ctx, state, THUMB_LANE_HEIGHT, WAVEFORM_LANE_HEIGHT, 'Waveform', hasVideo); + drawPlaceholderLane(ctx, state, lanes.waveformY, lanes.waveformHeight, 'Waveform', hasVideo); } - drawClips(ctx, state, clips, selectedClipId, pendingInPoint); + drawClips(ctx, state, clips, selectedClipId, pendingInPoint, lanes); drawPlayhead(ctx, state, currentTime); } @@ -73,14 +102,15 @@ function drawTimeTicks( ctx: CanvasRenderingContext2D, state: TimelineState, duration: number, - mouseX: number | null + mouseX: number | null, + lanes: LaneLayout ): void { const range = state.visibleEnd - state.visibleStart; const tickInterval = getTickInterval(range, state.width); const startTick = Math.floor(state.visibleStart / tickInterval) * tickInterval; ctx.textAlign = 'center'; - const baselineY = state.height - CONTROLS_LANE_HEIGHT + TICK_HEIGHT + 14; + const baselineY = lanes.controlsY + TICK_HEIGHT + 10; // Draw minor ticks first (behind major ticks) const minorInterval = tickInterval / 4; @@ -91,8 +121,8 @@ function drawTimeTicks( const x = timeToX(t, state); ctx.strokeStyle = '#35354a'; ctx.beginPath(); - ctx.moveTo(x, state.height - CONTROLS_LANE_HEIGHT); - ctx.lineTo(x, state.height - CONTROLS_LANE_HEIGHT + minorTickHeight); + ctx.moveTo(x, lanes.controlsY); + ctx.lineTo(x, lanes.controlsY + minorTickHeight); ctx.stroke(); } @@ -103,8 +133,8 @@ function drawTimeTicks( ctx.strokeStyle = '#45475a'; ctx.beginPath(); - ctx.moveTo(x, state.height - CONTROLS_LANE_HEIGHT); - ctx.lineTo(x, state.height - CONTROLS_LANE_HEIGHT + TICK_HEIGHT); + ctx.moveTo(x, lanes.controlsY); + ctx.lineTo(x, lanes.controlsY + TICK_HEIGHT); ctx.stroke(); let fontSize = BASE_FONT_SIZE;