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:
2026-09-22 18:15:19 -04:00
parent 23b30be357
commit c90b6765b7
5 changed files with 110 additions and 35 deletions

View File

@@ -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}

View File

@@ -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);

View File

@@ -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;
}

View File

@@ -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;