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.
@@ -1,7 +1,7 @@
1
1
  <script lang="ts" module>
2
2
  /**
3
- * A named region on the timeline. Rendered as a tinted band; clicking
4
- * it emits `onSegmentClick` with the full segment.
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
- export interface TimelineViewWindow {
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
- // Low-level timeline primitive: a horizontal track with a movable playhead,
25
- // optional view window (zoom range with draggable handles), optional
26
- // segment overlays, and seek/hover/segment-click event callbacks.
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
- // This component is unopinionated about playback — it doesn't know how
29
- // currentTime changes, only how to display it and how to collect user
30
- // seek/hover input. Pair with <TimelineScrubber> (which adds play/pause
31
- // and speed) or drive directly from your own animation loop.
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
- // Units are arbitrary (seconds, frames, steps)TimelineTrack just works
34
- // in the same unit you pass in as `duration`.
47
+ // The component is unopinionated about playbackit 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
- // See the package README for usage examples.
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 in your chosen unit. */
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
- * Optional zoomed view window. When set, only this range is visible on
45
- * the track; clicks and hover map into this window. Bindable.
64
+ * Render the selection range + its handles. Default: true when both
65
+ * selectionStart and selectionEnd are provided.
46
66
  */
47
- viewWindow?: TimelineViewWindow;
48
- /** Highlighted regions rendered as labeled bands. */
67
+ showSelection?: boolean;
68
+ /** Highlighted labeled regions. */
49
69
  segments?: TimelineSegment[];
50
- /** Show draggable handles for the view window when it's set. Default: true. */
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 when the user drags a view window handle. */
57
- onViewWindowChange?: (window: TimelineViewWindow) => void;
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
- onHover?: (time: number | null) => void;
62
- /** Snippet rendered above the hovered-time position. Use for preview tooltips. */
63
- hoverIndicator?: Snippet<[{ time: number; left: number }]>;
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
- viewWindow = $bindable(undefined),
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
- onViewWindowChange,
102
+ onSelectionChange,
103
+ onSelectionCommit,
76
104
  onSegmentClick,
