@zylem/ui 0.8.9 → 0.8.11

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.
Files changed (51) hide show
  1. package/dist/styles.css +1 -1
  2. package/package.json +12 -10
  3. package/src/components/Accordion/Accordion.tsx +10 -1
  4. package/src/components/AddTile/AddTile.css.ts +17 -0
  5. package/src/components/AddTile/AddTile.tsx +15 -7
  6. package/src/components/Callout/Callout.css.ts +45 -0
  7. package/src/components/Callout/Callout.tsx +32 -0
  8. package/src/components/Card/Card.css.ts +18 -0
  9. package/src/components/Card/Card.tsx +25 -2
  10. package/src/components/Dialog/Dialog.css.ts +69 -2
  11. package/src/components/Dialog/Dialog.tsx +35 -7
  12. package/src/components/DropdownMenu/DropdownMenu.css.ts +22 -0
  13. package/src/components/DropdownMenu/DropdownMenu.tsx +67 -20
  14. package/src/components/EmptyState/EmptyState.css.ts +9 -0
  15. package/src/components/EmptyState/EmptyState.tsx +3 -1
  16. package/src/components/FrameStrip/FrameStrip.css.ts +6 -0
  17. package/src/components/FrameStrip/FrameStrip.tsx +30 -2
  18. package/src/components/Menubar/Menubar.css.ts +52 -0
  19. package/src/components/Menubar/Menubar.tsx +77 -0
  20. package/src/components/Mixer/Knob.tsx +188 -0
  21. package/src/components/Mixer/Mixer.css.ts +246 -0
  22. package/src/components/Mixer/MixerStrip.tsx +150 -0
  23. package/src/components/NumberField/NumberField.tsx +5 -1
  24. package/src/components/Panel/Panel.tsx +6 -5
  25. package/src/components/PianoRoll/PianoRoll.css.ts +224 -0
  26. package/src/components/PianoRoll/PianoRoll.tsx +353 -0
  27. package/src/components/PianoRoll/piano-roll-math.ts +89 -0
  28. package/src/components/Property/Property.css.ts +1 -1
  29. package/src/components/Property/Property.tsx +3 -0
  30. package/src/components/Sidebar/Sidebar.css.ts +8 -0
  31. package/src/components/Sidebar/Sidebar.tsx +10 -2
  32. package/src/components/Slider/Slider.tsx +6 -0
  33. package/src/components/SpriteSizePicker/SpriteSizePicker.tsx +15 -4
  34. package/src/components/Tabs/Tabs.css.ts +48 -0
  35. package/src/components/Tabs/Tabs.tsx +41 -3
  36. package/src/components/TextField/TextField.tsx +49 -5
  37. package/src/components/TimelineLanes/TimelineLanes.css.ts +365 -0
  38. package/src/components/TimelineLanes/TimelineLanes.tsx +509 -0
  39. package/src/components/TimelineLanes/lanes-math.ts +182 -0
  40. package/src/components/Toast/Toast.css.ts +48 -0
  41. package/src/components/Toast/Toast.tsx +37 -0
  42. package/src/components/TrackHeader/TrackHeader.css.ts +162 -0
  43. package/src/components/TrackHeader/TrackHeader.tsx +145 -0
  44. package/src/components/TransportBar/TransportBar.css.ts +65 -0
  45. package/src/components/TransportBar/TransportBar.tsx +154 -0
  46. package/src/components/Waveform/Waveform.css.ts +77 -0
  47. package/src/components/Waveform/Waveform.tsx +195 -0
  48. package/src/components/Waveform/peaks.ts +49 -0
  49. package/src/components/index.ts +55 -1
  50. package/src/global/accordion.css.ts +23 -0
  51. package/src/styles.ts +11 -0
