dialkit 1.3.0 → 1.4.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.
Files changed (71) hide show
  1. package/README.md +433 -3
  2. package/dist/icons.d.ts +5 -1
  3. package/dist/icons.js +18 -0
  4. package/dist/icons.js.map +1 -1
  5. package/dist/index.cjs +3222 -479
  6. package/dist/index.cjs.map +1 -1
  7. package/dist/index.d.cts +274 -6
  8. package/dist/index.d.ts +274 -6
  9. package/dist/index.js +3154 -416
  10. package/dist/index.js.map +1 -1
  11. package/dist/solid/index.d.ts +225 -3
  12. package/dist/solid/index.js +5761 -2500
  13. package/dist/solid/index.js.map +1 -1
  14. package/dist/store/index.cjs +57 -16
  15. package/dist/store/index.cjs.map +1 -1
  16. package/dist/store/index.d.cts +14 -2
  17. package/dist/store/index.d.ts +14 -2
  18. package/dist/store/index.js +52 -16
  19. package/dist/store/index.js.map +1 -1
  20. package/dist/styles.css +788 -0
  21. package/dist/svelte/components/ControlRenderer.svelte +5 -2
  22. package/dist/svelte/components/ControlRenderer.svelte.d.ts +2 -0
  23. package/dist/svelte/components/ControlRenderer.svelte.d.ts.map +1 -1
  24. package/dist/svelte/components/DialRoot.svelte +43 -6
  25. package/dist/svelte/components/DialRoot.svelte.d.ts.map +1 -1
  26. package/dist/svelte/components/Panel.svelte +7 -1
  27. package/dist/svelte/components/Panel.svelte.d.ts +2 -0
  28. package/dist/svelte/components/Panel.svelte.d.ts.map +1 -1
  29. package/dist/svelte/components/Timeline/ClipPopover.svelte +206 -0
  30. package/dist/svelte/components/Timeline/ClipPopover.svelte.d.ts +26 -0
  31. package/dist/svelte/components/Timeline/ClipPopover.svelte.d.ts.map +1 -0
  32. package/dist/svelte/components/Timeline/DialTimeline.svelte +125 -0
  33. package/dist/svelte/components/Timeline/DialTimeline.svelte.d.ts +13 -0
  34. package/dist/svelte/components/Timeline/DialTimeline.svelte.d.ts.map +1 -0
  35. package/dist/svelte/components/Timeline/TimelineClip.svelte +233 -0
  36. package/dist/svelte/components/Timeline/TimelineClip.svelte.d.ts +24 -0
  37. package/dist/svelte/components/Timeline/TimelineClip.svelte.d.ts.map +1 -0
  38. package/dist/svelte/components/Timeline/TimelineSection.svelte +803 -0
  39. package/dist/svelte/components/Timeline/TimelineSection.svelte.d.ts +12 -0
  40. package/dist/svelte/components/Timeline/TimelineSection.svelte.d.ts.map +1 -0
  41. package/dist/svelte/components/Timeline/TimelineToggleButton.svelte +25 -0
  42. package/dist/svelte/components/Timeline/TimelineToggleButton.svelte.d.ts +4 -0
  43. package/dist/svelte/components/Timeline/TimelineToggleButton.svelte.d.ts.map +1 -0
  44. package/dist/svelte/components/TransitionControl.svelte +26 -11
  45. package/dist/svelte/components/TransitionControl.svelte.d.ts +9 -0
  46. package/dist/svelte/components/TransitionControl.svelte.d.ts.map +1 -1
  47. package/dist/svelte/createDialTimeline.svelte.d.ts +4 -0
  48. package/dist/svelte/createDialTimeline.svelte.d.ts.map +1 -0
  49. package/dist/svelte/createDialTimeline.svelte.js +73 -0
  50. package/dist/svelte/index.d.ts +4 -0
  51. package/dist/svelte/index.d.ts.map +1 -1
  52. package/dist/svelte/index.js +3 -0
  53. package/dist/svelte/theme-css.d.ts +1 -1
  54. package/dist/svelte/theme-css.d.ts.map +1 -1
  55. package/dist/svelte/theme-css.js +788 -0
  56. package/dist/timeline/index.cjs +1288 -0
  57. package/dist/timeline/index.cjs.map +1 -0
  58. package/dist/timeline/index.d.cts +443 -0
  59. package/dist/timeline/index.d.ts +443 -0
  60. package/dist/timeline/index.js +1233 -0
  61. package/dist/timeline/index.js.map +1 -0
  62. package/dist/vue/index.d.ts +273 -7
  63. package/dist/vue/index.js +4116 -1517
  64. package/dist/vue/index.js.map +1 -1
  65. package/package.json +23 -13
  66. package/dist/solid/index.cjs +0 -3536
  67. package/dist/solid/index.cjs.map +0 -1
  68. package/dist/solid/index.d.cts +0 -295
  69. package/dist/vue/index.cjs +0 -3497
  70. package/dist/vue/index.cjs.map +0 -1
  71. package/dist/vue/index.d.cts +0 -722
