fix: timeline resize regression + dynamic lane alignment
- 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 <cursoragent@cursor.com>
This commit is contained in:
@@ -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.
|
||||
@@ -30,7 +30,6 @@
|
||||
let isResizing = $state(false);
|
||||
let resizeStartY = $state(0);
|
||||
let resizeStartHeight = $state(0);
|
||||
let splitAreaEl = $state<HTMLDivElement | null>(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 @@
|
||||
</div>
|
||||
|
||||
<!-- Timeline section: always full width -->
|
||||
<div class="lower-section" bind:this={splitAreaEl}>
|
||||
<div class="lower-section">
|
||||
<div
|
||||
class="resize-handle"
|
||||
class:active={isResizing}
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
markOutPoint,
|
||||
removeClip,
|
||||
} from '$lib/stores/clips.svelte';
|
||||
import { drawTimeline, type TimelineState } from '$lib/timeline/renderer';
|
||||
import { drawTimeline, type TimelineState, computeLanes } from '$lib/timeline/renderer';
|
||||
import {
|
||||
computeZoom,
|
||||
panBy,
|
||||
@@ -300,7 +300,7 @@
|
||||
const x = getCanvasX(e);
|
||||
const y = getCanvasY(e);
|
||||
|
||||
const hit = hitTestClip(x, y, timelineState, clipStore.clips);
|
||||
const hit = hitTestClip(x, y, timelineState, clipStore.clips, computeLanes(timelineState.height));
|
||||
if (hit) {
|
||||
if (hit.edge === 'body') {
|
||||
selectClip(hit.clipId);
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
import type { Clip } from '$lib/stores/clips.svelte';
|
||||
import { timeToX, type TimelineState } from './renderer';
|
||||
import { timeToX, type TimelineState, type LaneLayout } from './renderer';
|
||||
|
||||
const CLIP_LANE_Y = 90;
|
||||
const CLIP_LANE_HEIGHT = 30;
|
||||
const HANDLE_WIDTH = 6;
|
||||
|
||||
export function drawClips(
|
||||
@@ -10,24 +8,28 @@ export function drawClips(
|
||||
state: TimelineState,
|
||||
clips: Clip[],
|
||||
selectedId: string | null,
|
||||
pendingInPoint: number | null
|
||||
pendingInPoint: number | null,
|
||||
lanes: LaneLayout
|
||||
): void {
|
||||
const clipY = lanes.clipY;
|
||||
const clipH = lanes.clipHeight;
|
||||
|
||||
for (const clip of clips) {
|
||||
const x1 = timeToX(clip.startTime, state);
|
||||
const x2 = timeToX(clip.endTime, state);
|
||||
const isSelected = clip.id === selectedId;
|
||||
|
||||
ctx.fillStyle = clip.color + '66';
|
||||
ctx.fillRect(x1, CLIP_LANE_Y, x2 - x1, CLIP_LANE_HEIGHT);
|
||||
ctx.fillRect(x1, clipY, x2 - x1, clipH);
|
||||
|
||||
ctx.strokeStyle = isSelected ? '#ffffff' : clip.color;
|
||||
ctx.lineWidth = isSelected ? 2 : 1;
|
||||
ctx.strokeRect(x1, CLIP_LANE_Y, x2 - x1, CLIP_LANE_HEIGHT);
|
||||
ctx.strokeRect(x1, clipY, x2 - x1, clipH);
|
||||
ctx.lineWidth = 1;
|
||||
|
||||
ctx.fillStyle = isSelected ? '#ffffff' : clip.color;
|
||||
ctx.fillRect(x1 - HANDLE_WIDTH / 2, CLIP_LANE_Y, HANDLE_WIDTH, CLIP_LANE_HEIGHT);
|
||||
ctx.fillRect(x2 - HANDLE_WIDTH / 2, CLIP_LANE_Y, HANDLE_WIDTH, CLIP_LANE_HEIGHT);
|
||||
ctx.fillRect(x1 - HANDLE_WIDTH / 2, clipY, HANDLE_WIDTH, clipH);
|
||||
ctx.fillRect(x2 - HANDLE_WIDTH / 2, clipY, HANDLE_WIDTH, clipH);
|
||||
|
||||
const labelWidth = x2 - x1;
|
||||
if (labelWidth > 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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user