fix timeline select & playhead
This commit is contained in:
@@ -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();
|
||||
|
||||
@@ -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 (!event.shiftKey) setSelectedIds(new Set());
|
||||
dragMode = 'marquee';
|
||||
marqueeStart = time;
|
||||
marqueeEnd = time;
|
||||
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());
|
||||
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);
|
||||
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?.();
|
||||
}
|
||||
|
||||
dragMode = 'none';
|
||||
dragMarkerId = null;
|
||||
moveStartTimes.clear();
|
||||
viewportEl?.releasePointerCapture(event.pointerId);
|
||||
if (dragMode === 'scrub' || wasScrubbing) {
|
||||
onReschedule?.();
|
||||
}
|
||||
|
||||
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);
|
||||
dragMode = 'none';
|
||||
pendingAction = 'none';
|
||||
dragMarkerId = null;
|
||||
moveStartTimes.clear();
|
||||
didDrag = false;
|
||||
isScrubbing = false;
|
||||
marqueeAdditive = false;
|
||||
|
||||
if (viewportEl && capturedPointerId !== null && event) {
|
||||
viewportEl.releasePointerCapture(capturedPointerId);
|
||||
}
|
||||
capturedPointerId = null;
|
||||
|
||||
if (wasScrubbing && app.duration > 0) {
|
||||
updateScrollCenter();
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
|
||||
Reference in New Issue
Block a user