@@ -0,0 +1,803 @@
1
+ <script lang="ts">
2
+ import { untrack } from 'svelte';
3
+ import { DialStore, formatLabel } from 'dialkit/store';
4
+ import type { DialValue } from 'dialkit/store';
5
+ import {
6
+ TimelineStore,
7
+ buildCopyInstruction,
8
+ clamp,
9
+ computeClipStaticFromValues,
10
+ formatClock,
11
+ normalizeTimelineValuesForCopy,
12
+ } from 'dialkit/timeline';
13
+ import type {
14
+ TimelineClipMeta,
15
+ TimelineClipStatic,
16
+ TimelineMeta,
17
+ TimelineTrackStatic,
18
+ TimelineTransport,
19
+ } from 'dialkit/timeline';
20
+ import {
21
+ ICON_ADD_PRESET,
22
+ ICON_CHEVRON,
23
+ ICON_CHECK,
24
+ ICON_CLIPBOARD,
25
+ ICON_PAUSE,
26
+ ICON_PLAY,
27
+ ICON_REPLAY,
28
+ } from '../../../icons';
29
+ import { findControl } from '../../../shortcut-utils';
30
+ import PresetManager from '../PresetManager.svelte';
31
+ import type { DialTheme } from '../DialRoot.svelte';
32
+ import ClipPopover from './ClipPopover.svelte';
33
+ import type { PopoverState } from './ClipPopover.svelte';
34
+ import TimelineClip from './TimelineClip.svelte';
35
+
36
+ const DRAG_THRESHOLD_PX = 3;
37
+ const MAJOR_TICK_TARGET_PX = 140;
38
+ const MILLISECOND_STEP = 0.001;
39
+ const SECOND_TICK_STEPS = [
40
+ 0.001, 0.002, 0.005,
41
+ 0.01, 0.02, 0.05,
42
+ 0.1, 0.2, 0.5,
43
+ 1, 2, 5, 10, 15, 30, 60, 120, 300, 600,
44
+ ];
45
+ const MIN_TIMELINE_MAX_ZOOM = 8;
46
+ const PLAYHEAD_FLAG_WIDTH = 52;
47
+ const PLAYHEAD_FLAG_EDGE_OVERHANG = 1;
48
+ const ZOOM_DRAG_DISTANCE = 180;
49
+
50
+ type ZoomDragState = {
51
+ pointerX: number;
52
+ rect: DOMRect;
53
+ zoom: number;
54
+ viewStart: number;
55
+ anchorRatio: number;
56
+ anchorTime: number;
57
+ moved: boolean;
58
+ };
59
+
60
+ type ScrubState = {
61
+ wasPlaying: boolean;
62
+ rect: DOMRect;
63
+ viewStart: number;
64
+ visibleDuration: number;
65
+ };
66
+
67
+ type GroupRow = {
68
+ kind: 'group';
69
+ key: string;
70
+ group: string;
71
+ collapsed: boolean;
72
+ };
73
+
74
+ type ClipRow = {
75
+ kind: 'clip';
76
+ key: string;
77
+ clip: TimelineClipMeta;
78
+ stat: TimelineClipStatic;
79
+ tracksOpen: boolean;
80
+ selected: boolean;
81
+ };
82
+
83
+ type TrackRow = {
84
+ kind: 'track';
85
+ key: string;
86
+ parent: TimelineClipMeta;
87
+ clip: TimelineClipMeta;
88
+ stat: TimelineClipStatic;
89
+ track: TimelineTrackStatic;
90
+ selected: boolean;
91
+ };
92
+
93
+ let { meta, defaultOpen, theme, dockVisible } = $props<{
94
+ meta: TimelineMeta;
95
+ defaultOpen: boolean;
96
+ theme: DialTheme;
97
+ dockVisible: boolean;
98
+ }>();
99
+
100
+ let open = $state(untrack(() => defaultOpen));
101
+ let copied = $state(false);
102
+ let popover = $state<PopoverState | null>(null);
103
+ let collapsedGroups = $state(new Set<string>());
104
+ let expandedTracks = $state(new Set<string>());
105
+ let zoom = $state(1);
106
+ let viewStart = $state(0);
107
+ let values = $state<Record<string, DialValue>>(DialStore.getValues(untrack(() => meta.id)));
108
+ let transport = $state<TimelineTransport>(TimelineStore.getTransport(untrack(() => meta.id)));
109
+ let laneArea = $state<HTMLDivElement>();
110
+ let horizontalScrollElement = $state<HTMLDivElement>();
111
+ let laneWidth = $state(0);
112
+
113
+ let zoomDrag: ZoomDragState | null = null;
114
+ let rulerScrub: ScrubState | null = null;
115
+ let trackScrub: ScrubState | null = null;
116
+ let overviewScrub: { wasPlaying: boolean; rect: DOMRect } | null = null;
117
+ let playheadScrub: ScrubState | null = null;
118
+
119
+ const presets = $derived.by(() => {
120
+ values;
121
+ return DialStore.getPresets(meta.id);
122
+ });
123
+ const activePresetId = $derived.by(() => {
124
+ values;
125
+ return DialStore.getActivePresetId(meta.id);
126
+ });
127
+ const visibleDuration = $derived(meta.duration > 0 ? meta.duration / zoom : meta.duration);
128
+ const safeViewStart = $derived(clampViewStart(viewStart, meta.duration, visibleDuration));
129
+ const viewEnd = $derived(safeViewStart + visibleDuration);
130
+ const pxPerSecond = $derived(visibleDuration > 0 && laneWidth > 0 ? laneWidth / visibleDuration : 0);
131
+ const maxZoom = $derived(Math.max(
132
+ MIN_TIMELINE_MAX_ZOOM,
133
+ laneWidth > 0 && meta.duration > 0
134
+ ? (MAJOR_TICK_TARGET_PX * meta.duration) / (MILLISECOND_STEP * 10 * laneWidth)
135
+ : MIN_TIMELINE_MAX_ZOOM
136
+ ));
137
+ const playheadX = $derived(clamp((transport.time - safeViewStart) * pxPerSecond, 0, laneWidth));
138
+ const playheadVisible = $derived(
139
+ transport.time >= safeViewStart && transport.time <= viewEnd && laneWidth > 0
140
+ );
141
+ const playheadFlagCenter = $derived(clamp(
142
+ playheadX,
143
+ PLAYHEAD_FLAG_WIDTH / 2 - PLAYHEAD_FLAG_EDGE_OVERHANG,
144
+ laneWidth - PLAYHEAD_FLAG_WIDTH / 2 + PLAYHEAD_FLAG_EDGE_OVERHANG
145
+ ));
146
+ const playheadFlagOffset = $derived(playheadFlagCenter - playheadX);
147
+ const playheadEdge = $derived(
148
+ playheadFlagOffset > 0.5 ? 'start' : playheadFlagOffset < -0.5 ? 'end' : 'center'
149
+ );
150
+ const overviewViewportWidth = $derived(
151
+ meta.duration > 0 ? ((viewEnd - safeViewStart) / meta.duration) * 100 : 100
152
+ );
153
+ const overviewPlayheadPercent = $derived(
154
+ meta.duration > 0 ? (transport.time / meta.duration) * 100 : 0
155
+ );
156
+
157
+ const ticks = $derived.by(() => {
158
+ const rawStep = pxPerSecond > 0 ? MAJOR_TICK_TARGET_PX / pxPerSecond : 1;
159
+ const adaptive = SECOND_TICK_STEPS.find((step) => step >= rawStep)
160
+ ?? SECOND_TICK_STEPS[SECOND_TICK_STEPS.length - 1];
161
+ const majorStep = zoom < 1.5 && meta.duration >= 1 ? Math.max(1, adaptive) : adaptive;
162
+ const fineStep = majorStep / 10;
163
+ const major: number[] = [];
164
+ const medium: number[] = [];
165
+ const fine: number[] = [];
166
+ const firstMajor = Math.ceil((safeViewStart - 1e-6) / majorStep) * majorStep;
167
+ for (let time = firstMajor; time <= viewEnd + 1e-6; time += majorStep) {
168
+ major.push(Number(time.toFixed(4)));
169
+ }
170
+ const firstFine = Math.ceil((safeViewStart - 1e-6) / fineStep);
171
+ const lastFine = Math.floor((viewEnd + 1e-6) / fineStep);
172
+ for (let index = firstFine; index <= lastFine; index++) {
173
+ if (index % 10 === 0) continue;
174
+ const tick = Number((index * fineStep).toFixed(6));
175
+ if (index % 5 === 0) medium.push(tick);
176
+ else fine.push(tick);
177
+ }
178
+ return { major, medium, fine, majorStep };
179
+ });
180
+
181
+ const rows = $derived.by(() => {
182
+ const result: Array<GroupRow | ClipRow | TrackRow> = [];
183
+ let lastGroup: string | undefined;
184
+ for (const clip of meta.clips) {
185
+ if (clip.group !== lastGroup) {
186
+ lastGroup = clip.group;
187
+ if (clip.group) {
188
+ result.push({
189
+ kind: 'group',
190
+ key: `group:${clip.group}`,
191
+ group: clip.group,
192
+ collapsed: collapsedGroups.has(clip.group),
193
+ });
194
+ }
195
+ }
196
+ if (clip.group && collapsedGroups.has(clip.group)) continue;
197
+ const tracksOpen = Boolean(clip.tracks?.length) && expandedTracks.has(clip.key);
198
+ const stat = computeClipStaticFromValues(values, clip, meta.duration);
199
+ result.push({
200
+ kind: 'clip',
201
+ key: clip.key,
202
+ clip,
203
+ stat,
204
+ tracksOpen,
205
+ selected: popover?.clip.key === clip.key,
206
+ });
207
+ if (!tracksOpen) continue;
208
+ for (const trackRef of clip.tracks ?? []) {
209
+ const track = stat.tracks.find((candidate) => candidate.prop === trackRef.prop);
210
+ if (!track) continue;
211
+ const key = `${clip.key}.${trackRef.prop}`;
212
+ result.push({
213
+ kind: 'track',
214
+ key,
215
+ parent: clip,
216
+ clip: {
217
+ key,
218
+ label: `${clip.label} · ${formatLabel(trackRef.prop)}`,
219
+ color: clip.color,
220
+ loop: clip.loop,
221
+ group: clip.group,
222
+ stepKeys: trackRef.stepKeys,
223
+ },
224
+ stat,
225
+ track,
226
+ selected: popover?.clip.key === key,
227
+ });
228
+ }
229
+ }
230
+ return result;
231
+ });
232
+
233
+ $effect(() => {
234
+ values = DialStore.getValues(meta.id);
235
+ transport = TimelineStore.getTransport(meta.id);
236
+ const unsubscribeValues = DialStore.subscribe(meta.id, () => {
237
+ values = DialStore.getValues(meta.id);
238
+ });
239
+ const unsubscribeTransport = TimelineStore.subscribe(meta.id, () => {
240
+ transport = TimelineStore.getTransport(meta.id);
241
+ });
242
+ return () => {
243
+ unsubscribeValues();
244
+ unsubscribeTransport();
245
+ };
246
+ });
247
+
248
+ $effect(() => {
249
+ if (!open || !laneArea) return;
250
+ const measure = () => {
251
+ if (laneArea) laneWidth = laneArea.getBoundingClientRect().width;
252
+ };
253
+ measure();
254
+ const observer = new ResizeObserver(measure);
255
+ observer.observe(laneArea);
256
+ return () => observer.disconnect();
257
+ });
258
+
259
+ $effect(() => {
260
+ const nextZoom = clamp(zoom, 1, maxZoom);
261
+ if (nextZoom !== zoom) zoom = nextZoom;
262
+ const nextStart = clampViewStart(viewStart, meta.duration, meta.duration / zoom);
263
+ if (nextStart !== viewStart) viewStart = nextStart;
264
+ });
265
+
266
+ $effect(() => {
267
+ if (!open || !horizontalScrollElement || pxPerSecond <= 0) return;
268
+ const next = safeViewStart * pxPerSecond;
269
+ if (Math.abs(horizontalScrollElement.scrollLeft - next) > 0.5) {
270
+ horizontalScrollElement.scrollLeft = next;
271
+ }
272
+ });
273
+
274
+ $effect(() => {
275
+ if (!dockVisible) popover = null;
276
+ });
277
+
278
+ function centerViewAt(time: number) {
279
+ if (zoom <= 1 || meta.duration <= 0) return;
280
+ const duration = meta.duration / zoom;
281
+ viewStart = clampViewStart(time - duration / 2, meta.duration, duration);
282
+ }
283
+
284
+ function resetView() {
285
+ zoom = 1;
286
+ viewStart = 0;
287
+ }
288
+
289
+ function handleReplay() {
290
+ viewStart = 0;
291
+ TimelineStore.replay(meta.id);
292
+ }
293
+
294
+ function handleHorizontalScroll(event: Event) {
295
+ if (pxPerSecond <= 0) return;
296
+ viewStart = clampViewStart(
297
+ (event.currentTarget as HTMLDivElement).scrollLeft / pxPerSecond,
298
+ meta.duration,
299
+ visibleDuration
300
+ );
301
+ }
302
+
303
+ function handleTimelineWheel(event: WheelEvent) {
304
+ if (!horizontalScrollElement || zoom <= 1) return;
305
+ const delta = Math.abs(event.deltaX) > Math.abs(event.deltaY)
306
+ ? event.deltaX
307
+ : event.shiftKey
308
+ ? event.deltaY
309
+ : 0;
310
+ if (delta === 0) return;
311
+ event.preventDefault();
312
+ horizontalScrollElement.scrollLeft += delta;
313
+ }
314
+
315
+ function seek(scrub: ScrubState | null, clientX: number) {
316
+ if (!scrub || scrub.rect.width <= 0) return;
317
+ TimelineStore.seek(meta.id, clamp(
318
+ scrub.viewStart + ((clientX - scrub.rect.left) / scrub.rect.width) * scrub.visibleDuration,
319
+ scrub.viewStart,
320
+ scrub.viewStart + scrub.visibleDuration
321
+ ));
322
+ }
323
+
324
+ function handleRulerPointerDown(event: PointerEvent) {
325
+ event.preventDefault();
326
+ event.stopPropagation();
327
+ const target = event.currentTarget as HTMLDivElement;
328
+ const rect = target.getBoundingClientRect();
329
+ if (rect.width <= 0) return;
330
+ target.setPointerCapture(event.pointerId);
331
+ if (!event.altKey) {
332
+ const reset = event.shiftKey;
333
+ rulerScrub = {
334
+ wasPlaying: TimelineStore.getTransport(meta.id).playing,
335
+ rect,
336
+ viewStart: reset ? 0 : safeViewStart,
337
+ visibleDuration: reset ? meta.duration : visibleDuration,
338
+ };
339
+ if (reset) resetView();
340
+ TimelineStore.pause(meta.id);
341
+ seek(rulerScrub, event.clientX);
342
+ return;
343
+ }
344
+ const ratio = clamp((event.clientX - rect.left) / rect.width, 0, 1);
345
+ zoomDrag = {
346
+ pointerX: event.clientX,
347
+ rect,
348
+ zoom,
349
+ viewStart: safeViewStart,
350
+ anchorRatio: ratio,
351
+ anchorTime: safeViewStart + ratio * visibleDuration,
352
+ moved: false,
353
+ };
354
+ }
355
+
356
+ function handleRulerPointerMove(event: PointerEvent) {
357
+ if (rulerScrub) {
358
+ seek(rulerScrub, event.clientX);
359
+ return;
360
+ }
361
+ if (!zoomDrag || meta.duration <= 0) return;
362
+ const dx = event.clientX - zoomDrag.pointerX;
363
+ if (!zoomDrag.moved && Math.abs(dx) <= DRAG_THRESHOLD_PX) return;
364
+ zoomDrag.moved = true;
365
+ const nextZoom = clamp(zoomDrag.zoom * Math.exp(dx / ZOOM_DRAG_DISTANCE), 1, maxZoom);
366
+ const nextDuration = meta.duration / nextZoom;
367
+ zoom = nextZoom;
368
+ viewStart = clampViewStart(
369
+ zoomDrag.anchorTime - zoomDrag.anchorRatio * nextDuration,
370
+ meta.duration,
371
+ nextDuration
372
+ );
373
+ }
374
+
375
+ function finishRuler() {
376
+ if (rulerScrub?.wasPlaying) TimelineStore.play(meta.id);
377
+ rulerScrub = null;
378
+ zoomDrag = null;
379
+ }
380
+
381
+ function handleTrackPointerDown(event: PointerEvent) {
382
+ const target = event.target as HTMLElement;
383
+ if (target.closest('.dialkit-timeline-label, button')) return;
384
+ if (!event.shiftKey && target.closest('.dialkit-timeline-clip')) return;
385
+ const rect = laneArea?.getBoundingClientRect();
386
+ if (!rect) return;
387
+ event.preventDefault();
388
+ (event.currentTarget as HTMLElement).setPointerCapture(event.pointerId);
389
+ const reset = event.shiftKey;
390
+ trackScrub = {
391
+ wasPlaying: TimelineStore.getTransport(meta.id).playing,
392
+ rect,
393
+ viewStart: reset ? 0 : safeViewStart,
394
+ visibleDuration: reset ? meta.duration : visibleDuration,
395
+ };
396
+ if (reset) resetView();
397
+ popover = null;
398
+ TimelineStore.pause(meta.id);
399
+ seek(trackScrub, event.clientX);
400
+ }
401
+
402
+ function finishTrack() {
403
+ if (trackScrub?.wasPlaying) TimelineStore.play(meta.id);
404
+ trackScrub = null;
405
+ }
406
+
407
+ function handleOverviewPointerDown(event: PointerEvent) {
408
+ event.preventDefault();
409
+ const target = event.currentTarget as HTMLElement;
410
+ target.setPointerCapture(event.pointerId);
411
+ overviewScrub = {
412
+ wasPlaying: TimelineStore.getTransport(meta.id).playing,
413
+ rect: target.getBoundingClientRect(),
414
+ };
415
+ TimelineStore.pause(meta.id);
416
+ seekOverview(event.clientX);
417
+ }
418
+
419
+ function seekOverview(clientX: number) {
420
+ if (!overviewScrub || overviewScrub.rect.width <= 0 || meta.duration <= 0) return;
421
+ const next = clamp(
422
+ ((clientX - overviewScrub.rect.left) / overviewScrub.rect.width) * meta.duration,
423
+ 0,
424
+ meta.duration
425
+ );
426
+ TimelineStore.seek(meta.id, next);
427
+ centerViewAt(next);
428
+ }
429
+
430
+ function finishOverview() {
431
+ if (overviewScrub?.wasPlaying) TimelineStore.play(meta.id);
432
+ overviewScrub = null;
433
+ }
434
+
435
+ function handlePlayheadPointerDown(event: PointerEvent) {
436
+ const rect = laneArea?.getBoundingClientRect();
437
+ if (!rect) return;
438
+ event.preventDefault();
439
+ event.stopPropagation();
440
+ (event.currentTarget as HTMLElement).setPointerCapture(event.pointerId);
441
+ const reset = event.shiftKey;
442
+ playheadScrub = {
443
+ wasPlaying: TimelineStore.getTransport(meta.id).playing,
444
+ rect,
445
+ viewStart: reset ? 0 : safeViewStart,
446
+ visibleDuration: reset ? meta.duration : visibleDuration,
447
+ };
448
+ if (reset) resetView();
449
+ TimelineStore.pause(meta.id);
450
+ seek(playheadScrub, event.clientX);
451
+ }
452
+
453
+ function finishPlayhead() {
454
+ if (playheadScrub?.wasPlaying) TimelineStore.play(meta.id);
455
+ playheadScrub = null;
456
+ }
457
+
458
+ function handleCopy() {
459
+ const normalized = normalizeTimelineValuesForCopy(DialStore.getValues(meta.id), meta.clips);
460
+ void navigator.clipboard.writeText(buildCopyInstruction('createDialTimeline', meta.name, normalized));
461
+ copied = true;
462
+ window.setTimeout(() => { copied = false; }, 1500);
463
+ }
464
+
465
+ function handleAddPreset() {
466
+ DialStore.savePreset(meta.id, `Version ${presets.length + 2}`);
467
+ }
468
+
469
+ function toggleSet(current: Set<string>, key: string): Set<string> {
470
+ const next = new Set(current);
471
+ if (next.has(key)) next.delete(key);
472
+ else next.add(key);
473
+ return next;
474
+ }
475
+
476
+ function toggleTracks(key: string) {
477
+ expandedTracks = toggleSet(expandedTracks, key);
478
+ }
479
+
480
+ function toggleGroup(key: string) {
481
+ collapsedGroups = toggleSet(collapsedGroups, key);
482
+ }
483
+
484
+ function clipPopoverExclusions(clip: TimelineClipMeta): Set<string> {
485
+ return new Set([...(clip.stepKeys ?? []), ...(clip.tracks?.map((track) => track.prop) ?? [])]);
486
+ }
487
+
488
+ function getClipControls(path: string, exclusions?: Set<string>) {
489
+ const panel = DialStore.getPanel(meta.id);
490
+ const folder = panel ? findControl(panel.controls, path) : null;
491
+ if (!folder?.children) return [];
492
+ return folder.children.filter((control) => {
493
+ const key = control.path.slice(path.length + 1);
494
+ return key !== 'at' && key !== 'duration' && !exclusions?.has(key);
495
+ });
496
+ }
497
+
498
+ function openClipPopover(clip: TimelineClipMeta, rect: DOMRect, stepKey?: string) {
499
+ const targetPath = stepKey ? `${clip.key}.${stepKey}` : clip.key;
500
+ if (getClipControls(targetPath, stepKey ? undefined : clipPopoverExclusions(clip)).length === 0) return;
501
+ popover = popover?.clip.key === clip.key && popover.stepKey === stepKey
502
+ ? null
503
+ : {
504
+ clip,
505
+ stepKey,
506
+ anchor: {
507
+ left: rect.left,
508
+ top: rect.top,
509
+ right: rect.right,
510
+ bottom: rect.bottom,
511
+ width: rect.width,
512
+ height: rect.height,
513
+ },
514
+ };
515
+ }
516
+
517
+ function handleBarClick(clip: TimelineClipMeta, rect: DOMRect, stepKey?: string) {
518
+ if (!stepKey && clip.tracks?.length) toggleTracks(clip.key);
519
+ else openClipPopover(clip, rect, stepKey);
520
+ }
521
+
522
+ function formatRulerSeconds(time: number, step: number): string {
523
+ if (step >= 1 && Number.isInteger(time)) return formatClock(time);
524
+ const decimals = Math.min(3, Math.max(1, Math.ceil(-Math.log10(step))));
525
+ return `${time.toFixed(decimals)}s`;
526
+ }
527
+
528
+ function clampViewStart(start: number, duration: number, shownDuration: number): number {
529
+ return clamp(start, 0, Math.max(0, duration - shownDuration));
530
+ }
531
+ </script>
532
+
533
+ <div class="dialkit-timeline-section">
534
+ <div class="dialkit-timeline-header" data-open={open || undefined}>
535
+ <div class="dialkit-timeline-identity">
536
+ <span class="dialkit-timeline-title">{meta.name}</span>
537
+ </div>
538
+
539
+ {#if !open}
540
+ <div
541
+ class="dialkit-timeline-overview"
542
+ onpointerdown={handleOverviewPointerDown}
543
+ onpointermove={(event) => overviewScrub && seekOverview(event.clientX)}
544
+ onpointerup={finishOverview}
545
+ onpointercancel={finishOverview}
546
+ onlostpointercapture={finishOverview}
547
+ title="Drag to scrub the full timeline"
548
+ role="slider"
549
+ tabindex="0"
550
+ aria-label="Timeline overview"
551
+ aria-valuemin={0}
552
+ aria-valuemax={meta.duration}
553
+ aria-valuenow={transport.time}
554
+ >
555
+ <div
556
+ class="dialkit-timeline-overview-viewport"
557
+ data-zoomed={overviewViewportWidth < 99.999 || undefined}
558
+ style:left={`${meta.duration > 0 ? (safeViewStart / meta.duration) * 100 : 0}%`}
559
+ style:width={`${overviewViewportWidth}%`}
560
+ ></div>
561
+ <div class="dialkit-timeline-overview-progress" style:width={`${overviewPlayheadPercent}%`}></div>
562
+ <div class="dialkit-timeline-overview-playhead" style:left={`${overviewPlayheadPercent}%`}></div>
563
+ </div>
564
+ {/if}
565
+
566
+ <div class="dialkit-timeline-actions">
567
+ <button
568
+ class="dialkit-toolbar-add"
569
+ onclick={() => transport.playing ? TimelineStore.pause(meta.id) : TimelineStore.play(meta.id)}
570
+ title={transport.playing ? 'Pause' : 'Play'}
571
+ aria-label={transport.playing ? 'Pause' : 'Play'}
572
+ >
573
+ <span style="position:relative;width:16px;height:16px;">
574
+ {#if transport.playing}
575
+ <svg viewBox="0 0 24 24" fill="none" aria-hidden="true" style="position:absolute;inset:0;width:16px;height:16px;color:var(--dial-text-label);">
576
+ {#each ICON_PAUSE as path}<path d={path} fill="currentColor" />{/each}
577
+ </svg>
578
+ {:else}
579
+ <svg viewBox="0 0 24 24" fill="none" aria-hidden="true" style="position:absolute;inset:0;width:16px;height:16px;color:var(--dial-text-label);">
580
+ <path d={ICON_PLAY} fill="currentColor" />
581
+ </svg>
582
+ {/if}
583
+ </span>
584
+ </button>
585
+ <button class="dialkit-toolbar-add" onclick={handleReplay} title="Replay" aria-label="Replay">
586
+ <svg viewBox="0 0 24 24" fill="none" aria-hidden="true" style="width:16px;height:16px;color:var(--dial-text-label);">
587
+ {#each ICON_REPLAY as path}<path d={path} fill="currentColor" />{/each}
588
+ </svg>
589
+ </button>
590
+ <button class="dialkit-toolbar-add" onclick={handleAddPreset} title="Add timeline version" aria-label="Add timeline version">
591
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
592
+ {#each ICON_ADD_PRESET as path}<path d={path} />{/each}
593
+ </svg>
594
+ </button>
595
+ <PresetManager panelId={meta.id} {presets} {activePresetId} />
596
+ <button
597
+ class="dialkit-toolbar-add"
598
+ onclick={handleCopy}
599
+ title="Copy parameters"
600
+ aria-label={copied ? 'Copied parameters' : 'Copy parameters'}
601
+ >
602
+ <span style="position:relative;width:16px;height:16px;">
603
+ {#if copied}
604
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" style="position:absolute;inset:0;width:16px;height:16px;color:var(--dial-text-label);">
605
+ <path d={ICON_CHECK} />
606
+ </svg>
607
+ {:else}
608
+ <svg viewBox="0 0 24 24" fill="none" aria-hidden="true" style="position:absolute;inset:0;width:16px;height:16px;color:var(--dial-text-label);">
609
+ <path d={ICON_CLIPBOARD.board} stroke="currentColor" stroke-width="2" stroke-linejoin="round" />
610
+ <path d={ICON_CLIPBOARD.sparkle} fill="currentColor" />
611
+ <path d={ICON_CLIPBOARD.body} stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
612
+ </svg>
613
+ {/if}
614
+ </span>
615
+ </button>
616
+ <button
617
+ class="dialkit-timeline-chevron"
618
+ data-open={open}
619
+ aria-expanded={open}
620
+ onclick={() => { open = !open; }}
621
+ title={open ? 'Collapse timeline' : 'Expand timeline'}
622
+ >
623
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
624
+ <path d={ICON_CHEVRON} />
625
+ </svg>
626
+ </button>
627
+ </div>
628
+ </div>
629
+
630
+ {#if open}
631
+ <div
632
+ class="dialkit-timeline-body"
633
+ onwheel={handleTimelineWheel}
634
+ onpointerdown={handleTrackPointerDown}
635
+ onpointermove={(event) => trackScrub && seek(trackScrub, event.clientX)}
636
+ onpointerup={finishTrack}
637
+ onpointercancel={finishTrack}
638
+ onlostpointercapture={finishTrack}
639
+ role="presentation"
640
+ >
641
+ <div class="dialkit-timeline-grid">
642
+ <div class="dialkit-timeline-row dialkit-timeline-ruler-row">
643
+ <div class="dialkit-timeline-label"></div>
644
+ <div
645
+ bind:this={laneArea}
646
+ class="dialkit-timeline-ruler"
647
+ onpointerdown={handleRulerPointerDown}
648
+ onpointermove={handleRulerPointerMove}
649
+ onpointerup={finishRuler}
650
+ onpointercancel={finishRuler}
651
+ onlostpointercapture={finishRuler}
652
+ title="Drag to seek · Option-drag to zoom · Shift-drag to reset zoom"
653
+ role="slider"
654
+ tabindex="0"
655
+ aria-label="Timeline ruler"
656
+ aria-valuemin={safeViewStart}
657
+ aria-valuemax={viewEnd}
658
+ aria-valuenow={transport.time}
659
+ >
660
+ {#each ticks.fine as time}
661
+ <div class="dialkit-timeline-tick dialkit-timeline-tick-fine" style:left={`${(time - safeViewStart) * pxPerSecond}px`}></div>
662
+ {/each}
663
+ {#each ticks.medium as time}
664
+ <div class="dialkit-timeline-tick dialkit-timeline-tick-medium" style:left={`${(time - safeViewStart) * pxPerSecond}px`}></div>
665
+ {/each}
666
+ {#each ticks.major as time}
667
+ <div class="dialkit-timeline-tick" style:left={`${(time - safeViewStart) * pxPerSecond}px`}>
668
+ <span class="dialkit-timeline-tick-label">{formatRulerSeconds(time, ticks.majorStep)}</span>
669
+ </div>
670
+ {/each}
671
+ </div>
672
+ </div>
673
+
674
+ {#each rows as row (row.key)}
675
+ {#if row.kind === 'group'}
676
+ <div class="dialkit-timeline-row dialkit-timeline-group-row">
677
+ <div class="dialkit-timeline-label">
678
+ <button
679
+ class="dialkit-timeline-group-toggle"
680
+ data-open={!row.collapsed}
681
+ onclick={() => toggleGroup(row.group)}
682
+ title={row.collapsed ? 'Expand layer' : 'Collapse layer'}
683
+ >
684
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
685
+ <path d={ICON_CHEVRON} />
686
+ </svg>
687
+ </button>
688
+ <span>{formatLabel(row.group)}</span>
689
+ </div>
690
+ <div class="dialkit-timeline-lane"></div>
691
+ </div>
692
+ {:else if row.kind === 'clip'}
693
+ <div class="dialkit-timeline-row" data-grouped={row.clip.group ? '' : undefined}>
694
+ <div class="dialkit-timeline-label">
695
+ {#if row.clip.tracks?.length}
696
+ <button
697
+ class="dialkit-timeline-group-toggle"
698
+ data-open={row.tracksOpen}
699
+ onclick={(event) => {
700
+ event.stopPropagation();
701
+ toggleTracks(row.clip.key);
702
+ }}
703
+ title={row.tracksOpen ? 'Collapse properties' : 'Expand properties'}
704
+ >
705
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
706
+ <path d={ICON_CHEVRON} />
707
+ </svg>
708
+ </button>
709
+ {/if}
710
+ {row.clip.label}
711
+ </div>
712
+ <div class="dialkit-timeline-lane">
713
+ <TimelineClip
714
+ timelineId={meta.id}
715
+ clip={row.clip}
716
+ at={row.stat.at}
717
+ duration={row.stat.duration}
718
+ loop={row.stat.loop}
719
+ steps={row.clip.stepKeys?.length ? row.stat.tracks[0]?.steps : undefined}
720
+ fixedDuration={row.clip.tracks?.length ? true : row.stat.isPhysics}
721
+ composite={Boolean(row.clip.tracks?.length)}
722
+ {pxPerSecond}
723
+ viewStart={safeViewStart}
724
+ timelineDuration={meta.duration}
725
+ selected={row.selected}
726
+ selectedStepKey={row.selected ? popover?.stepKey : undefined}
727
+ onClick={handleBarClick}
728
+ onDrag={() => { popover = null; }}
729
+ />
730
+ </div>
731
+ </div>
732
+ {:else}
733
+ <div class="dialkit-timeline-row dialkit-timeline-track-row" data-grouped={row.parent.group ? '' : undefined}>
734
+ <div class="dialkit-timeline-label">{formatLabel(row.track.prop ?? '')}</div>
735
+ <div class="dialkit-timeline-lane">
736
+ <TimelineClip
737
+ timelineId={meta.id}
738
+ clip={row.clip}
739
+ at={row.stat.at + row.track.delay}
740
+ duration={row.track.duration}
741
+ loop={row.stat.loop}
742
+ steps={row.clip.stepKeys?.length ? row.track.steps : undefined}
743
+ fixedDuration={!row.clip.stepKeys?.length && row.track.steps[0]?.isPhysics === true}
744
+ baseAt={row.stat.at}
745
+ delayMode={true}
746
+ {pxPerSecond}
747
+ viewStart={safeViewStart}
748
+ timelineDuration={meta.duration}
749
+ selected={row.selected}
750
+ selectedStepKey={row.selected ? popover?.stepKey : undefined}
751
+ onClick={openClipPopover}
752
+ onDrag={() => { popover = null; }}
753
+ />
754
+ </div>
755
+ </div>
756
+ {/if}
757
+ {/each}
758
+
759
+ {#if pxPerSecond > 0 && playheadVisible}
760
+ <div
761
+ class="dialkit-timeline-playhead-control"
762
+ data-edge={playheadEdge}
763
+ style={`left:calc(var(--dial-timeline-label-w) + ${playheadX}px);--dial-timeline-playhead-flag-offset:${playheadFlagOffset}px;`}
764
+ onpointerdown={handlePlayheadPointerDown}
765
+ onpointermove={(event) => playheadScrub && seek(playheadScrub, event.clientX)}
766
+ onpointerup={finishPlayhead}
767
+ onpointercancel={finishPlayhead}
768
+ onlostpointercapture={finishPlayhead}
769
+ role="slider"
770
+ tabindex="0"
771
+ aria-label="Timeline current time"
772
+ aria-valuemin={0}
773
+ aria-valuemax={meta.duration}
774
+ aria-valuenow={transport.time}
775
+ title="Drag to scrub the timeline"
776
+ >
777
+ <div class="dialkit-timeline-playhead-stem"></div>
778
+ <div class="dialkit-timeline-playhead-anchor">
779
+ <div class="dialkit-timeline-playhead-flag">{transport.time.toFixed(2)}</div>
780
+ </div>
781
+ </div>
782
+ {/if}
783
+ </div>
784
+ {#if zoom > 1}
785
+ <div class="dialkit-timeline-scroll-row">
786
+ <div class="dialkit-timeline-label"></div>
787
+ <div
788
+ bind:this={horizontalScrollElement}
789
+ class="dialkit-timeline-horizontal-scroll"
790
+ onscroll={handleHorizontalScroll}
791
+ aria-label="Timeline horizontal scroll"
792
+ >
793
+ <div style:width={`${laneWidth * zoom}px`}></div>
794
+ </div>
795
+ </div>
796
+ {/if}
797
+ </div>
798
+ {/if}
799
+
800
+ {#if popover}
801
+ <ClipPopover panelId={meta.id} {popover} {values} {theme} onClose={() => { popover = null; }} />
802
+ {/if}
803
+ </div>