feat: add canvas-based timeline with playhead, scrubbing, and zoom
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -1,17 +1,133 @@
|
|||||||
<div class="timeline">
|
<script lang="ts">
|
||||||
<div class="placeholder-text">Timeline</div>
|
import { session } from '$lib/stores/videoSession.svelte';
|
||||||
|
import { drawTimeline, type TimelineState } from '$lib/timeline/renderer';
|
||||||
|
import {
|
||||||
|
computeZoom,
|
||||||
|
handleClick as computeClickTime,
|
||||||
|
} from '$lib/timeline/interactions';
|
||||||
|
|
||||||
|
let canvas = $state<HTMLCanvasElement | null>(null);
|
||||||
|
let containerEl = $state<HTMLDivElement | null>(null);
|
||||||
|
let animFrameId = $state(0);
|
||||||
|
|
||||||
|
let timelineState = $state<TimelineState>({
|
||||||
|
visibleStart: 0,
|
||||||
|
visibleEnd: 60,
|
||||||
|
zoom: 1,
|
||||||
|
width: 600,
|
||||||
|
height: 160,
|
||||||
|
});
|
||||||
|
|
||||||
|
let isDragging = $state(false);
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
if (session.duration > 0) {
|
||||||
|
timelineState.visibleEnd = session.duration;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
if (!containerEl) return;
|
||||||
|
const observer = new ResizeObserver((entries) => {
|
||||||
|
for (const entry of entries) {
|
||||||
|
timelineState.width = entry.contentRect.width;
|
||||||
|
timelineState.height = entry.contentRect.height;
|
||||||
|
if (canvas) {
|
||||||
|
canvas.width = entry.contentRect.width * window.devicePixelRatio;
|
||||||
|
canvas.height = entry.contentRect.height * window.devicePixelRatio;
|
||||||
|
canvas.style.width = `${entry.contentRect.width}px`;
|
||||||
|
canvas.style.height = `${entry.contentRect.height}px`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
observer.observe(containerEl);
|
||||||
|
return () => observer.disconnect();
|
||||||
|
});
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
if (!canvas) return;
|
||||||
|
const ctx = canvas.getContext('2d');
|
||||||
|
if (!ctx) return;
|
||||||
|
|
||||||
|
function draw() {
|
||||||
|
if (!ctx || !canvas) return;
|
||||||
|
ctx.save();
|
||||||
|
ctx.scale(window.devicePixelRatio, window.devicePixelRatio);
|
||||||
|
drawTimeline(ctx, timelineState, session.currentTime, session.duration);
|
||||||
|
ctx.restore();
|
||||||
|
animFrameId = requestAnimationFrame(draw);
|
||||||
|
}
|
||||||
|
|
||||||
|
draw();
|
||||||
|
return () => cancelAnimationFrame(animFrameId);
|
||||||
|
});
|
||||||
|
|
||||||
|
function getCanvasX(e: MouseEvent): number {
|
||||||
|
if (!canvas) return 0;
|
||||||
|
const rect = canvas.getBoundingClientRect();
|
||||||
|
return e.clientX - rect.left;
|
||||||
|
}
|
||||||
|
|
||||||
|
function seekTo(time: number) {
|
||||||
|
session.currentTime = time;
|
||||||
|
const videoEl = document.querySelector('video');
|
||||||
|
if (videoEl) {
|
||||||
|
videoEl.currentTime = time;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleMouseDown(e: MouseEvent) {
|
||||||
|
isDragging = true;
|
||||||
|
const x = getCanvasX(e);
|
||||||
|
const targetTime = computeClickTime(x, timelineState, session.duration);
|
||||||
|
seekTo(targetTime);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleMouseMove(e: MouseEvent) {
|
||||||
|
if (!isDragging) return;
|
||||||
|
const x = getCanvasX(e);
|
||||||
|
const targetTime = computeClickTime(x, timelineState, session.duration);
|
||||||
|
seekTo(targetTime);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleMouseUp() {
|
||||||
|
isDragging = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleWheel(e: WheelEvent) {
|
||||||
|
e.preventDefault();
|
||||||
|
const x = getCanvasX(e);
|
||||||
|
const anchorTime = computeClickTime(x, timelineState, session.duration);
|
||||||
|
const factor = e.deltaY < 0 ? 1.15 : 0.87;
|
||||||
|
const result = computeZoom(timelineState, anchorTime, factor, session.duration);
|
||||||
|
timelineState.visibleStart = result.visibleStart;
|
||||||
|
timelineState.visibleEnd = result.visibleEnd;
|
||||||
|
timelineState.zoom = result.zoom;
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="timeline-container" bind:this={containerEl}>
|
||||||
|
<canvas
|
||||||
|
bind:this={canvas}
|
||||||
|
onmousedown={handleMouseDown}
|
||||||
|
onmousemove={handleMouseMove}
|
||||||
|
onmouseup={handleMouseUp}
|
||||||
|
onmouseleave={handleMouseUp}
|
||||||
|
onwheel={handleWheel}
|
||||||
|
></canvas>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
.timeline {
|
.timeline-container {
|
||||||
height: 160px;
|
height: 160px;
|
||||||
background: var(--timeline-bg);
|
background: var(--timeline-bg);
|
||||||
display: flex;
|
position: relative;
|
||||||
align-items: center;
|
cursor: crosshair;
|
||||||
justify-content: center;
|
|
||||||
}
|
}
|
||||||
.placeholder-text {
|
|
||||||
color: var(--text-muted);
|
canvas {
|
||||||
font-size: 13px;
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
48
src/lib/timeline/interactions.ts
Normal file
48
src/lib/timeline/interactions.ts
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
import { type TimelineState, xToTime } from './renderer';
|
||||||
|
|
||||||
|
export type { TimelineState };
|
||||||
|
|
||||||
|
const MIN_VISIBLE_RANGE = 0.1;
|
||||||
|
const MIN_ZOOM = 1;
|
||||||
|
|
||||||
|
export function clampZoom(zoom: number, duration: number): number {
|
||||||
|
const maxZoom = duration / MIN_VISIBLE_RANGE;
|
||||||
|
return Math.max(MIN_ZOOM, Math.min(zoom, maxZoom));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function computeZoom(
|
||||||
|
state: TimelineState,
|
||||||
|
anchorTime: number,
|
||||||
|
factor: number,
|
||||||
|
duration: number
|
||||||
|
): { visibleStart: number; visibleEnd: number; zoom: number } {
|
||||||
|
const currentRange = state.visibleEnd - state.visibleStart;
|
||||||
|
const newRange = currentRange / factor;
|
||||||
|
const clampedRange = Math.max(MIN_VISIBLE_RANGE, Math.min(newRange, duration));
|
||||||
|
|
||||||
|
const anchorRatio = (anchorTime - state.visibleStart) / currentRange;
|
||||||
|
let newStart = anchorTime - anchorRatio * clampedRange;
|
||||||
|
let newEnd = newStart + clampedRange;
|
||||||
|
|
||||||
|
if (newStart < 0) {
|
||||||
|
newStart = 0;
|
||||||
|
newEnd = clampedRange;
|
||||||
|
}
|
||||||
|
if (newEnd > duration) {
|
||||||
|
newEnd = duration;
|
||||||
|
newStart = Math.max(0, newEnd - clampedRange);
|
||||||
|
}
|
||||||
|
|
||||||
|
const newZoom = clampZoom(duration / clampedRange, duration);
|
||||||
|
|
||||||
|
return { visibleStart: newStart, visibleEnd: newEnd, zoom: newZoom };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function handleClick(
|
||||||
|
x: number,
|
||||||
|
state: TimelineState,
|
||||||
|
duration: number
|
||||||
|
): number {
|
||||||
|
const time = xToTime(x, state);
|
||||||
|
return Math.max(0, Math.min(time, duration));
|
||||||
|
}
|
||||||
128
src/lib/timeline/renderer.ts
Normal file
128
src/lib/timeline/renderer.ts
Normal file
@@ -0,0 +1,128 @@
|
|||||||
|
export interface TimelineState {
|
||||||
|
visibleStart: number;
|
||||||
|
visibleEnd: number;
|
||||||
|
zoom: number;
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function timeToX(time: number, state: TimelineState): number {
|
||||||
|
const range = state.visibleEnd - state.visibleStart;
|
||||||
|
if (range <= 0) return 0;
|
||||||
|
return ((time - state.visibleStart) / range) * state.width;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function xToTime(x: number, state: TimelineState): number {
|
||||||
|
const range = state.visibleEnd - state.visibleStart;
|
||||||
|
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;
|
||||||
|
|
||||||
|
export function drawTimeline(
|
||||||
|
ctx: CanvasRenderingContext2D,
|
||||||
|
state: TimelineState,
|
||||||
|
currentTime: number,
|
||||||
|
duration: number
|
||||||
|
): void {
|
||||||
|
const { width, height } = state;
|
||||||
|
ctx.clearRect(0, 0, width, height);
|
||||||
|
|
||||||
|
ctx.fillStyle = '#181825';
|
||||||
|
ctx.fillRect(0, 0, width, height);
|
||||||
|
|
||||||
|
drawTimeTicks(ctx, state, duration);
|
||||||
|
|
||||||
|
drawPlaceholderLane(ctx, state, 0, THUMB_LANE_HEIGHT, 'Thumbnails');
|
||||||
|
drawPlaceholderLane(ctx, state, THUMB_LANE_HEIGHT, WAVEFORM_LANE_HEIGHT, 'Waveform');
|
||||||
|
|
||||||
|
drawPlayhead(ctx, state, currentTime);
|
||||||
|
}
|
||||||
|
|
||||||
|
function drawTimeTicks(
|
||||||
|
ctx: CanvasRenderingContext2D,
|
||||||
|
state: TimelineState,
|
||||||
|
duration: number
|
||||||
|
): void {
|
||||||
|
const range = state.visibleEnd - state.visibleStart;
|
||||||
|
const tickInterval = getTickInterval(range, state.width);
|
||||||
|
const startTick = Math.floor(state.visibleStart / tickInterval) * tickInterval;
|
||||||
|
|
||||||
|
ctx.fillStyle = '#6c7086';
|
||||||
|
ctx.font = '10px -apple-system, sans-serif';
|
||||||
|
ctx.textAlign = 'center';
|
||||||
|
|
||||||
|
for (let t = startTick; t <= state.visibleEnd; t += tickInterval) {
|
||||||
|
if (t < 0 || t > duration) continue;
|
||||||
|
const x = timeToX(t, state);
|
||||||
|
ctx.strokeStyle = '#45475a';
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(x, state.height - CONTROLS_LANE_HEIGHT);
|
||||||
|
ctx.lineTo(x, state.height - CONTROLS_LANE_HEIGHT + TICK_HEIGHT);
|
||||||
|
ctx.stroke();
|
||||||
|
|
||||||
|
const label = formatTickLabel(t);
|
||||||
|
ctx.fillText(label, x, state.height - CONTROLS_LANE_HEIGHT + TICK_HEIGHT + 12);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getTickInterval(visibleRange: number, width: number): number {
|
||||||
|
const minPixelsPerTick = 80;
|
||||||
|
const maxTicks = width / minPixelsPerTick;
|
||||||
|
const rawInterval = visibleRange / maxTicks;
|
||||||
|
|
||||||
|
const intervals = [0.1, 0.25, 0.5, 1, 2, 5, 10, 15, 30, 60, 120, 300, 600];
|
||||||
|
for (const interval of intervals) {
|
||||||
|
if (interval >= rawInterval) return interval;
|
||||||
|
}
|
||||||
|
return 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatTickLabel(seconds: number): string {
|
||||||
|
const m = Math.floor(seconds / 60);
|
||||||
|
const s = Math.floor(seconds % 60);
|
||||||
|
return `${m}:${String(s).padStart(2, '0')}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function drawPlaceholderLane(
|
||||||
|
ctx: CanvasRenderingContext2D,
|
||||||
|
state: TimelineState,
|
||||||
|
y: number,
|
||||||
|
height: number,
|
||||||
|
label: string
|
||||||
|
): void {
|
||||||
|
ctx.fillStyle = '#2a2a3e';
|
||||||
|
ctx.fillRect(0, y, state.width, height);
|
||||||
|
ctx.strokeStyle = '#353550';
|
||||||
|
ctx.strokeRect(0, y, state.width, height);
|
||||||
|
ctx.fillStyle = '#6c7086';
|
||||||
|
ctx.font = '11px -apple-system, sans-serif';
|
||||||
|
ctx.textAlign = 'center';
|
||||||
|
ctx.fillText(`Generating ${label}…`, state.width / 2, y + height / 2 + 4);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function drawPlayhead(
|
||||||
|
ctx: CanvasRenderingContext2D,
|
||||||
|
state: TimelineState,
|
||||||
|
currentTime: number
|
||||||
|
): void {
|
||||||
|
const x = timeToX(currentTime, state);
|
||||||
|
ctx.strokeStyle = '#ffffff';
|
||||||
|
ctx.lineWidth = 2;
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(x, 0);
|
||||||
|
ctx.lineTo(x, state.height);
|
||||||
|
ctx.stroke();
|
||||||
|
ctx.lineWidth = 1;
|
||||||
|
|
||||||
|
ctx.fillStyle = '#ffffff';
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(x - 5, 0);
|
||||||
|
ctx.lineTo(x + 5, 0);
|
||||||
|
ctx.lineTo(x, 8);
|
||||||
|
ctx.closePath();
|
||||||
|
ctx.fill();
|
||||||
|
}
|
||||||
40
tests/lib/timeline/interactions.test.ts
Normal file
40
tests/lib/timeline/interactions.test.ts
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { clampZoom, computeZoom, type TimelineState } from '$lib/timeline/interactions';
|
||||||
|
|
||||||
|
describe('clampZoom', () => {
|
||||||
|
it('clamps zoom above minimum', () => {
|
||||||
|
expect(clampZoom(0.5, 60)).toBeGreaterThanOrEqual(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('clamps zoom below maximum', () => {
|
||||||
|
expect(clampZoom(10000, 60)).toBeLessThanOrEqual(600);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('computeZoom', () => {
|
||||||
|
it('zooming in narrows the visible range', () => {
|
||||||
|
const state: TimelineState = {
|
||||||
|
visibleStart: 0,
|
||||||
|
visibleEnd: 60,
|
||||||
|
zoom: 1,
|
||||||
|
width: 600,
|
||||||
|
height: 160,
|
||||||
|
};
|
||||||
|
const result = computeZoom(state, 30, 1.1, 60);
|
||||||
|
const newRange = result.visibleEnd - result.visibleStart;
|
||||||
|
expect(newRange).toBeLessThan(60);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('zooming out widens the visible range', () => {
|
||||||
|
const state: TimelineState = {
|
||||||
|
visibleStart: 10,
|
||||||
|
visibleEnd: 20,
|
||||||
|
zoom: 6,
|
||||||
|
width: 600,
|
||||||
|
height: 160,
|
||||||
|
};
|
||||||
|
const result = computeZoom(state, 15, 0.9, 60);
|
||||||
|
const newRange = result.visibleEnd - result.visibleStart;
|
||||||
|
expect(newRange).toBeGreaterThan(10);
|
||||||
|
});
|
||||||
|
});
|
||||||
57
tests/lib/timeline/renderer.test.ts
Normal file
57
tests/lib/timeline/renderer.test.ts
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { timeToX, xToTime, type TimelineState } from '$lib/timeline/renderer';
|
||||||
|
|
||||||
|
function makeState(overrides: Partial<TimelineState> = {}): TimelineState {
|
||||||
|
return {
|
||||||
|
visibleStart: 0,
|
||||||
|
visibleEnd: 60,
|
||||||
|
zoom: 1,
|
||||||
|
width: 600,
|
||||||
|
height: 160,
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('timeToX', () => {
|
||||||
|
it('maps start of visible range to 0', () => {
|
||||||
|
const state = makeState();
|
||||||
|
expect(timeToX(0, state)).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('maps end of visible range to width', () => {
|
||||||
|
const state = makeState();
|
||||||
|
expect(timeToX(60, state)).toBe(600);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('maps midpoint correctly', () => {
|
||||||
|
const state = makeState();
|
||||||
|
expect(timeToX(30, state)).toBe(300);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles zoomed-in view', () => {
|
||||||
|
const state = makeState({ visibleStart: 10, visibleEnd: 20 });
|
||||||
|
expect(timeToX(15, state)).toBe(300);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('xToTime', () => {
|
||||||
|
it('maps 0 to visible start', () => {
|
||||||
|
const state = makeState();
|
||||||
|
expect(xToTime(0, state)).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('maps width to visible end', () => {
|
||||||
|
const state = makeState();
|
||||||
|
expect(xToTime(600, state)).toBe(60);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('maps midpoint correctly', () => {
|
||||||
|
const state = makeState();
|
||||||
|
expect(xToTime(300, state)).toBe(30);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('inverse of timeToX', () => {
|
||||||
|
const state = makeState({ visibleStart: 10, visibleEnd: 20 });
|
||||||
|
expect(xToTime(timeToX(15, state), state)).toBeCloseTo(15);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user