svelte-p5-components 0.4.0 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +10 -0
- package/dist/ActivityBar.svelte +227 -0
- package/dist/ActivityBar.svelte.d.ts +66 -0
- package/dist/ContextMenu.svelte +439 -0
- package/dist/ContextMenu.svelte.d.ts +80 -0
- package/dist/DraggableWindow.svelte +63 -25
- package/dist/EntityToggleList.svelte +175 -21
- package/dist/EntityToggleList.svelte.d.ts +7 -0
- package/dist/SidePanel.svelte +272 -0
- package/dist/SidePanel.svelte.d.ts +59 -0
- package/dist/TimelineScrubber.svelte +223 -70
- package/dist/TimelineScrubber.svelte.d.ts +32 -8
- package/dist/TimelineScrubber.test.svelte.js +4 -3
- package/dist/TimelineTrack.svelte +483 -150
- package/dist/TimelineTrack.svelte.d.ts +33 -18
- package/dist/TimelineTrack.test.svelte.js +70 -9
- package/dist/createMediaPlayback.svelte.d.ts +60 -0
- package/dist/createMediaPlayback.svelte.js +169 -0
- package/dist/createMediaPlayback.test.d.ts +1 -0
- package/dist/createMediaPlayback.test.js +176 -0
- package/dist/index.d.ts +5 -1
- package/dist/index.js +4 -0
- package/package.json +1 -1
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
<script lang="ts" module>
|
|
2
2
|
/**
|
|
3
|
-
* A
|
|
4
|
-
*
|
|
3
|
+
* A labeled band on the track. Rendered as a tinted region; clicking
|
|
4
|
+
* emits `onSegmentClick` with the full segment.
|
|
5
5
|
*/
|
|
6
6
|
export interface TimelineSegment {
|
|
7
7
|
id: string;
|
|
@@ -11,7 +11,12 @@
|
|
|
11
11
|
color?: string;
|
|
12
12
|
}
|
|
13
13
|
|
|
14
|
-
|
|
14
|
+
/**
|
|
15
|
+
* A {start,end} clip range overlaid on the track. When present, the
|
|
16
|
+
* region is highlighted and draggable handles appear at both edges.
|
|
17
|
+
* Use this for Twitch-style clip editing or TE-style filter windows.
|
|
18
|
+
*/
|
|
19
|
+
export interface TimelineSelection {
|
|
15
20
|
start: number;
|
|
16
21
|
end: number;
|
|
17
22
|
}
|
|
@@ -21,74 +26,107 @@
|
|
|
21
26
|
import type { Snippet } from 'svelte';
|
|
22
27
|
import { onDestroy } from 'svelte';
|
|
23
28
|
|
|
24
|
-
//
|
|
25
|
-
//
|
|
26
|
-
//
|
|
29
|
+
// TimelineTrack is a horizontal scrub track for canvas apps. It models
|
|
30
|
+
// three draggable concepts at once:
|
|
31
|
+
//
|
|
32
|
+
// 1. A playhead — where "now" is.
|
|
33
|
+
// 2. A selection range (optional) — clip start/end handles and a
|
|
34
|
+
// tinted band between them. Think Twitch's clip editor, or a
|
|
35
|
+
// filter window in a data-viz dashboard.
|
|
36
|
+
// 3. Labeled segments (optional) — chapters, highlights, etc.
|
|
27
37
|
//
|
|
28
|
-
//
|
|
29
|
-
//
|
|
30
|
-
//
|
|
31
|
-
//
|
|
38
|
+
// Interaction model is YouTube-ish:
|
|
39
|
+
// - Track is normally thin. Hovering expands it vertically and shows
|
|
40
|
+
// a time tooltip + hover guideline.
|
|
41
|
+
// - Clicking the rail seeks.
|
|
42
|
+
// - The playhead is a visible grabbable knob that grows on hover.
|
|
43
|
+
// - Selection handles are visible vertical bars with grip dots;
|
|
44
|
+
// dragging them adjusts start/end. Dragging the tinted band
|
|
45
|
+
// between them shifts the whole selection.
|
|
32
46
|
//
|
|
33
|
-
//
|
|
34
|
-
//
|
|
47
|
+
// The component is unopinionated about playback — it just displays
|
|
48
|
+
// state and surfaces user intent. Pair with <TimelineScrubber> for a
|
|
49
|
+
// full player bar, or drive directly from your own animation loop.
|
|
35
50
|
//
|
|
36
|
-
//
|
|
51
|
+
// Units are arbitrary (seconds, frames, words) — the track treats
|
|
52
|
+
// `duration` and everything derived from it as opaque numbers.
|
|
37
53
|
|
|
38
54
|
interface Props {
|
|
39
|
-
/** Total timeline length
|
|
55
|
+
/** Total timeline length. */
|
|
40
56
|
duration: number;
|
|
41
57
|
/** Current playhead position. Bindable. */
|
|
42
58
|
currentTime?: number;
|
|
59
|
+
/** Selection range start. Bindable. */
|
|
60
|
+
selectionStart?: number;
|
|
61
|
+
/** Selection range end. Bindable. */
|
|
62
|
+
selectionEnd?: number;
|
|
43
63
|
/**
|
|
44
|
-
*
|
|
45
|
-
*
|
|
64
|
+
* Render the selection range + its handles. Default: true when both
|
|
65
|
+
* selectionStart and selectionEnd are provided.
|
|
46
66
|
*/
|
|
47
|
-
|
|
48
|
-
/** Highlighted
|
|
67
|
+
showSelection?: boolean;
|
|
68
|
+
/** Highlighted labeled regions. */
|
|
49
69
|
segments?: TimelineSegment[];
|
|
50
|
-
/**
|
|
51
|
-
showViewWindowHandles?: boolean;
|
|
52
|
-
/** Show a vertical playhead line. Default: true. */
|
|
70
|
+
/** Hide the playhead knob but still let the rail respond to clicks. */
|
|
53
71
|
showPlayhead?: boolean;
|
|
54
72
|
/** Called when the user clicks or drags the playhead. */
|
|
55
73
|
onSeek?: (time: number) => void;
|
|
56
|
-
/** Called
|
|
57
|
-
|
|
74
|
+
/** Called while the user drags a selection handle or the selection band. */
|
|
75
|
+
onSelectionChange?: (selection: TimelineSelection) => void;
|
|
76
|
+
/** Called at the end of a selection interaction (drag release). */
|
|
77
|
+
onSelectionCommit?: (selection: TimelineSelection) => void;
|
|
58
78
|
/** Called when the user clicks a segment band. */
|
|
59
79
|
onSegmentClick?: (segment: TimelineSegment) => void;
|
|
60
80
|
/** Called whenever the mouse moves over or leaves the track. `null` on leave. */
|
|
61
|
-
|
|
62
|
-
/** Snippet rendered above the
|
|
63
|
-
|
|
81
|
+
onHoverTime?: (time: number | null) => void;
|
|
82
|
+
/** Snippet rendered above the hover x position. Use for preview tooltips. */
|
|
83
|
+
hoverPreview?: Snippet<[{ time: number; xPercent: number }]>;
|
|
84
|
+
/**
|
|
85
|
+
* When dragging the selection start handle, also move the playhead to
|
|
86
|
+
* the new start (and fire `onSeek`). Handy for "scrub-to-trim" UIs where
|
|
87
|
+
* the start handle should preview the frame it lands on. Default: false.
|
|
88
|
+
*/
|
|
89
|
+
playheadFollowsSelectionStart?: boolean;
|
|
64
90
|
class?: string;
|
|
65
91
|
}
|
|
66
92
|
|
|
67
93
|
let {
|
|
68
94
|
duration,
|
|
69
95
|
currentTime = $bindable(0),
|
|
70
|
-
|
|
96
|
+
selectionStart = $bindable<number | undefined>(undefined),
|
|
97
|
+
selectionEnd = $bindable<number | undefined>(undefined),
|
|
98
|
+
showSelection = undefined,
|
|
71
99
|
segments = [],
|
|
72
|
-
showViewWindowHandles = true,
|
|
73
100
|
showPlayhead = true,
|
|
74
101
|
onSeek,
|
|
75
|
-
|
|
102
|
+
onSelectionChange,
|
|
103
|
+
onSelectionCommit,
|
|
76
104
|
onSegmentClick,
|
|
77
|
-
|
|
78
|
-
|
|
105
|
+
onHoverTime,
|
|
106
|
+
hoverPreview,
|
|
107
|
+
playheadFollowsSelectionStart = false,
|
|
79
108
|
class: className = ''
|
|
80
109
|
}: Props = $props();
|
|
81
110
|
|
|
82
111
|
let trackEl: HTMLDivElement | null = $state(null);
|
|
83
|
-
let dragging = $state<null | 'playhead' | '
|
|
112
|
+
let dragging = $state<null | 'playhead' | 'selection-start' | 'selection-end' | 'selection-body'>(
|
|
113
|
+
null
|
|
114
|
+
);
|
|
115
|
+
let dragStartTime = $state(0);
|
|
116
|
+
let dragStartSelection = $state<TimelineSelection>({ start: 0, end: 0 });
|
|
84
117
|
let hoverX = $state<number | null>(null);
|
|
118
|
+
let isHovered = $state(false);
|
|
85
119
|
|
|
86
|
-
const
|
|
87
|
-
|
|
88
|
-
|
|
120
|
+
const hasSelection = $derived(
|
|
121
|
+
(showSelection ?? (selectionStart !== undefined && selectionEnd !== undefined)) &&
|
|
122
|
+
selectionStart !== undefined &&
|
|
123
|
+
selectionEnd !== undefined
|
|
124
|
+
);
|
|
125
|
+
|
|
126
|
+
const safeDuration = $derived(Math.max(0.0001, duration));
|
|
89
127
|
|
|
90
128
|
function timeToPercent(t: number): number {
|
|
91
|
-
return ((t
|
|
129
|
+
return Math.max(0, Math.min(100, (t / safeDuration) * 100));
|
|
92
130
|
}
|
|
93
131
|
|
|
94
132
|
function eventToTime(event: PointerEvent): number | null {
|
|
@@ -96,7 +134,7 @@
|
|
|
96
134
|
const rect = trackEl.getBoundingClientRect();
|
|
97
135
|
const x = event.clientX - rect.left;
|
|
98
136
|
const pct = Math.max(0, Math.min(1, x / rect.width));
|
|
99
|
-
return
|
|
137
|
+
return pct * safeDuration;
|
|
100
138
|
}
|
|
101
139
|
|
|
102
140
|
function eventToClientX(event: PointerEvent): number | null {
|
|
@@ -105,16 +143,47 @@
|
|
|
105
143
|
return Math.max(0, Math.min(rect.width, event.clientX - rect.left));
|
|
106
144
|
}
|
|
107
145
|
|
|
108
|
-
function
|
|
109
|
-
|
|
110
|
-
|
|
146
|
+
function clamp(n: number, lo: number, hi: number): number {
|
|
147
|
+
return Math.max(lo, Math.min(hi, n));
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function handleRailPointerDown(event: PointerEvent) {
|
|
151
|
+
// Skip if user grabbed a child that manages its own drag (handles,
|
|
152
|
+
// segments, the playhead knob, or the selection band drag zone).
|
|
153
|
+
if ((event.target as HTMLElement).closest('[data-skip-rail]')) return;
|
|
111
154
|
|
|
112
155
|
const time = eventToTime(event);
|
|
113
156
|
if (time === null) return;
|
|
114
157
|
|
|
115
158
|
currentTime = time;
|
|
116
159
|
onSeek?.(time);
|
|
117
|
-
|
|
160
|
+
startDrag('playhead', event);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function startDrag(
|
|
164
|
+
which: 'playhead' | 'selection-start' | 'selection-end' | 'selection-body',
|
|
165
|
+
event: PointerEvent
|
|
166
|
+
) {
|
|
167
|
+
const time = eventToTime(event);
|
|
168
|
+
if (time === null) return;
|
|
169
|
+
|
|
170
|
+
dragging = which;
|
|
171
|
+
dragStartTime = time;
|
|
172
|
+
dragStartSelection = {
|
|
173
|
+
start: selectionStart ?? 0,
|
|
174
|
+
end: selectionEnd ?? safeDuration
|
|
175
|
+
};
|
|
176
|
+
|
|
177
|
+
if (event.target instanceof Element && 'setPointerCapture' in event.target) {
|
|
178
|
+
try {
|
|
179
|
+
(event.target as Element & { setPointerCapture: (id: number) => void }).setPointerCapture(
|
|
180
|
+
event.pointerId
|
|
181
|
+
);
|
|
182
|
+
} catch {
|
|
183
|
+
/* fall back to document listeners */
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
118
187
|
document.addEventListener('pointermove', handleDocPointerMove);
|
|
119
188
|
document.addEventListener('pointerup', handleDocPointerUp);
|
|
120
189
|
}
|
|
@@ -125,50 +194,68 @@
|
|
|
125
194
|
if (time === null) return;
|
|
126
195
|
|
|
127
196
|
if (dragging === 'playhead') {
|
|
128
|
-
currentTime = time;
|
|
129
|
-
onSeek?.(
|
|
130
|
-
} else if (dragging === '
|
|
131
|
-
const
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
};
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
197
|
+
currentTime = clamp(time, 0, safeDuration);
|
|
198
|
+
onSeek?.(currentTime);
|
|
199
|
+
} else if (dragging === 'selection-start' && hasSelection) {
|
|
200
|
+
const end = selectionEnd as number;
|
|
201
|
+
const next = clamp(time, 0, Math.max(0, end - 0.001));
|
|
202
|
+
selectionStart = next;
|
|
203
|
+
onSelectionChange?.({ start: next, end });
|
|
204
|
+
if (playheadFollowsSelectionStart) {
|
|
205
|
+
currentTime = next;
|
|
206
|
+
onSeek?.(next);
|
|
207
|
+
}
|
|
208
|
+
} else if (dragging === 'selection-end' && hasSelection) {
|
|
209
|
+
const start = selectionStart as number;
|
|
210
|
+
const next = clamp(time, Math.min(safeDuration, start + 0.001), safeDuration);
|
|
211
|
+
selectionEnd = next;
|
|
212
|
+
onSelectionChange?.({ start, end: next });
|
|
213
|
+
} else if (dragging === 'selection-body' && hasSelection) {
|
|
214
|
+
const delta = time - dragStartTime;
|
|
215
|
+
const width = dragStartSelection.end - dragStartSelection.start;
|
|
216
|
+
const nextStart = clamp(dragStartSelection.start + delta, 0, safeDuration - width);
|
|
217
|
+
const nextEnd = nextStart + width;
|
|
218
|
+
selectionStart = nextStart;
|
|
219
|
+
selectionEnd = nextEnd;
|
|
220
|
+
onSelectionChange?.({ start: nextStart, end: nextEnd });
|
|
144
221
|
}
|
|
145
222
|
}
|
|
146
223
|
|
|
147
224
|
function handleDocPointerUp() {
|
|
225
|
+
if (!dragging) return;
|
|
226
|
+
const wasDragging = dragging;
|
|
148
227
|
dragging = null;
|
|
149
228
|
document.removeEventListener('pointermove', handleDocPointerMove);
|
|
150
229
|
document.removeEventListener('pointerup', handleDocPointerUp);
|
|
151
|
-
}
|
|
152
230
|
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
231
|
+
if (
|
|
232
|
+
wasDragging === 'selection-start' ||
|
|
233
|
+
wasDragging === 'selection-end' ||
|
|
234
|
+
wasDragging === 'selection-body'
|
|
235
|
+
) {
|
|
236
|
+
if (selectionStart !== undefined && selectionEnd !== undefined) {
|
|
237
|
+
onSelectionCommit?.({ start: selectionStart, end: selectionEnd });
|
|
238
|
+
}
|
|
239
|
+
}
|
|
158
240
|
}
|
|
159
241
|
|
|
160
|
-
function
|
|
242
|
+
function handleRailPointerMove(event: PointerEvent) {
|
|
161
243
|
if (dragging) return;
|
|
162
244
|
const x = eventToClientX(event);
|
|
163
|
-
const time = eventToTime(event);
|
|
164
245
|
hoverX = x;
|
|
165
|
-
|
|
246
|
+
const time = eventToTime(event);
|
|
247
|
+
onHoverTime?.(time);
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
function handleRailPointerEnter() {
|
|
251
|
+
isHovered = true;
|
|
166
252
|
}
|
|
167
253
|
|
|
168
|
-
function
|
|
254
|
+
function handleRailPointerLeave() {
|
|
255
|
+
isHovered = false;
|
|
169
256
|
if (dragging) return;
|
|
170
257
|
hoverX = null;
|
|
171
|
-
|
|
258
|
+
onHoverTime?.(null);
|
|
172
259
|
}
|
|
173
260
|
|
|
174
261
|
function handleSegmentClick(segment: TimelineSegment, event: MouseEvent) {
|
|
@@ -176,13 +263,38 @@
|
|
|
176
263
|
onSegmentClick?.(segment);
|
|
177
264
|
}
|
|
178
265
|
|
|
266
|
+
function handleKeyDown(event: KeyboardEvent) {
|
|
267
|
+
if (event.key === 'ArrowLeft' || event.key === 'ArrowRight') {
|
|
268
|
+
event.preventDefault();
|
|
269
|
+
const step = event.shiftKey ? safeDuration / 20 : safeDuration / 100;
|
|
270
|
+
const delta = event.key === 'ArrowRight' ? step : -step;
|
|
271
|
+
currentTime = clamp(currentTime + delta, 0, safeDuration);
|
|
272
|
+
onSeek?.(currentTime);
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
|
|
179
276
|
const hoveredTime = $derived.by(() => {
|
|
180
277
|
if (hoverX === null || !trackEl) return null;
|
|
181
278
|
const rect = trackEl.getBoundingClientRect();
|
|
182
279
|
const pct = Math.max(0, Math.min(1, hoverX / rect.width));
|
|
183
|
-
return
|
|
280
|
+
return pct * safeDuration;
|
|
281
|
+
});
|
|
282
|
+
|
|
283
|
+
const hoveredPercent = $derived.by(() => {
|
|
284
|
+
if (hoverX === null || !trackEl) return null;
|
|
285
|
+
const rect = trackEl.getBoundingClientRect();
|
|
286
|
+
return Math.max(0, Math.min(100, (hoverX / rect.width) * 100));
|
|
184
287
|
});
|
|
185
288
|
|
|
289
|
+
const currentPercent = $derived(timeToPercent(currentTime));
|
|
290
|
+
const selectionStartPercent = $derived(
|
|
291
|
+
hasSelection ? timeToPercent(selectionStart as number) : 0
|
|
292
|
+
);
|
|
293
|
+
const selectionEndPercent = $derived(hasSelection ? timeToPercent(selectionEnd as number) : 100);
|
|
294
|
+
|
|
295
|
+
// Track is "active" (expanded) while hovered or dragging.
|
|
296
|
+
const isActive = $derived(isHovered || dragging !== null);
|
|
297
|
+
|
|
186
298
|
onDestroy(() => {
|
|
187
299
|
if (typeof document !== 'undefined') {
|
|
188
300
|
document.removeEventListener('pointermove', handleDocPointerMove);
|
|
@@ -194,30 +306,36 @@
|
|
|
194
306
|
<div
|
|
195
307
|
bind:this={trackEl}
|
|
196
308
|
class="timeline-track {className}"
|
|
309
|
+
class:is-active={isActive}
|
|
197
310
|
class:is-dragging={dragging !== null}
|
|
198
|
-
onpointerdown={
|
|
199
|
-
onpointermove={
|
|
200
|
-
|
|
311
|
+
onpointerdown={handleRailPointerDown}
|
|
312
|
+
onpointermove={handleRailPointerMove}
|
|
313
|
+
onpointerenter={handleRailPointerEnter}
|
|
314
|
+
onpointerleave={handleRailPointerLeave}
|
|
315
|
+
onkeydown={handleKeyDown}
|
|
201
316
|
role="slider"
|
|
202
|
-
aria-valuemin={
|
|
203
|
-
aria-valuemax={
|
|
317
|
+
aria-valuemin={0}
|
|
318
|
+
aria-valuemax={safeDuration}
|
|
204
319
|
aria-valuenow={currentTime}
|
|
205
320
|
tabindex="0"
|
|
206
321
|
>
|
|
207
322
|
<div class="timeline-track__rail"></div>
|
|
208
323
|
|
|
324
|
+
<div class="timeline-track__progress" style:width="{currentPercent}%" aria-hidden="true"></div>
|
|
325
|
+
|
|
209
326
|
{#each segments as segment (segment.id)}
|
|
210
327
|
{@const left = timeToPercent(segment.start)}
|
|
211
|
-
{@const
|
|
212
|
-
{
|
|
328
|
+
{@const right = timeToPercent(segment.end)}
|
|
329
|
+
{@const width = right - left}
|
|
330
|
+
{#if width > 0}
|
|
213
331
|
<button
|
|
214
332
|
type="button"
|
|
215
|
-
data-skip-
|
|
333
|
+
data-skip-rail
|
|
216
334
|
class="timeline-track__segment"
|
|
217
|
-
style:left="{
|
|
218
|
-
style:width="{
|
|
335
|
+
style:left="{left}%"
|
|
336
|
+
style:width="{width}%"
|
|
219
337
|
style:background-color={segment.color ??
|
|
220
|
-
'var(--timeline-segment-bg, rgba(59, 130, 246, 0.
|
|
338
|
+
'var(--timeline-segment-bg, rgba(59, 130, 246, 0.22))'}
|
|
221
339
|
title={segment.label ?? `${segment.start.toFixed(1)}–${segment.end.toFixed(1)}`}
|
|
222
340
|
onclick={(e) => handleSegmentClick(segment, e)}
|
|
223
341
|
>
|
|
@@ -226,48 +344,95 @@
|
|
|
226
344
|
{/if}
|
|
227
345
|
{/each}
|
|
228
346
|
|
|
229
|
-
{#if
|
|
347
|
+
{#if hasSelection}
|
|
348
|
+
{@const selLeft = selectionStartPercent}
|
|
349
|
+
{@const selWidth = Math.max(0, selectionEndPercent - selectionStartPercent)}
|
|
350
|
+
|
|
351
|
+
<!-- Selection band. Draggable as a whole when grabbed away from the handles/playhead. -->
|
|
230
352
|
<div
|
|
231
|
-
data-skip-
|
|
353
|
+
data-skip-rail
|
|
354
|
+
class="timeline-track__selection"
|
|
355
|
+
class:is-dragging={dragging === 'selection-body'}
|
|
356
|
+
style:left="{selLeft}%"
|
|
357
|
+
style:width="{selWidth}%"
|
|
358
|
+
onpointerdown={(e) => {
|
|
359
|
+
// Don't start a body-drag if the user clicked on a child that
|
|
360
|
+
// handles its own drag (the two handles).
|
|
361
|
+
if ((e.target as HTMLElement).closest('[data-selection-handle]')) return;
|
|
362
|
+
// Also don't body-drag if shift-clicking the playhead area.
|
|
363
|
+
e.stopPropagation();
|
|
364
|
+
startDrag('selection-body', e);
|
|
365
|
+
}}
|
|
366
|
+
aria-hidden="true"
|
|
367
|
+
></div>
|
|
368
|
+
|
|
369
|
+
<!-- Start handle -->
|
|
370
|
+
<div
|
|
371
|
+
data-skip-rail
|
|
372
|
+
data-selection-handle
|
|
232
373
|
class="timeline-track__handle timeline-track__handle--start"
|
|
233
|
-
|
|
234
|
-
|
|
374
|
+
class:is-dragging={dragging === 'selection-start'}
|
|
375
|
+
style:left="{selectionStartPercent}%"
|
|
376
|
+
onpointerdown={(e) => {
|
|
377
|
+
e.stopPropagation();
|
|
378
|
+
startDrag('selection-start', e);
|
|
379
|
+
}}
|
|
235
380
|
role="slider"
|
|
236
|
-
aria-label="
|
|
237
|
-
aria-valuenow={viewWindow.start}
|
|
381
|
+
aria-label="Selection start"
|
|
238
382
|
aria-valuemin={0}
|
|
239
|
-
aria-valuemax={
|
|
383
|
+
aria-valuemax={selectionEnd}
|
|
384
|
+
aria-valuenow={selectionStart}
|
|
240
385
|
tabindex="0"
|
|
241
|
-
|
|
386
|
+
>
|
|
387
|
+
<div class="timeline-track__handle-bar"></div>
|
|
388
|
+
</div>
|
|
389
|
+
|
|
390
|
+
<!-- End handle -->
|
|
242
391
|
<div
|
|
243
|
-
data-skip-
|
|
392
|
+
data-skip-rail
|
|
393
|
+
data-selection-handle
|
|
244
394
|
class="timeline-track__handle timeline-track__handle--end"
|
|
245
|
-
|
|
246
|
-
|
|
395
|
+
class:is-dragging={dragging === 'selection-end'}
|
|
396
|
+
style:left="{selectionEndPercent}%"
|
|
397
|
+
onpointerdown={(e) => {
|
|
398
|
+
e.stopPropagation();
|
|
399
|
+
startDrag('selection-end', e);
|
|
400
|
+
}}
|
|
247
401
|
role="slider"
|
|
248
|
-
aria-label="
|
|
249
|
-
aria-
|
|
250
|
-
aria-
|
|
251
|
-
aria-
|
|
402
|
+
aria-label="Selection end"
|
|
403
|
+
aria-valuemin={selectionStart}
|
|
404
|
+
aria-valuemax={safeDuration}
|
|
405
|
+
aria-valuenow={selectionEnd}
|
|
252
406
|
tabindex="0"
|
|
253
|
-
|
|
407
|
+
>
|
|
408
|
+
<div class="timeline-track__handle-bar"></div>
|
|
409
|
+
</div>
|
|
254
410
|
{/if}
|
|
255
411
|
|
|
256
412
|
{#if showPlayhead}
|
|
257
413
|
<div
|
|
414
|
+
data-skip-rail
|
|
258
415
|
class="timeline-track__playhead"
|
|
259
|
-
|
|
416
|
+
class:is-dragging={dragging === 'playhead'}
|
|
417
|
+
style:left="{currentPercent}%"
|
|
418
|
+
onpointerdown={(e) => {
|
|
419
|
+
e.stopPropagation();
|
|
420
|
+
startDrag('playhead', e);
|
|
421
|
+
}}
|
|
260
422
|
aria-hidden="true"
|
|
261
|
-
|
|
423
|
+
>
|
|
424
|
+
<div class="timeline-track__playhead-line"></div>
|
|
425
|
+
<div class="timeline-track__playhead-knob"></div>
|
|
426
|
+
</div>
|
|
262
427
|
{/if}
|
|
263
428
|
|
|
264
|
-
{#if
|
|
265
|
-
<div
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
>
|
|
270
|
-
{@render
|
|
429
|
+
{#if hoveredPercent !== null && dragging === null}
|
|
430
|
+
<div class="timeline-track__hover-line" style:left="{hoveredPercent}%" aria-hidden="true"></div>
|
|
431
|
+
{/if}
|
|
432
|
+
|
|
433
|
+
{#if hoverPreview && hoveredTime !== null && hoveredPercent !== null}
|
|
434
|
+
<div class="timeline-track__hover-preview" style:left="{hoveredPercent}%" aria-hidden="true">
|
|
435
|
+
{@render hoverPreview({ time: hoveredTime, xPercent: hoveredPercent })}
|
|
271
436
|
</div>
|
|
272
437
|
{/if}
|
|
273
438
|
</div>
|
|
@@ -280,97 +445,265 @@
|
|
|
280
445
|
user-select: none;
|
|
281
446
|
touch-action: none;
|
|
282
447
|
cursor: pointer;
|
|
448
|
+
display: flex;
|
|
449
|
+
align-items: center;
|
|
283
450
|
|
|
284
|
-
--timeline-rail-bg:
|
|
451
|
+
--timeline-rail-bg: rgba(0, 0, 0, 0.12);
|
|
285
452
|
--timeline-rail-height: 6px;
|
|
286
|
-
--timeline-
|
|
287
|
-
--timeline-
|
|
288
|
-
--timeline-
|
|
289
|
-
--timeline-
|
|
453
|
+
--timeline-rail-height-active: 12px;
|
|
454
|
+
--timeline-accent: #ef4444;
|
|
455
|
+
--timeline-accent-hover: #dc2626;
|
|
456
|
+
--timeline-selection-bg: rgba(100, 116, 139, 0.22);
|
|
457
|
+
--timeline-playhead-size: 14px;
|
|
458
|
+
--timeline-playhead-size-active: 20px;
|
|
459
|
+
--timeline-handle-hit-width: 22px;
|
|
460
|
+
--timeline-handle-bar-width: 5px;
|
|
461
|
+
|
|
462
|
+
/* Contrast ring drawn around the playhead knob so it reads against any
|
|
463
|
+
rail/segment color. Light-mode fallback; consuming apps override for
|
|
464
|
+
dark mode. */
|
|
465
|
+
--timeline-knob-ring: #ffffff;
|
|
466
|
+
/* Grip dots inset into the selection handle bars. */
|
|
467
|
+
--timeline-handle-dot: rgba(255, 255, 255, 0.85);
|
|
290
468
|
}
|
|
291
469
|
|
|
292
470
|
.timeline-track:focus-visible {
|
|
293
|
-
outline: 2px solid
|
|
471
|
+
outline: 2px solid var(--timeline-accent);
|
|
294
472
|
outline-offset: 2px;
|
|
473
|
+
border-radius: 4px;
|
|
295
474
|
}
|
|
296
475
|
|
|
297
476
|
.timeline-track__rail {
|
|
298
477
|
position: absolute;
|
|
299
|
-
top: 50%;
|
|
300
478
|
left: 0;
|
|
301
479
|
right: 0;
|
|
302
|
-
|
|
480
|
+
top: 50%;
|
|
303
481
|
transform: translateY(-50%);
|
|
482
|
+
height: var(--timeline-rail-height);
|
|
304
483
|
background: var(--timeline-rail-bg);
|
|
305
|
-
border-radius:
|
|
484
|
+
border-radius: 999px;
|
|
306
485
|
pointer-events: none;
|
|
486
|
+
transition: height 120ms ease;
|
|
307
487
|
}
|
|
308
488
|
|
|
309
|
-
.timeline-
|
|
489
|
+
.timeline-track.is-active .timeline-track__rail {
|
|
490
|
+
height: var(--timeline-rail-height-active);
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
/* Played-progress fill: 0 → currentTime, sits on top of the rail. */
|
|
494
|
+
.timeline-track__progress {
|
|
310
495
|
position: absolute;
|
|
496
|
+
left: 0;
|
|
311
497
|
top: 50%;
|
|
312
498
|
transform: translateY(-50%);
|
|
313
|
-
height:
|
|
314
|
-
|
|
315
|
-
border:
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
10px/1 system-ui,
|
|
320
|
-
sans-serif;
|
|
321
|
-
color: #1f2937;
|
|
322
|
-
overflow: hidden;
|
|
323
|
-
white-space: nowrap;
|
|
499
|
+
height: var(--timeline-rail-height);
|
|
500
|
+
background: var(--timeline-accent);
|
|
501
|
+
border-radius: 999px 0 0 999px;
|
|
502
|
+
pointer-events: none;
|
|
503
|
+
transition: height 120ms ease;
|
|
504
|
+
max-width: 100%;
|
|
324
505
|
}
|
|
325
506
|
|
|
326
|
-
.timeline-
|
|
327
|
-
|
|
507
|
+
.timeline-track.is-active .timeline-track__progress {
|
|
508
|
+
height: var(--timeline-rail-height-active);
|
|
328
509
|
}
|
|
329
510
|
|
|
330
|
-
.
|
|
331
|
-
|
|
511
|
+
/* Selection band: tinted slab between the handles. Drag-offsets the range.
|
|
512
|
+
Uses a neutral slate tint so it doesn't compete with the red progress
|
|
513
|
+
fill — the metaphors are different (selection = filter range; progress
|
|
514
|
+
= playhead position). */
|
|
515
|
+
.timeline-track__selection {
|
|
516
|
+
position: absolute;
|
|
517
|
+
top: 50%;
|
|
518
|
+
transform: translateY(-50%);
|
|
519
|
+
height: calc(var(--timeline-rail-height) + 4px);
|
|
520
|
+
background: var(--timeline-selection-bg);
|
|
521
|
+
border-radius: 3px;
|
|
522
|
+
cursor: grab;
|
|
523
|
+
transition: height 120ms ease;
|
|
524
|
+
z-index: 1;
|
|
332
525
|
}
|
|
333
526
|
|
|
334
|
-
.timeline-
|
|
335
|
-
|
|
336
|
-
top: 15%;
|
|
337
|
-
bottom: 15%;
|
|
338
|
-
width: var(--timeline-playhead-width);
|
|
339
|
-
margin-left: calc(var(--timeline-playhead-width) / -2);
|
|
340
|
-
background: var(--timeline-playhead-color);
|
|
341
|
-
pointer-events: none;
|
|
342
|
-
border-radius: 1px;
|
|
527
|
+
.timeline-track.is-active .timeline-track__selection {
|
|
528
|
+
height: calc(var(--timeline-rail-height-active) + 4px);
|
|
343
529
|
}
|
|
344
530
|
|
|
531
|
+
.timeline-track__selection.is-dragging {
|
|
532
|
+
cursor: grabbing;
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
/* Handles: a wide transparent hit zone with a centered visible bar + grip dots. */
|
|
345
536
|
.timeline-track__handle {
|
|
346
537
|
position: absolute;
|
|
347
538
|
top: 0;
|
|
348
539
|
bottom: 0;
|
|
349
|
-
width:
|
|
350
|
-
margin-left: -
|
|
540
|
+
width: var(--timeline-handle-hit-width);
|
|
541
|
+
margin-left: calc(var(--timeline-handle-hit-width) / -2);
|
|
351
542
|
cursor: ew-resize;
|
|
352
543
|
display: flex;
|
|
353
544
|
align-items: center;
|
|
354
545
|
justify-content: center;
|
|
546
|
+
z-index: 3;
|
|
355
547
|
}
|
|
356
548
|
|
|
357
|
-
.timeline-track__handle
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
549
|
+
.timeline-track__handle-bar {
|
|
550
|
+
width: var(--timeline-handle-bar-width);
|
|
551
|
+
height: var(--timeline-rail-height-active);
|
|
552
|
+
background-color: var(--timeline-accent);
|
|
553
|
+
/* A vertical column of subtle grip dots, centered on the bar, so the
|
|
554
|
+
handle reads as something to grab rather than a plain stripe. */
|
|
555
|
+
background-image: radial-gradient(
|
|
556
|
+
circle at center,
|
|
557
|
+
var(--timeline-handle-dot) 0 1px,
|
|
558
|
+
transparent 1.4px
|
|
559
|
+
);
|
|
560
|
+
background-repeat: repeat-y;
|
|
561
|
+
background-position: center;
|
|
562
|
+
background-size: 100% 6px;
|
|
563
|
+
border-radius: 999px;
|
|
564
|
+
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.25);
|
|
565
|
+
opacity: 0;
|
|
566
|
+
transition:
|
|
567
|
+
height 120ms ease,
|
|
568
|
+
width 120ms ease,
|
|
569
|
+
opacity 120ms ease,
|
|
570
|
+
box-shadow 120ms ease;
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
.timeline-track.is-active .timeline-track__handle-bar {
|
|
574
|
+
opacity: 1;
|
|
575
|
+
height: calc(var(--timeline-rail-height-active) + 8px);
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
.timeline-track__handle:hover .timeline-track__handle-bar,
|
|
579
|
+
.timeline-track__handle.is-dragging .timeline-track__handle-bar {
|
|
580
|
+
opacity: 1;
|
|
581
|
+
height: calc(var(--timeline-rail-height-active) + 14px);
|
|
582
|
+
width: calc(var(--timeline-handle-bar-width) + 1px);
|
|
364
583
|
}
|
|
365
584
|
|
|
366
585
|
.timeline-track__handle:focus-visible {
|
|
367
|
-
outline: 2px solid
|
|
586
|
+
outline: 2px solid var(--timeline-accent);
|
|
368
587
|
outline-offset: 2px;
|
|
588
|
+
border-radius: 2px;
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
/* Playhead: a vertical line with a circular knob at the current position.
|
|
592
|
+
The knob grows on hover/drag; the line fades in with the active state. */
|
|
593
|
+
.timeline-track__playhead {
|
|
594
|
+
position: absolute;
|
|
595
|
+
top: 0;
|
|
596
|
+
bottom: 0;
|
|
597
|
+
left: 0;
|
|
598
|
+
transform: translateX(-50%);
|
|
599
|
+
width: var(--timeline-playhead-size-active);
|
|
600
|
+
display: flex;
|
|
601
|
+
align-items: center;
|
|
602
|
+
justify-content: center;
|
|
603
|
+
z-index: 4;
|
|
604
|
+
cursor: grab;
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
.timeline-track__playhead.is-dragging {
|
|
608
|
+
cursor: grabbing;
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
.timeline-track__playhead-line {
|
|
612
|
+
position: absolute;
|
|
613
|
+
top: 10%;
|
|
614
|
+
bottom: 10%;
|
|
615
|
+
left: 50%;
|
|
616
|
+
transform: translateX(-50%);
|
|
617
|
+
width: 2px;
|
|
618
|
+
background: var(--timeline-accent);
|
|
619
|
+
opacity: 0;
|
|
620
|
+
transition: opacity 120ms ease;
|
|
621
|
+
pointer-events: none;
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
.timeline-track.is-active .timeline-track__playhead-line,
|
|
625
|
+
.timeline-track__playhead:hover .timeline-track__playhead-line {
|
|
626
|
+
opacity: 0.85;
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
.timeline-track__playhead-knob {
|
|
630
|
+
position: relative;
|
|
631
|
+
width: var(--timeline-playhead-size);
|
|
632
|
+
height: var(--timeline-playhead-size);
|
|
633
|
+
border-radius: 50%;
|
|
634
|
+
background: var(--timeline-accent);
|
|
635
|
+
/* A contrast ring (border) plus a soft drop shadow so the knob lifts off
|
|
636
|
+
the rail. The inset white ring keeps it legible over the red progress
|
|
637
|
+
fill it sits on top of. */
|
|
638
|
+
border: 2px solid var(--timeline-knob-ring);
|
|
639
|
+
box-shadow:
|
|
640
|
+
0 1px 3px rgba(0, 0, 0, 0.28),
|
|
641
|
+
0 0 0 1px rgba(0, 0, 0, 0.06);
|
|
642
|
+
box-sizing: border-box;
|
|
643
|
+
transition:
|
|
644
|
+
width 110ms cubic-bezier(0.2, 0, 0, 1),
|
|
645
|
+
height 110ms cubic-bezier(0.2, 0, 0, 1),
|
|
646
|
+
background-color 120ms ease,
|
|
647
|
+
box-shadow 120ms ease;
|
|
648
|
+
}
|
|
649
|
+
|
|
650
|
+
.timeline-track__playhead:hover .timeline-track__playhead-knob,
|
|
651
|
+
.timeline-track__playhead.is-dragging .timeline-track__playhead-knob {
|
|
652
|
+
background: var(--timeline-accent-hover);
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
.timeline-track__playhead:hover .timeline-track__playhead-knob,
|
|
656
|
+
.timeline-track__playhead.is-dragging .timeline-track__playhead-knob,
|
|
657
|
+
.timeline-track.is-active .timeline-track__playhead-knob {
|
|
658
|
+
width: var(--timeline-playhead-size-active);
|
|
659
|
+
height: var(--timeline-playhead-size-active);
|
|
660
|
+
box-shadow:
|
|
661
|
+
0 3px 8px rgba(0, 0, 0, 0.38),
|
|
662
|
+
0 0 0 1px rgba(0, 0, 0, 0.06);
|
|
369
663
|
}
|
|
370
664
|
|
|
371
|
-
.timeline-track__hover-
|
|
665
|
+
.timeline-track__hover-line {
|
|
666
|
+
position: absolute;
|
|
667
|
+
top: 20%;
|
|
668
|
+
bottom: 20%;
|
|
669
|
+
width: 1px;
|
|
670
|
+
margin-left: -0.5px;
|
|
671
|
+
background: rgba(0, 0, 0, 0.35);
|
|
672
|
+
pointer-events: none;
|
|
673
|
+
z-index: 2;
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
.timeline-track__hover-preview {
|
|
372
677
|
position: absolute;
|
|
373
678
|
bottom: 100%;
|
|
679
|
+
transform: translateX(-50%);
|
|
374
680
|
pointer-events: none;
|
|
681
|
+
z-index: 10;
|
|
682
|
+
}
|
|
683
|
+
|
|
684
|
+
.timeline-track__segment {
|
|
685
|
+
position: absolute;
|
|
686
|
+
top: 50%;
|
|
687
|
+
transform: translateY(-50%);
|
|
688
|
+
height: calc(var(--timeline-rail-height-active) + 4px);
|
|
689
|
+
padding: 0 4px;
|
|
690
|
+
border: none;
|
|
691
|
+
border-radius: 3px;
|
|
692
|
+
cursor: pointer;
|
|
693
|
+
font:
|
|
694
|
+
10px/1 system-ui,
|
|
695
|
+
sans-serif;
|
|
696
|
+
color: #1f2937;
|
|
697
|
+
overflow: hidden;
|
|
698
|
+
white-space: nowrap;
|
|
699
|
+
z-index: 1;
|
|
700
|
+
}
|
|
701
|
+
|
|
702
|
+
.timeline-track__segment:hover {
|
|
703
|
+
filter: brightness(0.92);
|
|
704
|
+
}
|
|
705
|
+
|
|
706
|
+
.timeline-track__segment-label {
|
|
707
|
+
opacity: 0.8;
|
|
375
708
|
}
|
|
376
709
|
</style>
|