@@ -0,0 +1,509 @@
1
+ import {
2
+ type Accessor,
3
+ createContext,
4
+ createMemo,
5
+ createSignal,
6
+ For,
7
+ type JSX,
8
+ Show,
9
+ splitProps,
10
+ useContext,
11
+ } from 'solid-js';
12
+ import {
13
+ type ClipBounds,
14
+ clamp,
15
+ resolveClipDrag,
16
+ rulerTicks,
17
+ snapTime,
18
+ type TimeScale,
19
+ } from './lanes-math';
20
+
21
+ export type { TimeScale, TimeUnit, RulerTick, ClipBounds } from './lanes-math';
22
+
23
+ /** Room past the end of the timeline so the last clip's edge is grabbable. */
24
+ const END_PADDING = 48;
25
+ const DEFAULT_HEADER_WIDTH = 168;
26
+ const DEFAULT_LANE_HEIGHT = 48;
27
+
28
+ export interface TimelineContextValue {
29
+ scale: Accessor<TimeScale>;
30
+ /** Timeline length in units. */
31
+ length: Accessor<number>;
32
+ snap: Accessor<number | undefined>;
33
+ headerWidth: Accessor<number>;
34
+ disabled: Accessor<boolean>;
35
+ timeToPx: (time: number) => number;
36
+ /** Pixel offset from the start of a lane body → time. */
37
+ pxToTime: (px: number) => number;
38
+ /** Pixel travel → unit travel, for drags. */
39
+ deltaToTime: (dx: number) => number;
40
+ snapTime: (time: number) => number;
41
+ }
42
+
43
+ const TimelineContext = createContext<TimelineContextValue>();
44
+
45
+ /** The scale and helpers of the enclosing `TimelineLanes`, for custom lane content. */
46
+ export function useTimeline(): TimelineContextValue {
47
+ const context = useContext(TimelineContext);
48
+ if (!context) throw new Error('useTimeline must be used inside <TimelineLanes>');
49
+ return context;
50
+ }
51
+
52
+ export interface TimelineRange {
53
+ start: number;
54
+ end: number;
55
+ }
56
+
57
+ export interface TimelineLanesProps {
58
+ scale: TimeScale;
59
+ /** Timeline length in units. */
60
+ length: number;
61
+ playhead?: number;
62
+ /** Fires while the ruler is clicked or dragged. */
63
+ onSeek?: (time: number) => void;
64
+ /** Drag snap in units, applied to clips and markers (not to seeking). */
65
+ snap?: number;
66
+ /** Width of the sticky header column. */
67
+ headerWidth?: number;
68
+ /** Rendered in the corner above the headers. */
69
+ corner?: JSX.Element;
70
+ /** Highlighted span on the ruler (a loop or selection). */
71
+ range?: TimelineRange;
72
+ /** Ctrl/⌘ + wheel zoom; receives the next pixels-per-unit. */
73
+ onZoom?: (pixelsPerUnit: number) => void;
74
+ minPixelsPerUnit?: number;
75
+ maxPixelsPerUnit?: number;
76
+ disabled?: boolean;
77
+ children?: JSX.Element;
78
+ class?: string;
79
+ ref?: (element: HTMLDivElement) => void;
80
+ }
81
+
82
+ /**
83
+ * Multi-track timeline: a sticky ruler with labelled ticks, a sticky header
84
+ * column, lanes that scroll together, and a playhead across all of them.
85
+ * Lanes hold `TimelineClip`s and `KeyframeMarker`s, which read the scale
86
+ * from context; anything else can via `useTimeline()`.
87
+ */
88
+ export function TimelineLanes(props: TimelineLanesProps) {
89
+ const headerWidth = () => props.headerWidth ?? DEFAULT_HEADER_WIDTH;
90
+ const ppu = () => props.scale.pixelsPerUnit;
91
+ const timeToPx = (time: number) => time * ppu();
92
+ const pxToTime = (px: number) => px / ppu();
93
+ const context: TimelineContextValue = {
94
+ scale: () => props.scale,
95
+ length: () => props.length,
96
+ snap: () => props.snap,
97
+ headerWidth,
98
+ disabled: () => Boolean(props.disabled),
99
+ timeToPx,
100
+ pxToTime,
101
+ deltaToTime: (dx) => dx / ppu(),
102
+ snapTime: (time) => snapTime(time, props.snap),
103
+ };
104
+
105
+ const ticks = createMemo(() => rulerTicks({ scale: props.scale, length: props.length }));
106
+ const minorStep = createMemo(() => {
107
+ const list = ticks();
108
+ return list.length > 1 ? (list[1]!.time - list[0]!.time) * ppu() : 0;
109
+ });
110
+ const majorStep = createMemo(() => {
111
+ const majors = ticks().filter((tick) => tick.major);
112
+ return majors.length > 1 ? (majors[1]!.time - majors[0]!.time) * ppu() : 0;
113
+ });
114
+ const bodyWidth = () => timeToPx(props.length) + END_PADDING;
115
+
116
+ let ruler: HTMLDivElement | undefined;
117
+ const seekFromPointer = (event: PointerEvent) => {
118
+ if (!ruler) return;
119
+ const rect = ruler.getBoundingClientRect();
120
+ props.onSeek?.(clamp(pxToTime(event.clientX - rect.left), 0, props.length));
121
+ };
122
+ const startSeek = (event: PointerEvent) => {
123
+ if (props.disabled || !props.onSeek || event.button !== 0) return;
124
+ event.preventDefault();
125
+ seekFromPointer(event);
126
+ const move = (moveEvent: PointerEvent) => seekFromPointer(moveEvent);
127
+ const stop = () => {
128
+ window.removeEventListener('pointermove', move);
129
+ window.removeEventListener('pointerup', stop);
130
+ window.removeEventListener('pointercancel', stop);
131
+ };
132
+ window.addEventListener('pointermove', move);
133
+ window.addEventListener('pointerup', stop);
134
+ window.addEventListener('pointercancel', stop);
135
+ };
136
+
137
+ const handleWheel = (event: WheelEvent) => {
138
+ if (!props.onZoom || !(event.ctrlKey || event.metaKey)) return;
139
+ event.preventDefault();
140
+ const factor = Math.exp(-event.deltaY * 0.002);
141
+ const next = clamp(
142
+ ppu() * factor,
143
+ props.minPixelsPerUnit ?? 4,
144
+ props.maxPixelsPerUnit ?? 400,
145
+ );
146
+ props.onZoom(next);
147
+ };
148
+
149
+ return (
150
+ <TimelineContext.Provider value={context}>
151
+ <div
152
+ ref={props.ref}
153
+ class={props.class ? `zylem-lanes ${props.class}` : 'zylem-lanes'}
154
+ data-disabled={props.disabled ? '' : undefined}
155
+ data-unit={props.scale.unit}
156
+ onWheel={handleWheel}
157
+ >
158
+ <div
159
+ class="zylem-lanes-content"
160
+ style={{
161
+ width: `${headerWidth() + bodyWidth()}px`,
162
+ '--zylem-lanes-header': `${headerWidth()}px`,
163
+ '--zylem-lanes-minor': `${minorStep()}px`,
164
+ '--zylem-lanes-major': `${majorStep()}px`,
165
+ }}
166
+ >
167
+ <div class="zylem-lanes-ruler-row">
168
+ <div class="zylem-lanes-corner">{props.corner}</div>
169
+ <div
170
+ ref={ruler}
171
+ class="zylem-lanes-ruler"
172
+ style={{ width: `${bodyWidth()}px` }}
173
+ onPointerDown={startSeek}
174
+ role={props.onSeek ? 'slider' : undefined}
175
+ aria-label={props.onSeek ? 'Playhead' : undefined}
176
+ aria-valuemin={props.onSeek ? 0 : undefined}
177
+ aria-valuemax={props.onSeek ? props.length : undefined}
178
+ aria-valuenow={props.onSeek ? props.playhead : undefined}
179
+ >
180
+ <Show when={props.range}>
181
+ {(range) => (
182
+ <div
183
+ class="zylem-lanes-range"
184
+ style={{
185
+ left: `${timeToPx(range().start)}px`,
186
+ width: `${Math.max(0, timeToPx(range().end - range().start))}px`,
187
+ }}
188
+ />
189
+ )}
190
+ </Show>
191
+ <For each={ticks()}>
192
+ {(tick) => (
193
+ <div
194
+ class="zylem-lanes-tick"
195
+ data-major={tick.major ? '' : undefined}
196
+ style={{ left: `${timeToPx(tick.time)}px` }}
197
+ >
198
+ <Show when={tick.label}>
199
+ <span class="zylem-lanes-tick-label">{tick.label}</span>
200
+ </Show>
201
+ </div>
202
+ )}
203
+ </For>
204
+ <Show when={props.playhead !== undefined}>
205
+ <div
206
+ class="zylem-lanes-playhead-head"
207
+ style={{ left: `${timeToPx(props.playhead ?? 0)}px` }}
208
+ />
209
+ </Show>
210
+ </div>
211
+ </div>
212
+ <div class="zylem-lanes-rows">{props.children}</div>
213
+ <Show when={props.playhead !== undefined}>
214
+ <div
215
+ class="zylem-lanes-playhead"
216
+ style={{ left: `${headerWidth() + timeToPx(props.playhead ?? 0)}px` }}
217
+ aria-hidden="true"
218
+ />
219
+ </Show>
220
+ </div>
221
+ </div>
222
+ </TimelineContext.Provider>
223
+ );
224
+ }
225
+
226
+ export interface TimelineLaneProps {
227
+ /** Sticky header cell; usually a `TrackHeader`. */
228
+ header?: JSX.Element;
229
+ height?: number;
230
+ selected?: boolean;
231
+ /** Dims the lane body (muted track, locked track). */
232
+ dimmed?: boolean;
233
+ /** Double-click on empty lane space, with the (snapped) time under the pointer. */
234
+ onCreate?: (time: number) => void;
235
+ /** Pointer down on empty lane space (not on a clip or marker). */
236
+ onSelectEmpty?: () => void;
237
+ children?: JSX.Element;
238
+ class?: string;
239
+ }
240
+
241
+ export function TimelineLane(props: TimelineLaneProps) {
242
+ const timeline = useTimeline();
243
+ const height = () => props.height ?? DEFAULT_LANE_HEIGHT;
244
+ let body: HTMLDivElement | undefined;
245
+
246
+ const timeAt = (event: MouseEvent) => {
247
+ if (!body) return 0;
248
+ const rect = body.getBoundingClientRect();
249
+ return clamp(
250
+ timeline.snapTime(timeline.pxToTime(event.clientX - rect.left)),
251
+ 0,
252
+ timeline.length(),
253
+ );
254
+ };
255
+
256
+ return (
257
+ <div
258
+ class={props.class ? `zylem-lane ${props.class}` : 'zylem-lane'}
259
+ style={{ height: `${height()}px` }}
260
+ data-selected={props.selected ? '' : undefined}
261
+ data-dimmed={props.dimmed ? '' : undefined}
262
+ >
263
+ <div class="zylem-lane-header">{props.header}</div>
264
+ <div
265
+ ref={body}
266
+ class="zylem-lane-body"
267
+ style={{ width: `${timeline.timeToPx(timeline.length()) + END_PADDING}px` }}
268
+ onPointerDown={(event) => {
269
+ if (event.target === body) props.onSelectEmpty?.();
270
+ }}
271
+ onDblClick={(event) => {
272
+ if (timeline.disabled() || event.target !== body) return;
273
+ props.onCreate?.(timeAt(event));
274
+ }}
275
+ >
276
+ <div class="zylem-lane-end" style={{ left: `${timeline.timeToPx(timeline.length())}px` }} />
277
+ {props.children}
278
+ </div>
279
+ </div>
280
+ );
281
+ }
282
+
283
+ export interface TimelineClipChange extends ClipBounds {
284
+ /** How far the start moved (non-zero only for start-edge drags); lets hosts offset media. */
285
+ startDelta: number;
286
+ }
287
+
288
+ export interface TimelineClipProps {
289
+ start: number;
290
+ length: number;
291
+ label?: string;
292
+ /** Any CSS color; tints the clip. */
293
+ color?: string;
294
+ selected?: boolean;
295
+ /** Length in units of a lead-in region drawn hatched at the clip start (a shot blend). */
296
+ blend?: number;
297
+ minLength?: number;
298
+ movable?: boolean;
299
+ resizable?: boolean;
300
+ disabled?: boolean;
301
+ onSelect?: (event: PointerEvent) => void;
302
+ /** Fires continuously while dragging. */
303
+ onChange?: (change: TimelineClipChange) => void;
304
+ /** Fires once when a drag ends, with the final bounds. */
305
+ onCommit?: (change: TimelineClipChange) => void;
306
+ onDoubleClick?: () => void;
307
+ /** Content drawn inside the clip under the label (a note preview, a waveform). */
308
+ children?: JSX.Element;
309
+ class?: string;
310
+ }
311
+
312
+ type DragMode = 'move' | 'start' | 'end';
313
+
314
+ /** A block on a lane. Drag the body to move, the edges to trim. */
315
+ export function TimelineClip(props: TimelineClipProps) {
316
+ const timeline = useTimeline();
317
+ const [dragging, setDragging] = createSignal<DragMode | null>(null);
318
+ const disabled = () => props.disabled || timeline.disabled();
319
+ const movable = () => props.movable !== false;
320
+ const resizable = () => props.resizable !== false;
321
+
322
+ const beginDrag = (mode: DragMode) => (event: PointerEvent) => {
323
+ if (event.button !== 0) return;
324
+ event.stopPropagation();
325
+ props.onSelect?.(event);
326
+ if (disabled()) return;
327
+ if (mode === 'move' && !movable()) return;
328
+ if (mode !== 'move' && !resizable()) return;
329
+ event.preventDefault();
330
+ const target = event.currentTarget as HTMLElement;
331
+ target.setPointerCapture?.(event.pointerId);
332
+ const originX = event.clientX;
333
+ const origin: ClipBounds = { start: props.start, length: props.length };
334
+ let last: TimelineClipChange = { ...origin, startDelta: 0 };
335
+ setDragging(mode);
336
+
337
+ const resolve = (moveEvent: PointerEvent): TimelineClipChange => {
338
+ const bounds = resolveClipDrag({
339
+ mode,
340
+ delta: timeline.deltaToTime(moveEvent.clientX - originX),
341
+ origin,
342
+ length: timeline.length(),
343
+ snap: timeline.snap(),
344
+ minLength: props.minLength ?? timeline.snap() ?? 0,
345
+ });
346
+ return { ...bounds, startDelta: mode === 'start' ? bounds.start - origin.start : 0 };
347
+ };
348
+ const move = (moveEvent: PointerEvent) => {
349
+ last = resolve(moveEvent);
350
+ props.onChange?.(last);
351
+ };
352
+ const stop = () => {
353
+ target.removeEventListener('pointermove', move);
354
+ target.removeEventListener('pointerup', stop);
355
+ target.removeEventListener('pointercancel', stop);
356
+ target.releasePointerCapture?.(event.pointerId);
357
+ setDragging(null);
358
+ props.onCommit?.(last);
359
+ };
360
+ target.addEventListener('pointermove', move);
361
+ target.addEventListener('pointerup', stop);
362
+ target.addEventListener('pointercancel', stop);
363
+ };
364
+
365
+ return (
366
+ <div
367
+ class={props.class ? `zylem-clip ${props.class}` : 'zylem-clip'}
368
+ style={{
369
+ left: `${timeline.timeToPx(props.start)}px`,
370
+ width: `${Math.max(2, timeline.timeToPx(props.length))}px`,
371
+ '--zylem-clip-color': props.color ?? 'var(--zylem-color-primary)',
372
+ }}
373
+ data-selected={props.selected ? '' : undefined}
374
+ data-dragging={dragging() ?? undefined}
375
+ data-disabled={disabled() ? '' : undefined}
376
+ role="button"
377
+ tabIndex={disabled() ? -1 : 0}
378
+ aria-label={props.label}
379
+ aria-pressed={props.selected ? 'true' : 'false'}
380
+ onPointerDown={beginDrag('move')}
381
+ onDblClick={(event) => {
382
+ event.stopPropagation();
383
+ props.onDoubleClick?.();
384
+ }}
385
+ >
386
+ <Show when={props.blend && props.blend > 0}>
387
+ <div
388
+ class="zylem-clip-blend"
389
+ style={{ width: `${timeline.timeToPx(props.blend ?? 0)}px` }}
390
+ aria-hidden="true"
391
+ />
392
+ </Show>
393
+ <Show when={props.label}>
394
+ <span class="zylem-clip-label">{props.label}</span>
395
+ </Show>
396
+ <Show when={props.children}>
397
+ <div class="zylem-clip-body">{props.children}</div>
398
+ </Show>
399
+ <Show when={resizable() && !disabled()}>
400
+ <div class="zylem-clip-handle" data-edge="start" onPointerDown={beginDrag('start')} />
401
+ <div class="zylem-clip-handle" data-edge="end" onPointerDown={beginDrag('end')} />
402
+ </Show>
403
+ </div>
404
+ );
405
+ }
406
+
407
+ export interface KeyframeMarkerProps {
408
+ time: number;
409
+ label?: string;
410
+ /** `diamond` for keyframes, `pin` for events/cues. */
411
+ shape?: 'diamond' | 'pin';
412
+ color?: string;
413
+ selected?: boolean;
414
+ disabled?: boolean;
415
+ onSelect?: (event: PointerEvent) => void;
416
+ /** Fires continuously while dragging. */
417
+ onChange?: (time: number) => void;
418
+ /** Fires once when a drag ends. */
419
+ onCommit?: (time: number) => void;
420
+ onDoubleClick?: () => void;
421
+ class?: string;
422
+ }
423
+
424
+ /** A draggable point on a lane: a keyframe diamond or an event pin. */
425
+ export function KeyframeMarker(props: KeyframeMarkerProps) {
426
+ const timeline = useTimeline();
427
+ const [dragging, setDragging] = createSignal(false);
428
+ const disabled = () => props.disabled || timeline.disabled();
429
+
430
+ const beginDrag = (event: PointerEvent) => {
431
+ if (event.button !== 0) return;
432
+ event.stopPropagation();
433
+ props.onSelect?.(event);
434
+ if (disabled()) return;
435
+ event.preventDefault();
436
+ const target = event.currentTarget as HTMLElement;
437
+ target.setPointerCapture?.(event.pointerId);
438
+ const originX = event.clientX;
439
+ const originTime = props.time;
440
+ let last = originTime;
441
+ setDragging(true);
442
+ const move = (moveEvent: PointerEvent) => {
443
+ last = clamp(
444
+ timeline.snapTime(originTime + timeline.deltaToTime(moveEvent.clientX - originX)),
445
+ 0,
446
+ timeline.length(),
447
+ );
448
+ props.onChange?.(last);
449
+ };
450
+ const stop = () => {
451
+ target.removeEventListener('pointermove', move);
452
+ target.removeEventListener('pointerup', stop);
453
+ target.removeEventListener('pointercancel', stop);
454
+ target.releasePointerCapture?.(event.pointerId);
455
+ setDragging(false);
456
+ props.onCommit?.(last);
457
+ };
458
+ target.addEventListener('pointermove', move);
459
+ target.addEventListener('pointerup', stop);
460
+ target.addEventListener('pointercancel', stop);
461
+ };
462
+
463
+ return (
464
+ <button
465
+ type="button"
466
+ class={props.class ? `zylem-marker ${props.class}` : 'zylem-marker'}
467
+ style={{
468
+ left: `${timeline.timeToPx(props.time)}px`,
469
+ '--zylem-marker-color': props.color ?? '',
470
+ }}
471
+ data-shape={props.shape ?? 'diamond'}
472
+ data-selected={props.selected ? '' : undefined}
473
+ data-dragging={dragging() ? '' : undefined}
474
+ disabled={disabled()}
475
+ aria-label={props.label ?? `Keyframe at ${props.time}`}
476
+ aria-pressed={props.selected ? 'true' : 'false'}
477
+ title={props.label}
478
+ onPointerDown={beginDrag}
479
+ onDblClick={(event) => {
480
+ event.stopPropagation();
481
+ props.onDoubleClick?.();
482
+ }}
483
+ >
484
+ <span class="zylem-marker-glyph" aria-hidden="true" />
485
+ <Show when={props.label && props.shape === 'pin'}>
486
+ <span class="zylem-marker-label">{props.label}</span>
487
+ </Show>
488
+ </button>
489
+ );
490
+ }
491
+
492
+ export interface TimelineLaneGroupProps {
493
+ /** Header cell content for the group row. */
494
+ header?: JSX.Element;
495
+ collapsed?: boolean;
496
+ children?: JSX.Element;
497
+ class?: string;
498
+ }
499
+
500
+ /** A thin divider lane that titles the lanes below it (a camera's keyframe sub-lanes). */
501
+ export function TimelineLaneGroup(props: TimelineLaneGroupProps) {
502
+ const [local] = splitProps(props, ['header', 'collapsed', 'children', 'class']);
503
+ return (
504
+ <>
505
+ <TimelineLane header={local.header} height={24} class={`zylem-lane--group ${local.class ?? ''}`} />
506
+ <Show when={!local.collapsed}>{local.children}</Show>
507
+ </>
508
+ );
509
+ }
@@ -0,0 +1,182 @@
1
+ /**
2
+ * Pure helpers behind `TimelineLanes`: unit ↔ pixel mapping, snapping, and
3
+ * ruler tick generation for seconds- and beats-based timelines.
4
+ */
5
+
6
+ export type TimeUnit = 'seconds' | 'beats';
7
+
8
+ export interface TimeScale {
9
+ unit: TimeUnit;
10
+ /** Horizontal pixels per unit (per second or per beat). */
11
+ pixelsPerUnit: number;
12
+ /** Beats per bar; only read for `beats`. Defaults to 4. */
13
+ beatsPerBar?: number;
14
+ }
15
+
16
+ export interface RulerTick {
17
+ time: number;
18
+ major: boolean;
19
+ /** Present on major ticks. */
20
+ label?: string;
21
+ }
22
+
23
+ /** Rounds `t` to the nearest multiple of `snap`; a missing or non-positive snap is a no-op. */
24
+ export function snapTime(t: number, snap?: number): number {
25
+ if (!snap || snap <= 0 || !Number.isFinite(t)) return t;
26
+ return Math.round(t / snap) * snap;
27
+ }
28
+
29
+ export function clamp(value: number, min: number, max: number): number {
30
+ return Math.min(max, Math.max(min, value));
31
+ }
32
+
33
+ export interface ClipBounds {
34
+ start: number;
35
+ length: number;
36
+ }
37
+
38
+ export interface ResolveClipDragOptions {
39
+ mode: 'move' | 'start' | 'end';
40
+ /** Pointer travel in units since the drag began. */
41
+ delta: number;
42
+ origin: ClipBounds;
43
+ /** Timeline length in units. */
44
+ length: number;
45
+ snap?: number;
46
+ minLength?: number;
47
+ }
48
+
49
+ /**
50
+ * Where a clip lands after a drag. Moves keep the length and stay inside the
51
+ * timeline; edge drags keep the far edge fixed and respect `minLength`.
52
+ */
53
+ export function resolveClipDrag(options: ResolveClipDragOptions): ClipBounds {
54
+ const { mode, delta, origin, length } = options;
55
+ const minLength = Math.max(options.minLength ?? 0, 0);
56
+ const originEnd = origin.start + origin.length;
57
+ if (mode === 'move') {
58
+ const maxStart = Math.max(0, length - origin.length);
59
+ const start = clamp(snapTime(origin.start + delta, options.snap), 0, maxStart);
60
+ return { start, length: origin.length };
61
+ }
62
+ if (mode === 'end') {
63
+ const end = clamp(
64
+ snapTime(originEnd + delta, options.snap),
65
+ origin.start + minLength,
66
+ Math.max(length, origin.start + minLength),
67
+ );
68
+ return { start: origin.start, length: end - origin.start };
69
+ }
70
+ const start = clamp(
71
+ snapTime(origin.start + delta, options.snap),
72
+ 0,
73
+ Math.max(0, originEnd - minLength),
74
+ );
75
+ return { start, length: originEnd - start };
76
+ }
77
+
78
+ const SECOND_STEPS = [0.1, 0.25, 0.5, 1, 2, 5, 10, 15, 30, 60, 120, 300, 600];
79
+
80
+ function beatSteps(beatsPerBar: number): number[] {
81
+ const bar = Math.max(1, beatsPerBar);
82
+ return [0.25, 0.5, 1, bar, bar * 2, bar * 4, bar * 8, bar * 16, bar * 32, bar * 64];
83
+ }
84
+
85
+ /** Picks the smallest step whose pixel width clears `minSpacing`. */
86
+ function pickStep(steps: readonly number[], pixelsPerUnit: number, minSpacing: number): number {
87
+ for (const step of steps) {
88
+ if (step * pixelsPerUnit >= minSpacing) return step;
89
+ }
90
+ return steps[steps.length - 1] ?? 1;
91
+ }
92
+
93
+ /** `65.5` → `1:05.5`; whole minutes drop the fraction. */
94
+ export function formatClock(seconds: number): string {
95
+ if (!Number.isFinite(seconds)) return '0:00';
96
+ const total = Math.max(0, seconds);
97
+ const minutes = Math.floor(total / 60);
98
+ const rest = total - minutes * 60;
99
+ const rounded = Math.round(rest * 100) / 100;
100
+ const whole = Math.floor(rounded);
101
+ const fraction = rounded - whole;
102
+ const secondsText = `${whole < 10 ? '0' : ''}${whole}`;
103
+ if (fraction < 0.005) return `${minutes}:${secondsText}`;
104
+ return `${minutes}:${secondsText}${fraction.toFixed(2).slice(1).replace(/0+$/, '')}`;
105
+ }
106
+
107
+ /**
108
+ * Beats as a `bar.beat` position, both 1-based: beat 4 in 4/4 is `2.1`. A
109
+ * fractional beat adds a third part in sixteenths-of-a-beat hundredths
110
+ * (`1.2.50` is halfway through the second beat).
111
+ */
112
+ export function formatBeats(beats: number, beatsPerBar = 4): string {
113
+ if (!Number.isFinite(beats)) return '1.1';
114
+ const bpb = Math.max(1, beatsPerBar);
115
+ const total = Math.max(0, beats);
116
+ const bar = Math.floor(total / bpb) + 1;
117
+ const beatInBar = total - (bar - 1) * bpb;
118
+ const beat = Math.floor(beatInBar + 1e-9) + 1;
119
+ const fraction = beatInBar - (beat - 1);
120
+ if (fraction < 0.005) return `${bar}.${beat}`;
121
+ return `${bar}.${beat}.${Math.round(fraction * 100)
122
+ .toString()
123
+ .padStart(2, '0')}`;
124
+ }
125
+
126
+ export interface RulerTickOptions {
127
+ scale: TimeScale;
128
+ /** Timeline length in units. */
129
+ length: number;
130
+ /** Minimum pixels between labelled ticks. */
131
+ minLabelSpacing?: number;
132
+ /** Minimum pixels between minor ticks. */
133
+ minMinorSpacing?: number;
134
+ }
135
+
136
+ /**
137
+ * Labelled major ticks and unlabelled minor ticks covering `0..length`. The
138
+ * major step grows with zoom-out so labels never crowd; minors subdivide it
139
+ * when there is room. Beat rulers label bars (`1`, `2`, …) once the major
140
+ * step reaches a bar, and `bar.beat` before that.
141
+ */
142
+ export function rulerTicks(options: RulerTickOptions): RulerTick[] {
143
+ const { scale, length } = options;
144
+ const ppu = scale.pixelsPerUnit;
145
+ if (!Number.isFinite(length) || length <= 0 || !Number.isFinite(ppu) || ppu <= 0) return [];
146
+ const minLabel = options.minLabelSpacing ?? 64;
147
+ const minMinor = options.minMinorSpacing ?? 10;
148
+ const bpb = scale.beatsPerBar ?? 4;
149
+ const steps = scale.unit === 'beats' ? beatSteps(bpb) : SECOND_STEPS;
150
+ const major = pickStep(steps, ppu, minLabel);
151
+ const minorCandidates = steps.filter((step) => step < major && Number.isInteger(major / step));
152
+ const minor = minorCandidates.reverse().find((step) => step * ppu >= minMinor);
153
+
154
+ const ticks: RulerTick[] = [];
155
+ const step = minor ?? major;
156
+ const count = Math.floor(length / step + 1e-9);
157
+ for (let i = 0; i <= count; i += 1) {
158
+ const time = Math.round(i * step * 1e6) / 1e6;
159
+ if (time > length + 1e-9) break;
160
+ const isMajor = Math.abs(time / major - Math.round(time / major)) < 1e-6;
161
+ ticks.push({
162
+ time,
163
+ major: isMajor,
164
+ label: isMajor ? tickLabel(time, scale, major) : undefined,
165
+ });
166
+ }
167
+ return ticks;
168
+ }
169
+
170
+ function tickLabel(time: number, scale: TimeScale, major: number): string {
171
+ if (scale.unit === 'seconds') return formatClock(time);
172
+ const bpb = scale.beatsPerBar ?? 4;
173
+ if (major >= bpb) return `${Math.floor(time / bpb + 1e-9) + 1}`;
174
+ return formatBeats(time, bpb);
175
+ }
176
+
177
+ /** Times of the ticks with a label, handy for `TimelineTrack`'s `ticks`. */
178
+ export function majorTickTimes(options: RulerTickOptions): number[] {
179
+ return rulerTicks(options)
180
+ .filter((tick) => tick.major)
181
+ .map((tick) => tick.time);
182
+ }