fix timeline select & playhead

This commit is contained in:
2026-06-08 23:13:29 -07:00
parent f6a6681e16
commit 43e9d77de9
2 changed files with 185 additions and 53 deletions
+6
View File
@@ -27,6 +27,7 @@
let videoEl: HTMLVideoElement | undefined = $state();
let videoInput: HTMLInputElement | undefined = $state();
let projectInput: HTMLInputElement | undefined = $state();
let timeline: Timeline | undefined = $state();
let controller: VideoController | null = null;
let audioEngine = createAudioEngine();
let statusMessage = $state('');
@@ -43,6 +44,7 @@
controller?.destroy();
void audioEngine.init(videoEl).then(() => rescheduleAudio());
controller = attachVideoPlayer(videoEl);
queueMicrotask(() => timeline?.focus());
}
$effect(() => {
@@ -217,6 +219,9 @@
type="button"
class="play-btn"
disabled={!app.videoUrl}
onkeydown={(event) => {
if (event.code === 'Space' && !event.ctrlKey) event.preventDefault();
}}
onclick={() => {
controller?.togglePlayPause();
rescheduleAudio();
@@ -243,6 +248,7 @@
</section>
<Timeline
bind:this={timeline}
onSeek={(time) => {
controller?.seek(time);
rescheduleAudio();
+179 -53
View File
@@ -18,6 +18,8 @@
timeToPx,
} from './math';
const DRAG_THRESHOLD_PX = 4;
interface Props {
onSeek?: (time: number) => void;
onReschedule?: () => void;
@@ -26,12 +28,22 @@
let { onSeek, onReschedule }: Props = $props();
let viewportEl: HTMLDivElement | undefined = $state();
let dragMode = $state<'none' | 'marker' | 'marquee' | 'move'>('none');
let dragMode = $state<'none' | 'marker' | 'marquee' | 'move' | 'scrub'>('none');
let pendingAction = $state<'none' | 'marker' | 'marquee' | 'scrub'>('none');
let dragMarkerId = $state<string | null>(null);
let pointerDownX = 0;
let dragStartX = 0;
let marqueeStart = 0;
let marqueeEnd = 0;
let marqueeStartX = $state(0);
let marqueeEndX = $state(0);
let moveStartTimes = new Map<string, number>();
let didDrag = false;
let isScrubbing = false;
let marqueeAdditive = false;
let capturedPointerId: number | null = null;
export function focus() {
viewportEl?.focus({ preventScroll: true });
}
function updateScrollCenter() {
setScrollLeft(
@@ -42,7 +54,7 @@
$effect(() => {
void app.currentTime;
void app.pixelsPerSecond;
if (app.duration > 0 && viewportEl) updateScrollCenter();
if (app.duration > 0 && viewportEl && !isScrubbing) updateScrollCenter();
});
$effect(() => {
@@ -73,39 +85,114 @@
return event.clientX - rect.left + app.scrollLeft;
}
function timeAtX(x: number): number {
return snapToFrame(
Math.max(0, Math.min(app.duration || Infinity, pxToTime(x, app.pixelsPerSecond))),
);
}
function scrubToX(x: number) {
isScrubbing = true;
onSeek?.(timeAtX(x));
}
function activateDragMode() {
if (dragMode !== 'none' || pendingAction === 'none') return;
if (pendingAction === 'marker' && dragMarkerId) {
dragMode = app.selectedIds.has(dragMarkerId) ? 'move' : 'marker';
return;
}
if (pendingAction === 'marquee') {
dragMode = 'marquee';
return;
}
if (pendingAction === 'scrub') {
dragMode = 'scrub';
isScrubbing = true;
}
}
function handlePointerDown(event: PointerEvent) {
if (!viewportEl) return;
const x = localX(event);
const time = pxToTime(x, app.pixelsPerSecond);
const target = event.target as HTMLElement;
const markerEl = target.closest('[data-marker-id]') as HTMLElement | null;
const inRuler = target.closest('.ruler');
pointerDownX = x;
dragStartX = x;
didDrag = false;
dragMode = 'none';
// Middle-click drag: scrub playhead anywhere on the timeline
if (event.button === 1) {
event.preventDefault();
pendingAction = 'scrub';
dragMode = 'scrub';
scrubToX(x);
viewportEl.setPointerCapture(event.pointerId);
capturedPointerId = event.pointerId;
return;
}
if (event.button !== 0) return;
if (markerEl?.dataset.markerId) {
event.preventDefault();
const id = markerEl.dataset.markerId;
toggleSelection(id, event.shiftKey);
dragMode = app.selectedIds.has(id) ? 'move' : 'marker';
const wasSelected = app.selectedIds.has(id);
if (event.shiftKey) {
toggleSelection(id, true);
} else if (!wasSelected) {
setSelectedIds(new Set([id]));
}
pendingAction = 'marker';
dragMarkerId = id;
dragStartX = x;
moveStartTimes = new Map(
app.markers.filter((m) => app.selectedIds.has(m.id)).map((m) => [m.id, m.time]),
);
viewportEl.setPointerCapture(event.pointerId);
capturedPointerId = event.pointerId;
return;
}
if (inRuler) {
pendingAction = 'scrub';
viewportEl.setPointerCapture(event.pointerId);
capturedPointerId = event.pointerId;
return;
}
// Track empty area: box-select (shift adds to existing selection)
marqueeAdditive = event.shiftKey;
if (!event.shiftKey) setSelectedIds(new Set());
dragMode = 'marquee';
marqueeStart = time;
marqueeEnd = time;
pendingAction = 'marquee';
marqueeStartX = x;
marqueeEndX = x;
viewportEl.setPointerCapture(event.pointerId);
capturedPointerId = event.pointerId;
}
function handlePointerMove(event: PointerEvent) {
if (dragMode === 'none' || !viewportEl) return;
if (pendingAction === 'none' || !viewportEl) return;
const x = localX(event);
if (dragMode === 'scrub') {
scrubToX(x);
return;
}
if (!didDrag && Math.abs(x - pointerDownX) >= DRAG_THRESHOLD_PX) {
didDrag = true;
activateDragMode();
}
if (dragMode === 'marquee') {
marqueeEnd = pxToTime(x, app.pixelsPerSecond);
marqueeEndX = x;
return;
}
@@ -121,38 +208,73 @@
}
if (dragMode === 'marker' && dragMarkerId) {
const time = snapToFrame(
Math.max(0, Math.min(app.duration || Infinity, pxToTime(x, app.pixelsPerSecond))),
);
setMarkerTimes(new Map([[dragMarkerId, time]]));
setMarkerTimes(new Map([[dragMarkerId, timeAtX(x)]]));
}
}
function handlePointerUp(event: PointerEvent) {
function finishPointerInteraction(event?: PointerEvent) {
if (pendingAction === 'none') return;
const x = event ? localX(event) : pointerDownX;
const wasScrubbing = isScrubbing;
if (dragMode === 'marquee') {
const minT = Math.min(marqueeStart, marqueeEnd);
const maxT = Math.max(marqueeStart, marqueeEnd);
const minX = Math.min(marqueeStartX, marqueeEndX);
const maxX = Math.max(marqueeStartX, marqueeEndX);
const minT = pxToTime(minX, app.pixelsPerSecond);
const maxT = pxToTime(maxX, app.pixelsPerSecond);
const hits = app.markers.filter((m) => m.time >= minT && m.time <= maxT).map((m) => m.id);
setSelectedIds(new Set(hits));
if (marqueeAdditive) {
const next = new Set(app.selectedIds);
for (const id of hits) next.add(id);
setSelectedIds(next);
} else {
setSelectedIds(new Set(hits));
}
} else if (dragMode === 'none' && pendingAction === 'scrub' && !didDrag) {
onSeek?.(timeAtX(x));
}
if (dragMode === 'move' || dragMode === 'marker') {
onReschedule?.();
}
if (dragMode === 'scrub' || wasScrubbing) {
onReschedule?.();
}
dragMode = 'none';
pendingAction = 'none';
dragMarkerId = null;
moveStartTimes.clear();
viewportEl?.releasePointerCapture(event.pointerId);
didDrag = false;
isScrubbing = false;
marqueeAdditive = false;
if (viewportEl && capturedPointerId !== null && event) {
viewportEl.releasePointerCapture(capturedPointerId);
}
capturedPointerId = null;
if (wasScrubbing && app.duration > 0) {
updateScrollCenter();
}
}
function handleRulerClick(event: MouseEvent) {
if (dragMode !== 'none') return;
const x = localX(event as unknown as PointerEvent);
const time = snapToFrame(
Math.max(0, Math.min(app.duration || Infinity, pxToTime(x, app.pixelsPerSecond))),
);
onSeek?.(time);
function handlePointerUp(event: PointerEvent) {
finishPointerInteraction(event);
}
function handlePointerCancel(event: PointerEvent) {
finishPointerInteraction(event);
}
function handleLostPointerCapture() {
if (pendingAction !== 'none') finishPointerInteraction();
}
function handleContextMenu(event: MouseEvent) {
if (event.button === 1 || isScrubbing) event.preventDefault();
}
function handleZoomInput(event: Event) {
@@ -171,12 +293,8 @@
});
const playheadX = $derived(timeToPx(app.currentTime, app.pixelsPerSecond));
const marqueeVisible = $derived(dragMode === 'marquee');
const marqueeLeft = $derived(timeToPx(Math.min(marqueeStart, marqueeEnd), app.pixelsPerSecond));
const marqueeWidth = $derived(
Math.abs(
timeToPx(marqueeEnd, app.pixelsPerSecond) - timeToPx(marqueeStart, app.pixelsPerSecond),
),
);
const marqueeLeft = $derived(Math.min(marqueeStartX, marqueeEndX));
const marqueeWidth = $derived(Math.abs(marqueeEndX - marqueeStartX));
</script>
<div class="timeline-panel">
@@ -193,20 +311,26 @@
<span class="hint">Alt + scroll to zoom</span>
</div>
<div class="timeline-viewport" bind:this={viewportEl} onwheel={handleWheel}>
<!-- svelte-ignore a11y_no_noninteractive_tabindex -->
<!-- svelte-ignore a11y_no_noninteractive_element_interactions -->
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class="timeline-viewport"
bind:this={viewportEl}
tabindex="-1"
onwheel={handleWheel}
onpointerdown={handlePointerDown}
onpointermove={handlePointerMove}
onpointerup={handlePointerUp}
onpointercancel={handlePointerCancel}
onlostpointercapture={handleLostPointerCapture}
oncontextmenu={handleContextMenu}
>
<div
class="timeline-content"
style="width: {contentWidth}px; transform: translateX({-app.scrollLeft}px);"
>
<!-- svelte-ignore a11y_click_events_have_key_events -->
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class="ruler"
onclick={handleRulerClick}
onpointerdown={handlePointerDown}
onpointermove={handlePointerMove}
onpointerup={handlePointerUp}
>
<div class="ruler">
{#each ticks as tick}
<div class="tick" style="left: {timeToPx(tick, app.pixelsPerSecond)}px">
<span>{formatTime(tick)}</span>
@@ -214,18 +338,13 @@
{/each}
</div>
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class="track"
onpointerdown={handlePointerDown}
onpointermove={handlePointerMove}
onpointerup={handlePointerUp}
>
<div class="track">
{#each app.markers as marker (marker.id)}
<button
type="button"
class="marker"
class:selected={app.selectedIds.has(marker.id)}
tabindex="-1"
data-marker-id={marker.id}
style="left: {timeToPx(marker.time, app.pixelsPerSecond)}px"
title="{marker.key} @ {formatTime(marker.time)}"
@@ -279,6 +398,12 @@
border-radius: 6px;
user-select: none;
touch-action: none;
outline: none;
}
.timeline-viewport:focus-visible {
border-color: #63b3ed;
box-shadow: 0 0 0 1px #63b3ed;
}
.timeline-content {
@@ -292,7 +417,7 @@
height: 28px;
border-bottom: 1px solid #333;
background: #222;
cursor: pointer;
cursor: ew-resize;
}
.tick {
@@ -346,6 +471,7 @@
top: 0;
bottom: 0;
width: 2px;
transform: translateX(-50%);
background: #f56565;
pointer-events: none;
z-index: 2;