77
- onHover,
78
- hoverIndicator,
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' | 'window-start' | 'window-end'>(null);
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 effectiveStart = $derived(viewWindow?.start ?? 0);
87
- const effectiveEnd = $derived(viewWindow?.end ?? duration);
88
- const effectiveRange = $derived(Math.max(0.0001, effectiveEnd - effectiveStart));
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 - effectiveStart) / effectiveRange) * 100;
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 effectiveStart + pct * effectiveRange;
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 handleTrackPointerDown(event: PointerEvent) {
109
- // Ignore clicks on segment buttons / handles (they handle themselves).
110
- if ((event.target as HTMLElement).closest('[data-skip-seek]')) return;
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
- dragging = 'playhead';
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?.(time);
130
- } else if (dragging === 'window-start' && viewWindow) {
131
- const next = {
132
- start: Math.max(0, Math.min(viewWindow.end - 0.1, time)),
133
- end: viewWindow.end
134
- };
135
- viewWindow = next;
136
- onViewWindowChange?.(next);
137
- } else if (dragging === 'window-end' && viewWindow) {
138
- const next = {
139
- start: viewWindow.start,
140
- end: Math.min(duration, Math.max(viewWindow.start + 0.1, time))
141
- };
142
- viewWindow = next;
143
- onViewWindowChange?.(next);
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
- function handleHandlePointerDown(which: 'window-start' | 'window-end', event: PointerEvent) {
154
- event.stopPropagation();
155
- dragging = which;
156
- document.addEventListener('pointermove', handleDocPointerMove);
157
- document.addEventListener('pointerup', handleDocPointerUp);
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 handleTrackPointerMove(event: PointerEvent) {
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
- onHover?.(time);
246
+ const time = eventToTime(event);
247
+ onHoverTime?.(time);
248
+ }
249
+
250
+ function handleRailPointerEnter() {
251
+ isHovered = true;
166
252
  }
167
253
 
168
- function handleTrackPointerLeave() {
254
+ function handleRailPointerLeave() {
255
+ isHovered = false;
169
256
  if (dragging) return;
170
257
  hoverX = null;
171
- onHover?.(null);
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 effectiveStart + pct * effectiveRange;
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={handleTrackPointerDown}
199
- onpointermove={handleTrackPointerMove}
200
- onpointerleave={handleTrackPointerLeave}
311
+ onpointerdown={handleRailPointerDown}
312
+ onpointermove={handleRailPointerMove}
313
+ onpointerenter={handleRailPointerEnter}
314
+ onpointerleave={handleRailPointerLeave}
315
+ onkeydown={handleKeyDown}
201
316
  role="slider"
202
- aria-valuemin={effectiveStart}
203
- aria-valuemax={effectiveEnd}
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 width = timeToPercent(segment.end) - left}
212
- {#if width > 0 && left < 100 && left + width > 0}
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-seek
333
+ data-skip-rail
216
334
  class="timeline-track__segment"
217
- style:left="{Math.max(0, left)}%"
218
- style:width="{Math.min(100, left + width) - Math.max(0, left)}%"
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.18))'}
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 viewWindow && showViewWindowHandles}
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-seek
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
- style:left="0%"
234
- onpointerdown={(e) => handleHandlePointerDown('window-start', e)}
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="View window start"
237
- aria-valuenow={viewWindow.start}
381
+ aria-label="Selection start"
238
382
  aria-valuemin={0}
239
- aria-valuemax={viewWindow.end}
383
+ aria-valuemax={selectionEnd}
384
+ aria-valuenow={selectionStart}
240
385
  tabindex="0"
241
- ></div>
386
+ >
387
+ <div class="timeline-track__handle-bar"></div>
388
+ </div>
389
+
390
+ <!-- End handle -->
242
391
  <div
243
- data-skip-seek
392
+ data-skip-rail
393
+ data-selection-handle
244
394
  class="timeline-track__handle timeline-track__handle--end"
245
- style:left="100%"
246
- onpointerdown={(e) => handleHandlePointerDown('window-end', e)}
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="View window end"
249
- aria-valuenow={viewWindow.end}
250
- aria-valuemin={viewWindow.start}
251
- aria-valuemax={duration}
402
+ aria-label="Selection end"
403
+ aria-valuemin={selectionStart}
404
+ aria-valuemax={safeDuration}
405
+ aria-valuenow={selectionEnd}
252
406
  tabindex="0"
253
- ></div>
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
- style:left="{timeToPercent(currentTime)}%"
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
- ></div>
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 hoverIndicator && hoveredTime !== null && hoverX !== null}
265
- <div
266
- class="timeline-track__hover-indicator"
267
- style:left="{timeToPercent(hoveredTime)}%"
268
- aria-hidden="true"
269
- >
270
- {@render hoverIndicator({ time: hoveredTime, left: hoverX })}
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: #e5e7eb;
451
+ --timeline-rail-bg: rgba(0, 0, 0, 0.12);
285
452
  --timeline-rail-height: 6px;
286
- --timeline-playhead-color: #ef4444;
287
- --timeline-playhead-width: 2px;
288
- --timeline-handle-color: #6b7280;
289
- --timeline-handle-width: 4px;
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 #3b82f6;
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
- height: var(--timeline-rail-height);
480
+ top: 50%;
303
481
  transform: translateY(-50%);
482
+ height: var(--timeline-rail-height);
304
483
  background: var(--timeline-rail-bg);
305
- border-radius: calc(var(--timeline-rail-height) / 2);
484
+ border-radius: 999px;
306
485
  pointer-events: none;
486
+ transition: height 120ms ease;
307
487
  }
308
488
 
309
- .timeline-track__segment {
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: calc(var(--timeline-rail-height) + 6px);
314
- padding: 0 4px;
315
- border: none;
316
- border-radius: 3px;
317
- cursor: pointer;
318
- font:
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-track__segment:hover {
327
- filter: brightness(0.92);
507
+ .timeline-track.is-active .timeline-track__progress {
508
+ height: var(--timeline-rail-height-active);
328
509
  }
329
510
 
330
- .timeline-track__segment-label {
331
- opacity: 0.75;
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-track__playhead {
335
- position: absolute;
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: 10px;
350
- margin-left: -5px;
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::before {
358
- content: '';
359
- display: block;
360
- width: var(--timeline-handle-width);
361
- height: 100%;
362
- background: var(--timeline-handle-color);
363
- border-radius: 2px;
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 #3b82f6;
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-indicator {
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>