svelte-p5-components 0.3.0 → 0.4.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/dist/CanvasFrame.svelte +129 -0
- package/dist/CanvasFrame.svelte.d.ts +52 -0
- package/dist/CanvasFrame.test.svelte.d.ts +1 -0
- package/dist/CanvasFrame.test.svelte.js +60 -0
- package/dist/EntityToggleList.svelte +269 -0
- package/dist/EntityToggleList.svelte.d.ts +33 -0
- package/dist/EntityToggleList.test.svelte.d.ts +1 -0
- package/dist/EntityToggleList.test.svelte.js +69 -0
- package/dist/HoverTooltip.svelte +184 -0
- package/dist/HoverTooltip.svelte.d.ts +22 -0
- package/dist/HoverTooltip.test.svelte.d.ts +1 -0
- package/dist/HoverTooltip.test.svelte.js +66 -0
- package/dist/SplitPane.svelte +280 -0
- package/dist/SplitPane.svelte.d.ts +48 -0
- package/dist/SplitPane.test.svelte.d.ts +1 -0
- package/dist/SplitPane.test.svelte.js +85 -0
- package/dist/TimelineScrubber.svelte +214 -0
- package/dist/TimelineScrubber.svelte.d.ts +32 -0
- package/dist/TimelineScrubber.test.svelte.d.ts +1 -0
- package/dist/TimelineScrubber.test.svelte.js +66 -0
- package/dist/TimelineTrack.svelte +376 -0
- package/dist/TimelineTrack.svelte.d.ts +50 -0
- package/dist/TimelineTrack.test.svelte.d.ts +1 -0
- package/dist/TimelineTrack.test.svelte.js +72 -0
- package/dist/createMediaSync.svelte.d.ts +53 -0
- package/dist/createMediaSync.svelte.js +115 -0
- package/dist/index.d.ts +7 -0
- package/dist/index.js +7 -0
- package/dist/tests/setup.d.ts +1 -0
- package/dist/tests/setup.js +26 -0
- package/package.json +12 -3
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import type { Snippet } from 'svelte';
|
|
3
|
+
import TimelineTrack, {
|
|
4
|
+
type TimelineSegment,
|
|
5
|
+
type TimelineViewWindow
|
|
6
|
+
} from './TimelineTrack.svelte';
|
|
7
|
+
|
|
8
|
+
// Full timeline widget: scrub track + play/pause + speed picker + time
|
|
9
|
+
// display. For apps that want the standard canvas-app timeline shape
|
|
10
|
+
// without reinventing controls.
|
|
11
|
+
//
|
|
12
|
+
// TimelineScrubber layers on top of <TimelineTrack> — reach for the track
|
|
13
|
+
// directly if you want custom chrome around it.
|
|
14
|
+
//
|
|
15
|
+
// Bindable: currentTime, isPlaying, speed, viewWindow. The consumer owns
|
|
16
|
+
// the animation loop that advances currentTime when isPlaying is true —
|
|
17
|
+
// this component just displays state and surfaces user intent.
|
|
18
|
+
//
|
|
19
|
+
// `speedLocked` (with an optional reason) is the UX-disclosure hook for
|
|
20
|
+
// video-driven playback, where the speed multiplier is effectively
|
|
21
|
+
// ignored. Set it to true and the speed button renders muted with a
|
|
22
|
+
// title-tip.
|
|
23
|
+
//
|
|
24
|
+
// See the package README for usage examples.
|
|
25
|
+
|
|
26
|
+
interface Props {
|
|
27
|
+
duration: number;
|
|
28
|
+
currentTime?: number;
|
|
29
|
+
isPlaying?: boolean;
|
|
30
|
+
speed?: number;
|
|
31
|
+
/** Available speed presets. Default: [1, 2, 4, 8, 16]. */
|
|
32
|
+
speedOptions?: number[];
|
|
33
|
+
/** Render speed as disabled / muted. Use when video playback overrides it. */
|
|
34
|
+
speedLocked?: boolean;
|
|
35
|
+
/** Title/tooltip shown when `speedLocked` is true. */
|
|
36
|
+
speedLockedReason?: string;
|
|
37
|
+
/** Optional zoomed view window; bindable. */
|
|
38
|
+
viewWindow?: TimelineViewWindow;
|
|
39
|
+
segments?: TimelineSegment[];
|
|
40
|
+
/** Format `currentTime` and `duration` for display. Default: mm:ss. */
|
|
41
|
+
formatTime?: (seconds: number) => string;
|
|
42
|
+
onSeek?: (time: number) => void;
|
|
43
|
+
onPlayToggle?: (nowPlaying: boolean) => void;
|
|
44
|
+
onSpeedChange?: (speed: number) => void;
|
|
45
|
+
onSegmentClick?: (segment: TimelineSegment) => void;
|
|
46
|
+
/** Snippet rendered above the hovered time, e.g. for a turn-preview tooltip. */
|
|
47
|
+
hoverIndicator?: Snippet<[{ time: number; left: number }]>;
|
|
48
|
+
class?: string;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
let {
|
|
52
|
+
duration,
|
|
53
|
+
currentTime = $bindable(0),
|
|
54
|
+
isPlaying = $bindable(false),
|
|
55
|
+
speed = $bindable(1),
|
|
56
|
+
speedOptions = [1, 2, 4, 8, 16],
|
|
57
|
+
speedLocked = false,
|
|
58
|
+
speedLockedReason,
|
|
59
|
+
viewWindow = $bindable(undefined),
|
|
60
|
+
segments = [],
|
|
61
|
+
formatTime = defaultFormatTime,
|
|
62
|
+
onSeek,
|
|
63
|
+
onPlayToggle,
|
|
64
|
+
onSpeedChange,
|
|
65
|
+
onSegmentClick,
|
|
66
|
+
hoverIndicator,
|
|
67
|
+
class: className = ''
|
|
68
|
+
}: Props = $props();
|
|
69
|
+
|
|
70
|
+
function defaultFormatTime(seconds: number): string {
|
|
71
|
+
if (!isFinite(seconds)) return '00:00';
|
|
72
|
+
const m = Math.floor(seconds / 60);
|
|
73
|
+
const s = Math.floor(seconds % 60);
|
|
74
|
+
return `${String(m).padStart(2, '0')}:${String(s).padStart(2, '0')}`;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function togglePlay() {
|
|
78
|
+
isPlaying = !isPlaying;
|
|
79
|
+
onPlayToggle?.(isPlaying);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function cycleSpeed() {
|
|
83
|
+
if (speedLocked) return;
|
|
84
|
+
if (speedOptions.length === 0) return;
|
|
85
|
+
const idx = speedOptions.indexOf(speed);
|
|
86
|
+
const next = speedOptions[(idx + 1) % speedOptions.length] ?? speedOptions[0] ?? 1;
|
|
87
|
+
speed = next;
|
|
88
|
+
onSpeedChange?.(next);
|
|
89
|
+
}
|
|
90
|
+
</script>
|
|
91
|
+
|
|
92
|
+
<div class="timeline-scrubber {className}">
|
|
93
|
+
<div class="timeline-scrubber__track">
|
|
94
|
+
<span class="timeline-scrubber__time" aria-label="start"
|
|
95
|
+
>{formatTime(viewWindow?.start ?? 0)}</span
|
|
96
|
+
>
|
|
97
|
+
<TimelineTrack
|
|
98
|
+
{duration}
|
|
99
|
+
bind:currentTime
|
|
100
|
+
bind:viewWindow
|
|
101
|
+
{segments}
|
|
102
|
+
{onSeek}
|
|
103
|
+
{onSegmentClick}
|
|
104
|
+
{hoverIndicator}
|
|
105
|
+
/>
|
|
106
|
+
<span class="timeline-scrubber__time" aria-label="end">
|
|
107
|
+
{formatTime(viewWindow?.end ?? duration)}
|
|
108
|
+
</span>
|
|
109
|
+
</div>
|
|
110
|
+
|
|
111
|
+
<div class="timeline-scrubber__controls">
|
|
112
|
+
<button
|
|
113
|
+
type="button"
|
|
114
|
+
class="timeline-scrubber__btn"
|
|
115
|
+
aria-pressed={isPlaying}
|
|
116
|
+
aria-label={isPlaying ? 'Pause' : 'Play'}
|
|
117
|
+
onclick={togglePlay}
|
|
118
|
+
>
|
|
119
|
+
{isPlaying ? '❚❚' : '▶'}
|
|
120
|
+
</button>
|
|
121
|
+
|
|
122
|
+
<button
|
|
123
|
+
type="button"
|
|
124
|
+
class="timeline-scrubber__btn timeline-scrubber__btn--speed"
|
|
125
|
+
class:is-locked={speedLocked}
|
|
126
|
+
aria-label="Playback speed: {speed}x{speedLocked
|
|
127
|
+
? ` (locked${speedLockedReason ? ': ' + speedLockedReason : ''})`
|
|
128
|
+
: ''}"
|
|
129
|
+
title={speedLocked ? (speedLockedReason ?? 'Speed locked') : 'Click to change speed'}
|
|
130
|
+
disabled={speedLocked}
|
|
131
|
+
onclick={cycleSpeed}
|
|
132
|
+
>
|
|
133
|
+
{speed}×{speedLocked ? ' ·' : ''}
|
|
134
|
+
</button>
|
|
135
|
+
|
|
136
|
+
<span class="timeline-scrubber__currtime" aria-label="Current time">
|
|
137
|
+
{formatTime(currentTime)}
|
|
138
|
+
</span>
|
|
139
|
+
</div>
|
|
140
|
+
</div>
|
|
141
|
+
|
|
142
|
+
<style>
|
|
143
|
+
.timeline-scrubber {
|
|
144
|
+
display: flex;
|
|
145
|
+
flex-direction: column;
|
|
146
|
+
gap: 4px;
|
|
147
|
+
font:
|
|
148
|
+
12px/1 system-ui,
|
|
149
|
+
-apple-system,
|
|
150
|
+
Segoe UI,
|
|
151
|
+
sans-serif;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
.timeline-scrubber__track {
|
|
155
|
+
display: flex;
|
|
156
|
+
align-items: center;
|
|
157
|
+
gap: 8px;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
.timeline-scrubber__track :global(.timeline-track) {
|
|
161
|
+
flex: 1 1 auto;
|
|
162
|
+
min-width: 0;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
.timeline-scrubber__time {
|
|
166
|
+
font-family: ui-monospace, 'SF Mono', Menlo, monospace;
|
|
167
|
+
font-size: 11px;
|
|
168
|
+
color: #6b7280;
|
|
169
|
+
min-width: 3.5em;
|
|
170
|
+
text-align: center;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
.timeline-scrubber__controls {
|
|
174
|
+
display: flex;
|
|
175
|
+
align-items: center;
|
|
176
|
+
gap: 8px;
|
|
177
|
+
justify-content: center;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
.timeline-scrubber__btn {
|
|
181
|
+
background: #fff;
|
|
182
|
+
border: 1px solid #e5e7eb;
|
|
183
|
+
border-radius: 4px;
|
|
184
|
+
padding: 4px 10px;
|
|
185
|
+
cursor: pointer;
|
|
186
|
+
font: inherit;
|
|
187
|
+
line-height: 1;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
.timeline-scrubber__btn:hover:not(:disabled) {
|
|
191
|
+
background: #f3f4f6;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
.timeline-scrubber__btn:disabled {
|
|
195
|
+
cursor: not-allowed;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
.timeline-scrubber__btn--speed {
|
|
199
|
+
min-width: 3em;
|
|
200
|
+
font-variant-numeric: tabular-nums;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
.timeline-scrubber__btn--speed.is-locked {
|
|
204
|
+
opacity: 0.5;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
.timeline-scrubber__currtime {
|
|
208
|
+
font-family: ui-monospace, 'SF Mono', Menlo, monospace;
|
|
209
|
+
font-size: 12px;
|
|
210
|
+
color: #111;
|
|
211
|
+
min-width: 4em;
|
|
212
|
+
text-align: right;
|
|
213
|
+
}
|
|
214
|
+
</style>
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import type { Snippet } from 'svelte';
|
|
2
|
+
import { type TimelineSegment, type TimelineViewWindow } from './TimelineTrack.svelte';
|
|
3
|
+
interface Props {
|
|
4
|
+
duration: number;
|
|
5
|
+
currentTime?: number;
|
|
6
|
+
isPlaying?: boolean;
|
|
7
|
+
speed?: number;
|
|
8
|
+
/** Available speed presets. Default: [1, 2, 4, 8, 16]. */
|
|
9
|
+
speedOptions?: number[];
|
|
10
|
+
/** Render speed as disabled / muted. Use when video playback overrides it. */
|
|
11
|
+
speedLocked?: boolean;
|
|
12
|
+
/** Title/tooltip shown when `speedLocked` is true. */
|
|
13
|
+
speedLockedReason?: string;
|
|
14
|
+
/** Optional zoomed view window; bindable. */
|
|
15
|
+
viewWindow?: TimelineViewWindow;
|
|
16
|
+
segments?: TimelineSegment[];
|
|
17
|
+
/** Format `currentTime` and `duration` for display. Default: mm:ss. */
|
|
18
|
+
formatTime?: (seconds: number) => string;
|
|
19
|
+
onSeek?: (time: number) => void;
|
|
20
|
+
onPlayToggle?: (nowPlaying: boolean) => void;
|
|
21
|
+
onSpeedChange?: (speed: number) => void;
|
|
22
|
+
onSegmentClick?: (segment: TimelineSegment) => void;
|
|
23
|
+
/** Snippet rendered above the hovered time, e.g. for a turn-preview tooltip. */
|
|
24
|
+
hoverIndicator?: Snippet<[{
|
|
25
|
+
time: number;
|
|
26
|
+
left: number;
|
|
27
|
+
}]>;
|
|
28
|
+
class?: string;
|
|
29
|
+
}
|
|
30
|
+
declare const TimelineScrubber: import("svelte").Component<Props, {}, "currentTime" | "viewWindow" | "isPlaying" | "speed">;
|
|
31
|
+
type TimelineScrubber = ReturnType<typeof TimelineScrubber>;
|
|
32
|
+
export default TimelineScrubber;
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { describe, it, expect, vi } from 'vitest';
|
|
2
|
+
import { render, fireEvent } from '@testing-library/svelte';
|
|
3
|
+
import TimelineScrubber from './TimelineScrubber.svelte';
|
|
4
|
+
describe('<TimelineScrubber>', () => {
|
|
5
|
+
it('shows the play icon when paused and pause when playing', () => {
|
|
6
|
+
const { container: paused } = render(TimelineScrubber, {
|
|
7
|
+
props: { duration: 60, currentTime: 0, isPlaying: false }
|
|
8
|
+
});
|
|
9
|
+
expect(paused.querySelector('[aria-label="Play"]')).not.toBeNull();
|
|
10
|
+
const { container: playing } = render(TimelineScrubber, {
|
|
11
|
+
props: { duration: 60, currentTime: 0, isPlaying: true }
|
|
12
|
+
});
|
|
13
|
+
expect(playing.querySelector('[aria-label="Pause"]')).not.toBeNull();
|
|
14
|
+
});
|
|
15
|
+
it('clicking the play button fires onPlayToggle with the new state', async () => {
|
|
16
|
+
const onPlayToggle = vi.fn();
|
|
17
|
+
const { container } = render(TimelineScrubber, {
|
|
18
|
+
props: { duration: 60, isPlaying: false, onPlayToggle }
|
|
19
|
+
});
|
|
20
|
+
const btn = container.querySelector('[aria-label="Play"]');
|
|
21
|
+
await fireEvent.click(btn);
|
|
22
|
+
expect(onPlayToggle).toHaveBeenCalledWith(true);
|
|
23
|
+
});
|
|
24
|
+
it('cycles through the default speed presets on each speed-button click', async () => {
|
|
25
|
+
const onSpeedChange = vi.fn();
|
|
26
|
+
const { container } = render(TimelineScrubber, {
|
|
27
|
+
props: { duration: 60, speed: 1, onSpeedChange }
|
|
28
|
+
});
|
|
29
|
+
const btn = container.querySelector('.timeline-scrubber__btn--speed');
|
|
30
|
+
await fireEvent.click(btn);
|
|
31
|
+
expect(onSpeedChange).toHaveBeenLastCalledWith(2);
|
|
32
|
+
});
|
|
33
|
+
it('speedLocked disables the speed button and surfaces the reason via title', () => {
|
|
34
|
+
const { container } = render(TimelineScrubber, {
|
|
35
|
+
props: {
|
|
36
|
+
duration: 60,
|
|
37
|
+
speed: 8,
|
|
38
|
+
speedLocked: true,
|
|
39
|
+
speedLockedReason: 'Video is playing'
|
|
40
|
+
}
|
|
41
|
+
});
|
|
42
|
+
const btn = container.querySelector('.timeline-scrubber__btn--speed');
|
|
43
|
+
expect(btn.disabled).toBe(true);
|
|
44
|
+
expect(btn.classList.contains('is-locked')).toBe(true);
|
|
45
|
+
expect(btn.getAttribute('title')).toContain('Video is playing');
|
|
46
|
+
});
|
|
47
|
+
it('clicking a locked speed button does NOT emit onSpeedChange', async () => {
|
|
48
|
+
const onSpeedChange = vi.fn();
|
|
49
|
+
const { container } = render(TimelineScrubber, {
|
|
50
|
+
props: { duration: 60, speed: 2, speedLocked: true, onSpeedChange }
|
|
51
|
+
});
|
|
52
|
+
const btn = container.querySelector('.timeline-scrubber__btn--speed');
|
|
53
|
+
await fireEvent.click(btn);
|
|
54
|
+
expect(onSpeedChange).not.toHaveBeenCalled();
|
|
55
|
+
});
|
|
56
|
+
it('uses the custom formatTime prop for both edge labels and the current-time readout', () => {
|
|
57
|
+
const formatTime = (s) => `[${Math.round(s)}s]`;
|
|
58
|
+
const { container } = render(TimelineScrubber, {
|
|
59
|
+
props: { duration: 120, currentTime: 42, formatTime }
|
|
60
|
+
});
|
|
61
|
+
const text = container.textContent ?? '';
|
|
62
|
+
expect(text).toContain('[0s]');
|
|
63
|
+
expect(text).toContain('[120s]');
|
|
64
|
+
expect(text).toContain('[42s]');
|
|
65
|
+
});
|
|
66
|
+
});
|
|
@@ -0,0 +1,376 @@
|
|
|
1
|
+
<script lang="ts" module>
|
|
2
|
+
/**
|
|
3
|
+
* A named region on the timeline. Rendered as a tinted band; clicking
|
|
4
|
+
* it emits `onSegmentClick` with the full segment.
|
|
5
|
+
*/
|
|
6
|
+
export interface TimelineSegment {
|
|
7
|
+
id: string;
|
|
8
|
+
start: number;
|
|
9
|
+
end: number;
|
|
10
|
+
label?: string;
|
|
11
|
+
color?: string;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export interface TimelineViewWindow {
|
|
15
|
+
start: number;
|
|
16
|
+
end: number;
|
|
17
|
+
}
|
|
18
|
+
</script>
|
|
19
|
+
|
|
20
|
+
<script lang="ts">
|
|
21
|
+
import type { Snippet } from 'svelte';
|
|
22
|
+
import { onDestroy } from 'svelte';
|
|
23
|
+
|
|
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.
|
|
27
|
+
//
|
|
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.
|
|
32
|
+
//
|
|
33
|
+
// Units are arbitrary (seconds, frames, steps) — TimelineTrack just works
|
|
34
|
+
// in the same unit you pass in as `duration`.
|
|
35
|
+
//
|
|
36
|
+
// See the package README for usage examples.
|
|
37
|
+
|
|
38
|
+
interface Props {
|
|
39
|
+
/** Total timeline length in your chosen unit. */
|
|
40
|
+
duration: number;
|
|
41
|
+
/** Current playhead position. Bindable. */
|
|
42
|
+
currentTime?: number;
|
|
43
|
+
/**
|
|
44
|
+
* Optional zoomed view window. When set, only this range is visible on
|
|
45
|
+
* the track; clicks and hover map into this window. Bindable.
|
|
46
|
+
*/
|
|
47
|
+
viewWindow?: TimelineViewWindow;
|
|
48
|
+
/** Highlighted regions rendered as labeled bands. */
|
|
49
|
+
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. */
|
|
53
|
+
showPlayhead?: boolean;
|
|
54
|
+
/** Called when the user clicks or drags the playhead. */
|
|
55
|
+
onSeek?: (time: number) => void;
|
|
56
|
+
/** Called when the user drags a view window handle. */
|
|
57
|
+
onViewWindowChange?: (window: TimelineViewWindow) => void;
|
|
58
|
+
/** Called when the user clicks a segment band. */
|
|
59
|
+
onSegmentClick?: (segment: TimelineSegment) => void;
|
|
60
|
+
/** 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 }]>;
|
|
64
|
+
class?: string;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
let {
|
|
68
|
+
duration,
|
|
69
|
+
currentTime = $bindable(0),
|
|
70
|
+
viewWindow = $bindable(undefined),
|
|
71
|
+
segments = [],
|
|
72
|
+
showViewWindowHandles = true,
|
|
73
|
+
showPlayhead = true,
|
|
74
|
+
onSeek,
|
|
75
|
+
onViewWindowChange,
|
|
76
|
+
onSegmentClick,
|
|
77
|
+
onHover,
|
|
78
|
+
hoverIndicator,
|
|
79
|
+
class: className = ''
|
|
80
|
+
}: Props = $props();
|
|
81
|
+
|
|
82
|
+
let trackEl: HTMLDivElement | null = $state(null);
|
|
83
|
+
let dragging = $state<null | 'playhead' | 'window-start' | 'window-end'>(null);
|
|
84
|
+
let hoverX = $state<number | null>(null);
|
|
85
|
+
|
|
86
|
+
const effectiveStart = $derived(viewWindow?.start ?? 0);
|
|
87
|
+
const effectiveEnd = $derived(viewWindow?.end ?? duration);
|
|
88
|
+
const effectiveRange = $derived(Math.max(0.0001, effectiveEnd - effectiveStart));
|
|
89
|
+
|
|
90
|
+
function timeToPercent(t: number): number {
|
|
91
|
+
return ((t - effectiveStart) / effectiveRange) * 100;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function eventToTime(event: PointerEvent): number | null {
|
|
95
|
+
if (!trackEl) return null;
|
|
96
|
+
const rect = trackEl.getBoundingClientRect();
|
|
97
|
+
const x = event.clientX - rect.left;
|
|
98
|
+
const pct = Math.max(0, Math.min(1, x / rect.width));
|
|
99
|
+
return effectiveStart + pct * effectiveRange;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function eventToClientX(event: PointerEvent): number | null {
|
|
103
|
+
if (!trackEl) return null;
|
|
104
|
+
const rect = trackEl.getBoundingClientRect();
|
|
105
|
+
return Math.max(0, Math.min(rect.width, event.clientX - rect.left));
|
|
106
|
+
}
|
|
107
|
+
|
|
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;
|
|
111
|
+
|
|
112
|
+
const time = eventToTime(event);
|
|
113
|
+
if (time === null) return;
|
|
114
|
+
|
|
115
|
+
currentTime = time;
|
|
116
|
+
onSeek?.(time);
|
|
117
|
+
dragging = 'playhead';
|
|
118
|
+
document.addEventListener('pointermove', handleDocPointerMove);
|
|
119
|
+
document.addEventListener('pointerup', handleDocPointerUp);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function handleDocPointerMove(event: PointerEvent) {
|
|
123
|
+
if (!dragging) return;
|
|
124
|
+
const time = eventToTime(event);
|
|
125
|
+
if (time === null) return;
|
|
126
|
+
|
|
127
|
+
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);
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function handleDocPointerUp() {
|
|
148
|
+
dragging = null;
|
|
149
|
+
document.removeEventListener('pointermove', handleDocPointerMove);
|
|
150
|
+
document.removeEventListener('pointerup', handleDocPointerUp);
|
|
151
|
+
}
|
|
152
|
+
|
|
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);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function handleTrackPointerMove(event: PointerEvent) {
|
|
161
|
+
if (dragging) return;
|
|
162
|
+
const x = eventToClientX(event);
|
|
163
|
+
const time = eventToTime(event);
|
|
164
|
+
hoverX = x;
|
|
165
|
+
onHover?.(time);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function handleTrackPointerLeave() {
|
|
169
|
+
if (dragging) return;
|
|
170
|
+
hoverX = null;
|
|
171
|
+
onHover?.(null);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function handleSegmentClick(segment: TimelineSegment, event: MouseEvent) {
|
|
175
|
+
event.stopPropagation();
|
|
176
|
+
onSegmentClick?.(segment);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
const hoveredTime = $derived.by(() => {
|
|
180
|
+
if (hoverX === null || !trackEl) return null;
|
|
181
|
+
const rect = trackEl.getBoundingClientRect();
|
|
182
|
+
const pct = Math.max(0, Math.min(1, hoverX / rect.width));
|
|
183
|
+
return effectiveStart + pct * effectiveRange;
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
onDestroy(() => {
|
|
187
|
+
if (typeof document !== 'undefined') {
|
|
188
|
+
document.removeEventListener('pointermove', handleDocPointerMove);
|
|
189
|
+
document.removeEventListener('pointerup', handleDocPointerUp);
|
|
190
|
+
}
|
|
191
|
+
});
|
|
192
|
+
</script>
|
|
193
|
+
|
|
194
|
+
<div
|
|
195
|
+
bind:this={trackEl}
|
|
196
|
+
class="timeline-track {className}"
|
|
197
|
+
class:is-dragging={dragging !== null}
|
|
198
|
+
onpointerdown={handleTrackPointerDown}
|
|
199
|
+
onpointermove={handleTrackPointerMove}
|
|
200
|
+
onpointerleave={handleTrackPointerLeave}
|
|
201
|
+
role="slider"
|
|
202
|
+
aria-valuemin={effectiveStart}
|
|
203
|
+
aria-valuemax={effectiveEnd}
|
|
204
|
+
aria-valuenow={currentTime}
|
|
205
|
+
tabindex="0"
|
|
206
|
+
>
|
|
207
|
+
<div class="timeline-track__rail"></div>
|
|
208
|
+
|
|
209
|
+
{#each segments as segment (segment.id)}
|
|
210
|
+
{@const left = timeToPercent(segment.start)}
|
|
211
|
+
{@const width = timeToPercent(segment.end) - left}
|
|
212
|
+
{#if width > 0 && left < 100 && left + width > 0}
|
|
213
|
+
<button
|
|
214
|
+
type="button"
|
|
215
|
+
data-skip-seek
|
|
216
|
+
class="timeline-track__segment"
|
|
217
|
+
style:left="{Math.max(0, left)}%"
|
|
218
|
+
style:width="{Math.min(100, left + width) - Math.max(0, left)}%"
|
|
219
|
+
style:background-color={segment.color ??
|
|
220
|
+
'var(--timeline-segment-bg, rgba(59, 130, 246, 0.18))'}
|
|
221
|
+
title={segment.label ?? `${segment.start.toFixed(1)}–${segment.end.toFixed(1)}`}
|
|
222
|
+
onclick={(e) => handleSegmentClick(segment, e)}
|
|
223
|
+
>
|
|
224
|
+
{#if segment.label}<span class="timeline-track__segment-label">{segment.label}</span>{/if}
|
|
225
|
+
</button>
|
|
226
|
+
{/if}
|
|
227
|
+
{/each}
|
|
228
|
+
|
|
229
|
+
{#if viewWindow && showViewWindowHandles}
|
|
230
|
+
<div
|
|
231
|
+
data-skip-seek
|
|
232
|
+
class="timeline-track__handle timeline-track__handle--start"
|
|
233
|
+
style:left="0%"
|
|
234
|
+
onpointerdown={(e) => handleHandlePointerDown('window-start', e)}
|
|
235
|
+
role="slider"
|
|
236
|
+
aria-label="View window start"
|
|
237
|
+
aria-valuenow={viewWindow.start}
|
|
238
|
+
aria-valuemin={0}
|
|
239
|
+
aria-valuemax={viewWindow.end}
|
|
240
|
+
tabindex="0"
|
|
241
|
+
></div>
|
|
242
|
+
<div
|
|
243
|
+
data-skip-seek
|
|
244
|
+
class="timeline-track__handle timeline-track__handle--end"
|
|
245
|
+
style:left="100%"
|
|
246
|
+
onpointerdown={(e) => handleHandlePointerDown('window-end', e)}
|
|
247
|
+
role="slider"
|
|
248
|
+
aria-label="View window end"
|
|
249
|
+
aria-valuenow={viewWindow.end}
|
|
250
|
+
aria-valuemin={viewWindow.start}
|
|
251
|
+
aria-valuemax={duration}
|
|
252
|
+
tabindex="0"
|
|
253
|
+
></div>
|
|
254
|
+
{/if}
|
|
255
|
+
|
|
256
|
+
{#if showPlayhead}
|
|
257
|
+
<div
|
|
258
|
+
class="timeline-track__playhead"
|
|
259
|
+
style:left="{timeToPercent(currentTime)}%"
|
|
260
|
+
aria-hidden="true"
|
|
261
|
+
></div>
|
|
262
|
+
{/if}
|
|
263
|
+
|
|
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 })}
|
|
271
|
+
</div>
|
|
272
|
+
{/if}
|
|
273
|
+
</div>
|
|
274
|
+
|
|
275
|
+
<style>
|
|
276
|
+
.timeline-track {
|
|
277
|
+
position: relative;
|
|
278
|
+
width: 100%;
|
|
279
|
+
height: var(--timeline-track-height, 32px);
|
|
280
|
+
user-select: none;
|
|
281
|
+
touch-action: none;
|
|
282
|
+
cursor: pointer;
|
|
283
|
+
|
|
284
|
+
--timeline-rail-bg: #e5e7eb;
|
|
285
|
+
--timeline-rail-height: 6px;
|
|
286
|
+
--timeline-playhead-color: #ef4444;
|
|
287
|
+
--timeline-playhead-width: 2px;
|
|
288
|
+
--timeline-handle-color: #6b7280;
|
|
289
|
+
--timeline-handle-width: 4px;
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
.timeline-track:focus-visible {
|
|
293
|
+
outline: 2px solid #3b82f6;
|
|
294
|
+
outline-offset: 2px;
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
.timeline-track__rail {
|
|
298
|
+
position: absolute;
|
|
299
|
+
top: 50%;
|
|
300
|
+
left: 0;
|
|
301
|
+
right: 0;
|
|
302
|
+
height: var(--timeline-rail-height);
|
|
303
|
+
transform: translateY(-50%);
|
|
304
|
+
background: var(--timeline-rail-bg);
|
|
305
|
+
border-radius: calc(var(--timeline-rail-height) / 2);
|
|
306
|
+
pointer-events: none;
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
.timeline-track__segment {
|
|
310
|
+
position: absolute;
|
|
311
|
+
top: 50%;
|
|
312
|
+
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;
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
.timeline-track__segment:hover {
|
|
327
|
+
filter: brightness(0.92);
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
.timeline-track__segment-label {
|
|
331
|
+
opacity: 0.75;
|
|
332
|
+
}
|
|
333
|
+
|
|
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;
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
.timeline-track__handle {
|
|
346
|
+
position: absolute;
|
|
347
|
+
top: 0;
|
|
348
|
+
bottom: 0;
|
|
349
|
+
width: 10px;
|
|
350
|
+
margin-left: -5px;
|
|
351
|
+
cursor: ew-resize;
|
|
352
|
+
display: flex;
|
|
353
|
+
align-items: center;
|
|
354
|
+
justify-content: center;
|
|
355
|
+
}
|
|
356
|
+
|
|
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;
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
.timeline-track__handle:focus-visible {
|
|
367
|
+
outline: 2px solid #3b82f6;
|
|
368
|
+
outline-offset: 2px;
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
.timeline-track__hover-indicator {
|
|
372
|
+
position: absolute;
|
|
373
|
+
bottom: 100%;
|
|
374
|
+
pointer-events: none;
|
|
375
|
+
}
|
|
376
|
+
</style>
|