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">
|
||||
<div class="placeholder-text">Timeline</div>
|
||||
<script lang="ts">
|
||||
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>
|
||||
|
||||
<style>
|
||||
.timeline {
|
||||
.timeline-container {
|
||||
height: 160px;
|
||||
background: var(--timeline-bg);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
position: relative;
|
||||
cursor: crosshair;
|
||||
}
|
||||
.placeholder-text {
|
||||
color: var(--text-muted);
|
||||
font-size: 13px;
|
||||
|
||||
canvas {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
</style>
|
||||
|
||||
Reference in New Issue
Block a user