panelui-web 0.9.1 → 0.10.1

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.
@@ -0,0 +1,613 @@
1
+ 'use client';
2
+ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
3
+ /**
4
+ * Timeline — time laid out left to right: tracks of clips under a ruler,
5
+ * with a playhead, for editing a recording, an animation or a schedule.
6
+ *
7
+ * ```tsx
8
+ * <Timeline duration={150} defaultZoom={12}>
9
+ * <Timeline.Header>
10
+ * <Timeline.Time />
11
+ * <Timeline.Zoom />
12
+ * </Timeline.Header>
13
+ * <Timeline.Viewport>
14
+ * <Timeline.Ruler />
15
+ * <Timeline.Track label="Zooms">
16
+ * <Timeline.Clip start={12} end={20} color="chart-2" variant="block" onChange={…} />
17
+ * </Timeline.Track>
18
+ * <Timeline.Playhead />
19
+ * </Timeline.Viewport>
20
+ * </Timeline>
21
+ * ```
22
+ *
23
+ * ## Clips you take hold of
24
+ *
25
+ * A clip with `onChange` is moved by dragging its middle and trimmed by
26
+ * dragging either end. It cannot be pushed into its neighbour on the same
27
+ * track, and near another clip's edge, the playhead or either end of the
28
+ * timeline it snaps there, with a guide line drawn the length of the
29
+ * timeline so the alignment can be seen.
30
+ *
31
+ * ## Three kinds of clip
32
+ *
33
+ * `bar` is a flat segment on a thin track, for something that only has a
34
+ * span: a cut, a caption. `block` is a tall pill with grips, for an effect
35
+ * placed on top of the footage: a zoom, a highlight. `media` is the
36
+ * footage itself, a wide band that holds a `Timeline.Waveform`.
37
+ *
38
+ * ## The ruler keeps its distance
39
+ *
40
+ * Labels on the ruler stay at least 64 pixels apart at any zoom: zoomed
41
+ * out, it counts in minutes; zoomed in, in fractions of a second. Pinch,
42
+ * or ⌘ or Ctrl with the wheel, zooms about the pointer, so the moment under
43
+ * the hand stays under it.
44
+ */
45
+ import { ScrollArea } from '@base-ui/react/scroll-area';
46
+ import { Children, createContext, isValidElement, useCallback, useContext, useEffect, useMemo, useRef, useState, } from 'react';
47
+ import { tv } from 'tailwind-variants';
48
+ import { Slider } from '../slider/index.js';
49
+ /** Space before the first second and after the last, in pixels. */
50
+ const GUTTER = 16;
51
+ /** How close, in pixels, an edge has to come to something before it snaps there. */
52
+ const SNAP = 8;
53
+ /** The narrowest a clip can be trimmed to, in pixels at the current zoom. */
54
+ const MIN_WIDTH = 12;
55
+ /** How far apart, at least, the ruler's labels stand. */
56
+ const LABEL_GAP = 64;
57
+ /** Steps the ruler can count in, in seconds. */
58
+ const STEPS = [0.1, 0.2, 0.5, 1, 2, 5, 10, 15, 30, 60, 120, 300, 600, 900, 1800, 3600];
59
+ const COLORS = {
60
+ primary: 'var(--primary)',
61
+ info: 'var(--info)',
62
+ success: 'var(--success)',
63
+ warning: 'var(--warning)',
64
+ destructive: 'var(--destructive)',
65
+ 'chart-1': 'var(--chart-1)',
66
+ 'chart-2': 'var(--chart-2)',
67
+ 'chart-3': 'var(--chart-3)',
68
+ 'chart-4': 'var(--chart-4)',
69
+ 'chart-5': 'var(--chart-5)',
70
+ };
71
+ /**
72
+ * A time as a reader expects it. On a timeline under a minute long, seconds
73
+ * with a unit: `0.5s`, `12s`. Longer, `m:ss`, and `h:mm:ss` past an hour.
74
+ * `tenths` adds a tenth of a second either way.
75
+ */
76
+ export function formatTime(seconds, tenths = false, short = false) {
77
+ const sign = seconds < 0 ? '−' : '';
78
+ const total = Math.abs(seconds);
79
+ if (short)
80
+ return `${sign}${tenths ? total.toFixed(1) : Math.round(total)}s`;
81
+ const hours = Math.floor(total / 3600);
82
+ const minutes = Math.floor((total - hours * 3600) / 60);
83
+ const rest = total - hours * 3600 - minutes * 60;
84
+ const whole = Math.floor(rest + 1e-6);
85
+ const second = String(whole).padStart(2, '0');
86
+ const fraction = tenths ? `.${Math.floor((rest - whole) * 10 + 1e-6)}` : '';
87
+ return hours
88
+ ? `${sign}${hours}:${String(minutes).padStart(2, '0')}:${second}${fraction}`
89
+ : `${sign}${minutes}:${second}${fraction}`;
90
+ }
91
+ /** Under a minute, a timeline counts in seconds rather than minutes. */
92
+ const isShort = (duration) => duration < 60;
93
+ /** The ruler's step at a zoom: the smallest that keeps labels `LABEL_GAP` apart. */
94
+ export function rulerStep(zoom) {
95
+ return STEPS.find((step) => step * zoom >= LABEL_GAP) ?? STEPS[STEPS.length - 1];
96
+ }
97
+ const clamp = (value, min, max) => Math.min(Math.max(value, min), max);
98
+ const timelineVariants = tv({
99
+ slots: {
100
+ root: [
101
+ 'group/timeline relative flex w-full min-w-0 flex-col overflow-hidden rounded-[22px] bg-popover text-popover-foreground select-none',
102
+ 'shadow-[0_10px_30px_-12px_rgb(0_0_0/0.3)] ring-1 ring-foreground/[0.06] dark:ring-foreground/10',
103
+ ],
104
+ header: 'flex min-h-12 min-w-0 items-center gap-2 px-3 pt-1.5',
105
+ time: 'shrink-0 px-1 text-xs font-medium whitespace-nowrap tabular-nums text-muted-foreground [&>b]:font-medium [&>b]:text-foreground',
106
+ // The first thing to give way on a narrow screen.
107
+ zoom: 'ms-auto flex w-44 min-w-24 shrink items-center',
108
+ viewport: 'relative min-w-0 flex-1 overscroll-x-contain outline-none',
109
+ content: 'relative isolate pb-3',
110
+ scrollbar: 'mx-3 mb-1 flex h-1.5 opacity-0 transition-opacity delay-300 data-hovering:opacity-100 data-hovering:delay-0 data-scrolling:opacity-100 data-scrolling:delay-0',
111
+ thumb: 'relative flex-1 rounded-full bg-foreground/20',
112
+ // Past the end, a strip of diagonal lines says there is nothing more.
113
+ end: [
114
+ 'pointer-events-none absolute top-8 bottom-0 -z-10',
115
+ 'bg-[repeating-linear-gradient(-45deg,color-mix(in_oklab,var(--foreground)_12%,transparent)_0_2px,transparent_2px_7px)]',
116
+ ],
117
+ ruler: 'relative h-8 cursor-ew-resize border-b border-foreground/[0.06]',
118
+ tick: 'pointer-events-none absolute bottom-0 w-px bg-foreground/20',
119
+ label: 'pointer-events-none absolute top-1.5 -translate-x-1/2 text-[11px] font-medium tabular-nums text-muted-foreground',
120
+ track: 'relative my-1.5 first-of-type:mt-3',
121
+ clip: [
122
+ 'group/clip absolute inset-y-0 flex min-w-0 items-center justify-center overflow-hidden outline-none',
123
+ 'bg-[linear-gradient(to_bottom,color-mix(in_oklab,var(--clip)_78%,white),var(--clip)_55%,color-mix(in_oklab,var(--clip)_88%,black))]',
124
+ 'shadow-[inset_0_1px_0_rgb(255_255_255/0.35),inset_0_-1px_0_rgb(0_0_0/0.15),0_1px_2px_rgb(0_0_0/0.2)]',
125
+ 'transition-[scale,box-shadow,filter] duration-150',
126
+ 'data-editable:cursor-grab data-dragging:cursor-grabbing data-dragging:scale-y-[1.04] data-dragging:brightness-110',
127
+ 'focus-visible:ring-2 focus-visible:ring-foreground focus-visible:ring-offset-2 focus-visible:ring-offset-popover',
128
+ 'motion-reduce:transition-none',
129
+ ],
130
+ handle: [
131
+ 'absolute inset-y-0 z-10 w-2.5 cursor-ew-resize opacity-0 transition-opacity duration-150',
132
+ 'group-hover/clip:opacity-100 group-data-dragging/clip:opacity-100',
133
+ "after:absolute after:inset-y-[28%] after:w-[3px] after:rounded-full after:bg-white/80 after:content-['']",
134
+ ],
135
+ grip: 'pointer-events-none flex h-[42%] gap-[3px] [&>span]:w-[3px] [&>span]:rounded-full [&>span]:bg-white/45',
136
+ clipLabel: [
137
+ 'pointer-events-none relative z-10 inline-flex max-w-full items-center gap-1.5 truncate px-2 text-xs font-semibold text-(--clip-foreground,white)',
138
+ '[text-shadow:0_1px_2px_rgb(0_0_0/0.25)] [&_svg]:size-3.5 [&_svg]:shrink-0',
139
+ ],
140
+ waveform: 'pointer-events-none absolute inset-x-0 inset-y-[12%] size-auto h-[76%] w-full text-white/40',
141
+ guide: 'pointer-events-none absolute top-8 bottom-0 z-20 w-px -translate-x-1/2 bg-foreground/70',
142
+ playhead: 'pointer-events-none absolute top-0 bottom-0 z-30 -translate-x-1/2',
143
+ playheadLine: 'absolute top-6 bottom-0 left-1/2 w-0.5 -translate-x-1/2 rounded-full bg-(--playhead)',
144
+ playheadHead: [
145
+ 'pointer-events-auto absolute top-1 left-1/2 flex h-5 min-w-5 -translate-x-1/2 cursor-ew-resize items-center justify-center rounded-md px-1.5',
146
+ 'bg-(--playhead) text-[10px] font-semibold tabular-nums text-(--playhead-foreground) shadow-sm outline-none',
147
+ 'focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-popover',
148
+ ],
149
+ },
150
+ variants: {
151
+ variant: {
152
+ bar: { clip: 'rounded-lg' },
153
+ block: { clip: 'rounded-[14px]' },
154
+ media: { clip: 'rounded-xl' },
155
+ },
156
+ size: {
157
+ sm: { track: 'h-6' },
158
+ md: { track: 'h-14' },
159
+ lg: { track: 'h-16' },
160
+ },
161
+ },
162
+ });
163
+ const TimelineContext = createContext(null);
164
+ function useTimelineContext(part) {
165
+ const context = useContext(TimelineContext);
166
+ if (!context)
167
+ throw new Error(`${part} must be used inside a <Timeline>.`);
168
+ return context;
169
+ }
170
+ /**
171
+ * The timeline's state, for controls of your own: a play button in a
172
+ * toolbar, a readout, a button that zooms to fit. Use it inside `<Timeline>`.
173
+ */
174
+ export function useTimeline() {
175
+ const { duration, time, setTime, playing, setPlaying, zoom, setZoom, minZoom, maxZoom } = useTimelineContext('useTimeline');
176
+ return { duration, time, setTime, playing, setPlaying, zoom, setZoom, minZoom, maxZoom };
177
+ }
178
+ /** Holds the time, the zoom and playback for the parts inside it. */
179
+ function TimelineRoot({ duration, time: timeProp, defaultTime = 0, onTimeChange, zoom: zoomProp, defaultZoom = 12, onZoomChange, minZoom = 2, maxZoom = 120, playing: playingProp, onPlayingChange, loop = false, snap = true, readOnly = false, className, children, ...props }) {
180
+ const slots = timelineVariants();
181
+ const [innerTime, setInnerTime] = useState(defaultTime);
182
+ const [innerZoom, setInnerZoom] = useState(defaultZoom);
183
+ const [innerPlaying, setInnerPlaying] = useState(false);
184
+ const [guide, setGuide] = useState(null);
185
+ const time = clamp(timeProp ?? innerTime, 0, duration);
186
+ const zoom = clamp(zoomProp ?? innerZoom, minZoom, maxZoom);
187
+ const playing = playingProp ?? innerPlaying;
188
+ const spans = useRef(new Map()).current;
189
+ const viewport = useRef(null);
190
+ const live = useRef({ time, zoom, duration, loop, onTimeChange, onZoomChange, onPlayingChange, timeProp, zoomProp, playingProp });
191
+ live.current = { time, zoom, duration, loop, onTimeChange, onZoomChange, onPlayingChange, timeProp, zoomProp, playingProp };
192
+ const setTime = useCallback((next) => {
193
+ const value = clamp(next, 0, live.current.duration);
194
+ // Seen by the playback loop at once, so a jump back to the start is not
195
+ // overwritten by the frame that was already on its way.
196
+ live.current.time = value;
197
+ if (live.current.timeProp === undefined)
198
+ setInnerTime(value);
199
+ live.current.onTimeChange?.(value);
200
+ }, []);
201
+ const setPlaying = useCallback((next) => {
202
+ if (live.current.playingProp === undefined)
203
+ setInnerPlaying(next);
204
+ live.current.onPlayingChange?.(next);
205
+ }, []);
206
+ const setZoom = useCallback((next, anchor) => {
207
+ const value = clamp(next, minZoom, maxZoom);
208
+ if (live.current.zoomProp === undefined)
209
+ setInnerZoom(value);
210
+ live.current.onZoomChange?.(value);
211
+ // Keep the moment under the pointer (or the playhead) where it was.
212
+ const box = viewport.current;
213
+ if (!box)
214
+ return;
215
+ const at = anchor ?? { time: live.current.time, x: GUTTER + live.current.time * live.current.zoom - box.scrollLeft };
216
+ requestAnimationFrame(() => {
217
+ box.scrollLeft = GUTTER + at.time * value - at.x;
218
+ });
219
+ }, [minZoom, maxZoom]);
220
+ // Playback: time runs on with the clock while playing.
221
+ useEffect(() => {
222
+ if (!playing)
223
+ return;
224
+ let frame = 0;
225
+ let last = performance.now();
226
+ const tick = (now) => {
227
+ const next = live.current.time + (now - last) / 1000;
228
+ last = now;
229
+ if (next >= live.current.duration) {
230
+ if (live.current.loop)
231
+ setTime(0);
232
+ else {
233
+ setTime(live.current.duration);
234
+ setPlaying(false);
235
+ return;
236
+ }
237
+ }
238
+ else
239
+ setTime(next);
240
+ frame = requestAnimationFrame(tick);
241
+ };
242
+ frame = requestAnimationFrame(tick);
243
+ return () => cancelAnimationFrame(frame);
244
+ }, [playing, setTime, setPlaying]);
245
+ const context = useMemo(() => ({
246
+ duration,
247
+ zoom,
248
+ minZoom,
249
+ maxZoom,
250
+ setZoom,
251
+ time,
252
+ setTime,
253
+ playing,
254
+ setPlaying,
255
+ snap,
256
+ readOnly,
257
+ spans,
258
+ setGuide,
259
+ viewport,
260
+ }), [duration, zoom, minZoom, maxZoom, setZoom, time, setTime, playing, setPlaying, snap, readOnly, spans]);
261
+ return (_jsx(TimelineContext.Provider, { value: context, children: _jsx(GuideContext.Provider, { value: guide, children: _jsx("div", { "data-slot": "timeline", "data-playing": playing ? '' : undefined, className: slots.root({ className }), ...props, children: children }) }) }));
262
+ }
263
+ TimelineRoot.displayName = 'Timeline';
264
+ const GuideContext = createContext(null);
265
+ /** A row above the tracks, for a toolbar, the time and the zoom. */
266
+ function TimelineHeader({ className, ...props }) {
267
+ const slots = timelineVariants();
268
+ return _jsx("div", { "data-slot": "timeline-header", className: slots.header({ className }), ...props });
269
+ }
270
+ TimelineHeader.displayName = 'Timeline.Header';
271
+ /** Where the playhead is, written out, and how long the whole is. */
272
+ function TimelineTime({ showDuration = true, className, ...props }) {
273
+ const { time, duration } = useTimelineContext('Timeline.Time');
274
+ const slots = timelineVariants();
275
+ return (_jsxs("span", { "data-slot": "timeline-time", className: slots.time({ className }), ...props, children: [_jsx("b", { children: formatTime(time, true, isShort(duration)) }), showDuration ? ` / ${formatTime(duration, isShort(duration), isShort(duration))}` : null] }));
276
+ }
277
+ TimelineTime.displayName = 'Timeline.Time';
278
+ /** A line slider that zooms the timeline, with zoom out and zoom in either side. */
279
+ function TimelineZoom({ className, ...props }) {
280
+ const { zoom, setZoom, minZoom, maxZoom } = useTimelineContext('Timeline.Zoom');
281
+ const slots = timelineVariants();
282
+ // The slider runs on a log scale, so each stretch of it zooms by the same factor.
283
+ const toSlider = (value) => Math.log(value / minZoom) / Math.log(maxZoom / minZoom);
284
+ const fromSlider = (value) => minZoom * Math.pow(maxZoom / minZoom, value);
285
+ const step = 0.125;
286
+ return (_jsx("div", { "data-slot": "timeline-zoom", className: slots.zoom({ className }), ...props, children: _jsxs(Slider, { variant: "line", label: "Zoom", min: 0, max: 1, step: 0.001, value: toSlider(zoom), onValueChange: (value) => setZoom(fromSlider(value)), format: () => `${Math.round(zoom)} pixels per second`, children: [_jsx(Slider.Step, { by: -step, "aria-label": "Zoom out", children: _jsxs("svg", { viewBox: "0 0 16 16", fill: "none", stroke: "currentColor", strokeWidth: 1.6, strokeLinecap: "round", children: [_jsx("circle", { cx: "7", cy: "7", r: "4.5" }), _jsx("path", { d: "M5 7h4M10.5 10.5 13.5 13.5" })] }) }), _jsx(Slider.Step, { by: step, "aria-label": "Zoom in", children: _jsxs("svg", { viewBox: "0 0 16 16", fill: "none", stroke: "currentColor", strokeWidth: 1.6, strokeLinecap: "round", children: [_jsx("circle", { cx: "7", cy: "7", r: "4.5" }), _jsx("path", { d: "M5 7h4M7 5v4M10.5 10.5 13.5 13.5" })] }) })] }) }));
287
+ }
288
+ TimelineZoom.displayName = 'Timeline.Zoom';
289
+ /**
290
+ * The part that scrolls sideways: the ruler, the tracks and the playhead go
291
+ * in here. Pinch, or ⌘ or Ctrl with the wheel, zooms about the pointer.
292
+ */
293
+ function TimelineViewport({ className, style, children, ...props }) {
294
+ const timeline = useTimelineContext('Timeline.Viewport');
295
+ const guide = useContext(GuideContext);
296
+ const slots = timelineVariants();
297
+ const { viewport, duration, zoom, setZoom } = timeline;
298
+ const width = GUTTER * 2 + duration * zoom;
299
+ useEffect(() => {
300
+ const box = viewport.current;
301
+ if (!box)
302
+ return;
303
+ const onWheel = (event) => {
304
+ if (!event.ctrlKey && !event.metaKey)
305
+ return;
306
+ event.preventDefault();
307
+ const x = event.clientX - box.getBoundingClientRect().left;
308
+ const time = (box.scrollLeft + x - GUTTER) / live.current;
309
+ setZoom(live.current * Math.exp(-event.deltaY * 0.01), { time, x });
310
+ };
311
+ box.addEventListener('wheel', onWheel, { passive: false });
312
+ return () => box.removeEventListener('wheel', onWheel);
313
+ }, [viewport, setZoom]);
314
+ const live = useRef(zoom);
315
+ live.current = zoom;
316
+ return (_jsxs(ScrollArea.Root, { "data-slot": "timeline-viewport", className: slots.viewport({ className }), children: [_jsx(ScrollArea.Viewport, { ref: viewport, className: "outline-none", children: _jsxs(ScrollArea.Content, { dir: "ltr", className: slots.content(), style: { ...style, width, minWidth: '100%', ['--timeline-zoom']: zoom }, ...props, children: [_jsx("span", { "aria-hidden": true, className: slots.end(), style: { left: GUTTER + duration * zoom, right: 0 } }), children, guide !== null ? _jsx("span", { "aria-hidden": true, "data-slot": "timeline-guide", className: slots.guide(), style: { left: GUTTER + guide * zoom } }) : null] }) }), _jsx(ScrollArea.Scrollbar, { orientation: "horizontal", className: slots.scrollbar(), children: _jsx(ScrollArea.Thumb, { className: slots.thumb() }) })] }));
317
+ }
318
+ TimelineViewport.displayName = 'Timeline.Viewport';
319
+ /** Turns a pointer's position into a time on the timeline. */
320
+ function useTimeAt() {
321
+ const { viewport, zoom, duration } = useTimelineContext('A timeline part');
322
+ return useCallback((clientX) => {
323
+ const box = viewport.current;
324
+ if (!box)
325
+ return 0;
326
+ const x = clientX - box.getBoundingClientRect().left + box.scrollLeft;
327
+ return clamp((x - GUTTER) / zoom, 0, duration);
328
+ }, [viewport, zoom, duration]);
329
+ }
330
+ /** Ticks and times along the top. Pressing or dragging on it moves the playhead there. */
331
+ function TimelineRuler({ className, ...props }) {
332
+ const { duration, zoom, setTime } = useTimelineContext('Timeline.Ruler');
333
+ const slots = timelineVariants();
334
+ const timeAt = useTimeAt();
335
+ const step = rulerStep(zoom);
336
+ // Minor ticks between labels, where there is room for them.
337
+ const minor = [5, 4, 2].map((parts) => step / parts).find((each) => each * zoom >= 8) ?? step;
338
+ const ticks = [];
339
+ const count = Math.floor(duration / minor + 1e-6);
340
+ for (let at = 0; at <= count; at++) {
341
+ const t = at * minor;
342
+ const major = Math.abs(t / step - Math.round(t / step)) < 1e-6;
343
+ const left = GUTTER + t * zoom;
344
+ ticks.push(_jsx("span", { className: slots.tick({ className: major ? 'h-2.5' : 'h-1.5' }), style: { left } }, `t${at}`));
345
+ if (major) {
346
+ ticks.push(_jsx("span", { className: slots.label(), style: { left }, children: formatTime(t, step < 1, isShort(duration)) }, `l${at}`));
347
+ }
348
+ }
349
+ const scrub = (event) => {
350
+ if (event.button !== 0)
351
+ return;
352
+ event.currentTarget.setPointerCapture(event.pointerId);
353
+ setTime(timeAt(event.clientX));
354
+ };
355
+ return (_jsx("div", { "aria-hidden": true, "data-slot": "timeline-ruler", className: slots.ruler({ className }), onPointerDown: scrub, onPointerMove: (event) => {
356
+ if (event.currentTarget.hasPointerCapture(event.pointerId))
357
+ setTime(timeAt(event.clientX));
358
+ }, ...props, children: ticks }));
359
+ }
360
+ TimelineRuler.displayName = 'Timeline.Ruler';
361
+ const TrackContext = createContext(null);
362
+ /** How wide the clip is on screen, so a waveform can draw bars of a fixed width. */
363
+ const ClipWidthContext = createContext(0);
364
+ /** A row of clips. Clips on one track cannot overlap. */
365
+ function TimelineTrack({ size = 'md', label, className, ...props }) {
366
+ const slots = timelineVariants({ size });
367
+ const spans = useRef(new Map()).current;
368
+ return (_jsx(TrackContext.Provider, { value: useMemo(() => ({ spans, size }), [spans, size]), children: _jsx("div", { role: "group", "aria-label": label, "data-slot": "timeline-track", "data-size": size, className: slots.track({ className }), ...props }) }));
369
+ }
370
+ TimelineTrack.displayName = 'Timeline.Track';
371
+ /** One clip. With `onChange` it moves by its middle and trims by its ends. */
372
+ function TimelineClip({ start, end, onChange, onChangeEnd, onRemove, variant: variantProp, color = 'primary', label, className, style, children, onKeyDown, ...props }) {
373
+ const timeline = useTimelineContext('Timeline.Clip');
374
+ const track = useContext(TrackContext);
375
+ if (!track)
376
+ throw new Error('Timeline.Clip must be used inside a <Timeline.Track>.');
377
+ const variant = variantProp ?? (track.size === 'sm' ? 'bar' : track.size === 'lg' ? 'media' : 'block');
378
+ const slots = timelineVariants({ variant });
379
+ const id = useRef(Math.random().toString(36).slice(2)).current;
380
+ const editable = Boolean(onChange) && !timeline.readOnly;
381
+ const [dragging, setDragging] = useState(false);
382
+ const { zoom, duration, spans: everyone, setGuide, time } = timeline;
383
+ // Known to the track, for neighbours, and to the timeline, for snapping.
384
+ track.spans.set(id, { start, end });
385
+ everyone.set(id, { start, end });
386
+ useEffect(() => () => {
387
+ track.spans.delete(id);
388
+ everyone.delete(id);
389
+ }, [track.spans, everyone, id]);
390
+ const live = useRef({ start, end, onChange, onChangeEnd, time });
391
+ live.current = { start, end, onChange, onChangeEnd, time };
392
+ /** The room this clip has: from its left neighbour's end to its right neighbour's start. */
393
+ const room = () => {
394
+ let lower = 0;
395
+ let upper = duration;
396
+ track.spans.forEach((span, other) => {
397
+ if (other === id)
398
+ return;
399
+ if (span.end <= live.current.start + 1e-6)
400
+ lower = Math.max(lower, span.end);
401
+ if (span.start >= live.current.end - 1e-6)
402
+ upper = Math.min(upper, span.start);
403
+ });
404
+ return { lower, upper };
405
+ };
406
+ /** Moves an edge onto a nearby edge, the playhead or an end, and says where. */
407
+ const snapEdge = (at) => {
408
+ if (!timeline.snap)
409
+ return { at, to: null };
410
+ const reach = SNAP / zoom;
411
+ const targets = [0, duration, live.current.time];
412
+ everyone.forEach((span, other) => {
413
+ if (other !== id)
414
+ targets.push(span.start, span.end);
415
+ });
416
+ let best = null;
417
+ for (const target of targets) {
418
+ if (Math.abs(target - at) <= reach && (best === null || Math.abs(target - at) < Math.abs(best - at)))
419
+ best = target;
420
+ }
421
+ return best === null ? { at, to: null } : { at: best, to: best };
422
+ };
423
+ const press = (event, part) => {
424
+ if (!editable || event.button !== 0)
425
+ return;
426
+ event.preventDefault();
427
+ event.stopPropagation();
428
+ event.currentTarget.closest('[data-slot=timeline-clip]')?.focus({ preventScroll: true, focusVisible: false });
429
+ const originX = event.clientX;
430
+ const origin = { start: live.current.start, end: live.current.end };
431
+ const { lower, upper } = room();
432
+ const length = origin.end - origin.start;
433
+ const shortest = MIN_WIDTH / zoom;
434
+ setDragging(true);
435
+ let last = origin;
436
+ const move = (next) => {
437
+ const delta = (next.clientX - originX) / zoom;
438
+ let span;
439
+ let guide = null;
440
+ if (part === 'move') {
441
+ let from = clamp(origin.start + delta, lower, upper - length);
442
+ // Either edge may snap; the nearer one wins.
443
+ const head = snapEdge(from);
444
+ const tail = snapEdge(from + length);
445
+ if (head.to !== null && (tail.to === null || Math.abs(head.at - from) <= Math.abs(tail.at - from - length))) {
446
+ from = clamp(head.at, lower, upper - length);
447
+ guide = head.to;
448
+ }
449
+ else if (tail.to !== null) {
450
+ from = clamp(tail.at - length, lower, upper - length);
451
+ guide = tail.to;
452
+ }
453
+ span = { start: from, end: from + length };
454
+ }
455
+ else if (part === 'start') {
456
+ const snapped = snapEdge(origin.start + delta);
457
+ const from = clamp(snapped.at, lower, origin.end - shortest);
458
+ guide = snapped.to !== null && from === snapped.at ? snapped.to : null;
459
+ span = { start: from, end: origin.end };
460
+ }
461
+ else {
462
+ const snapped = snapEdge(origin.end + delta);
463
+ const to = clamp(snapped.at, origin.start + shortest, upper);
464
+ guide = snapped.to !== null && to === snapped.at ? snapped.to : null;
465
+ span = { start: origin.start, end: to };
466
+ }
467
+ setGuide(guide);
468
+ last = span;
469
+ live.current.onChange?.(span);
470
+ };
471
+ const release = () => {
472
+ setDragging(false);
473
+ setGuide(null);
474
+ live.current.onChangeEnd?.(last);
475
+ window.removeEventListener('pointermove', move);
476
+ window.removeEventListener('pointerup', release);
477
+ window.removeEventListener('pointercancel', release);
478
+ };
479
+ window.addEventListener('pointermove', move);
480
+ window.addEventListener('pointerup', release);
481
+ window.addEventListener('pointercancel', release);
482
+ };
483
+ const keys = (event) => {
484
+ onKeyDown?.(event);
485
+ if (event.defaultPrevented)
486
+ return;
487
+ if ((event.key === 'Delete' || event.key === 'Backspace') && onRemove && !timeline.readOnly) {
488
+ event.preventDefault();
489
+ onRemove();
490
+ return;
491
+ }
492
+ if (!editable || (event.key !== 'ArrowLeft' && event.key !== 'ArrowRight'))
493
+ return;
494
+ event.preventDefault();
495
+ const { lower, upper } = room();
496
+ const by = (event.key === 'ArrowLeft' ? -1 : 1) * (event.shiftKey ? 1 : 0.1);
497
+ const length = end - start;
498
+ const from = clamp(start + by, lower, upper - length);
499
+ const span = { start: from, end: from + length };
500
+ onChange?.(span);
501
+ onChangeEnd?.(span);
502
+ };
503
+ const left = GUTTER + start * zoom;
504
+ const width = Math.max((end - start) * zoom, 2);
505
+ const name = `${label ? `${label}, ` : ''}${formatTime(start, true, isShort(duration))} to ${formatTime(end, true, isShort(duration))}`;
506
+ const roomy = width >= 40;
507
+ return (_jsxs("div", { role: "button", tabIndex: 0, "aria-roledescription": "clip", "aria-label": name, "data-slot": "timeline-clip", "data-variant": variant, "data-editable": editable ? '' : undefined, "data-dragging": dragging ? '' : undefined, className: slots.clip({ className }), style: {
508
+ ...style,
509
+ left,
510
+ width,
511
+ '--clip': COLORS[color],
512
+ // Text on a warning-coloured clip is dark; on the others it is white.
513
+ ...(color === 'warning' ? { '--clip-foreground': 'rgb(0 0 0 / 0.8)' } : null),
514
+ }, onPointerDown: (event) => press(event, 'move'), onKeyDown: keys, ...props, children: [variant === 'block' && roomy && !hasLabel(children) ? (_jsxs("span", { "aria-hidden": true, className: slots.grip(), children: [_jsx("span", {}), _jsx("span", {})] })) : null, _jsx(ClipWidthContext.Provider, { value: width, children: children }), editable && width >= 28 ? (_jsxs(_Fragment, { children: [_jsx("span", { "aria-hidden": true, "data-slot": "timeline-clip-handle", className: slots.handle({ className: 'start-0 after:start-1' }), onPointerDown: (event) => press(event, 'start') }), _jsx("span", { "aria-hidden": true, "data-slot": "timeline-clip-handle", className: slots.handle({ className: 'end-0 after:end-1' }), onPointerDown: (event) => press(event, 'end') })] })) : null] }));
515
+ }
516
+ TimelineClip.displayName = 'Timeline.Clip';
517
+ function hasLabel(children) {
518
+ let found = false;
519
+ Children.forEach(children, (child) => {
520
+ if (isValidElement(child) && child.type === TimelineClipLabel)
521
+ found = true;
522
+ });
523
+ return found;
524
+ }
525
+ /** Words on a clip, with an icon: its length, its speed, what it is. */
526
+ function TimelineClipLabel({ className, ...props }) {
527
+ const slots = timelineVariants();
528
+ return _jsx("span", { "data-slot": "timeline-clip-label", className: slots.clipLabel({ className }), ...props });
529
+ }
530
+ TimelineClipLabel.displayName = 'Timeline.ClipLabel';
531
+ /** The sound of a media clip, drawn as bars across it. */
532
+ function TimelineWaveform({ peaks, className, ...props }) {
533
+ const slots = timelineVariants();
534
+ const width = useContext(ClipWidthContext);
535
+ // One bar every 4 pixels at any zoom: the peaks are resampled to the room
536
+ // there is, each bar taking the loudest of the peaks it stands for.
537
+ const bars = Math.max(1, Math.floor((width || peaks.length * 4) / 4));
538
+ const heights = Array.from({ length: bars }, (_, at) => {
539
+ let loudest = 0;
540
+ if (bars > peaks.length) {
541
+ // More bars than peaks: blend between the two nearest, so zooming in
542
+ // draws a curve rather than steps.
543
+ const position = (at / Math.max(bars - 1, 1)) * (peaks.length - 1);
544
+ const below = Math.floor(position);
545
+ const mix = position - below;
546
+ loudest = (peaks[below] ?? 0) * (1 - mix) + (peaks[below + 1] ?? peaks[below] ?? 0) * mix;
547
+ }
548
+ else {
549
+ const from = Math.floor((at / bars) * peaks.length);
550
+ const to = Math.max(from + 1, Math.floor(((at + 1) / bars) * peaks.length));
551
+ for (let each = from; each < to && each < peaks.length; each++)
552
+ loudest = Math.max(loudest, peaks[each] ?? 0);
553
+ }
554
+ // Rounded, so the server and the browser write the same numbers.
555
+ return Math.round(Math.max(clamp(loudest, 0, 1) * 100, 3) * 100) / 100;
556
+ });
557
+ return (_jsx("svg", { "aria-hidden": true, "data-slot": "timeline-waveform", viewBox: `0 0 ${bars * 4} 100`, preserveAspectRatio: "none", className: slots.waveform({ className }), ...props, children: heights.map((height, at) => (_jsx("rect", { x: at * 4 + 1, y: Math.round((50 - height / 2) * 100) / 100, width: 2, height: height, rx: 1, fill: "currentColor" }, at))) }));
558
+ }
559
+ TimelineWaveform.displayName = 'Timeline.Waveform';
560
+ /**
561
+ * The line at the current time, from the ruler to the bottom. Its head can
562
+ * be dragged, and with focus the arrow keys step it: a tenth of a second,
563
+ * or a second with Shift.
564
+ */
565
+ function TimelinePlayhead({ color = 'primary', showTime = true, className, style, ...props }) {
566
+ const { time, setTime, duration, zoom, playing, setPlaying } = useTimelineContext('Timeline.Playhead');
567
+ const slots = timelineVariants();
568
+ const timeAt = useTimeAt();
569
+ const left = GUTTER + time * zoom;
570
+ const foreground = color === 'primary' ? 'var(--primary-foreground)' : color === 'warning' ? 'black' : 'white';
571
+ return (_jsxs("div", { "data-slot": "timeline-playhead", className: slots.playhead({ className }), style: { ...style, left, ['--playhead']: COLORS[color], ['--playhead-foreground']: foreground }, ...props, children: [_jsx("span", { "aria-hidden": true, className: slots.playheadLine() }), _jsx("div", { role: "slider", tabIndex: 0, "aria-label": "Playhead", "aria-valuemin": 0, "aria-valuemax": duration, "aria-valuenow": Math.round(time * 10) / 10, "aria-valuetext": formatTime(time, true, isShort(duration)), "data-slot": "timeline-playhead-head", className: slots.playheadHead(), onPointerDown: (event) => {
572
+ if (event.button !== 0)
573
+ return;
574
+ event.currentTarget.setPointerCapture(event.pointerId);
575
+ if (playing)
576
+ setPlaying(false);
577
+ }, onPointerMove: (event) => {
578
+ if (event.currentTarget.hasPointerCapture(event.pointerId))
579
+ setTime(timeAt(event.clientX));
580
+ }, onKeyDown: (event) => {
581
+ const by = event.shiftKey ? 1 : 0.1;
582
+ const moves = {
583
+ ArrowLeft: time - by,
584
+ ArrowRight: time + by,
585
+ Home: 0,
586
+ End: duration,
587
+ };
588
+ if (event.key === ' ') {
589
+ event.preventDefault();
590
+ setPlaying(!playing);
591
+ return;
592
+ }
593
+ const next = moves[event.key];
594
+ if (next === undefined)
595
+ return;
596
+ event.preventDefault();
597
+ setTime(next);
598
+ }, children: showTime ? formatTime(time, isShort(duration), isShort(duration)) : null })] }));
599
+ }
600
+ TimelinePlayhead.displayName = 'Timeline.Playhead';
601
+ export const Timeline = Object.assign(TimelineRoot, {
602
+ Header: TimelineHeader,
603
+ Time: TimelineTime,
604
+ Zoom: TimelineZoom,
605
+ Viewport: TimelineViewport,
606
+ Ruler: TimelineRuler,
607
+ Track: TimelineTrack,
608
+ Clip: TimelineClip,
609
+ ClipLabel: TimelineClipLabel,
610
+ Waveform: TimelineWaveform,
611
+ Playhead: TimelinePlayhead,
612
+ });
613
+ export { TimelineHeader, TimelineTime, TimelineZoom, TimelineViewport, TimelineRuler, TimelineTrack, TimelineClip, TimelineClipLabel, TimelineWaveform, TimelinePlayhead, timelineVariants, };