dialkit 1.2.1 → 1.4.0

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