@pixel-point/toolcraft 0.0.2

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 (257) hide show
  1. package/LICENSE.md +98 -0
  2. package/README.md +41 -0
  3. package/bin/create-toolcraft-app.mjs +8 -0
  4. package/bin/toolcraft.mjs +8 -0
  5. package/package.json +24 -0
  6. package/scripts/prepare-pack.mjs +29 -0
  7. package/src/cli.mjs +392 -0
  8. package/src/cli.test.mjs +284 -0
  9. package/src/copy-recursive.mjs +86 -0
  10. package/src/generate.mjs +212 -0
  11. package/src/generate.test.mjs +322 -0
  12. package/src/import-map.mjs +14 -0
  13. package/src/package-json.mjs +80 -0
  14. package/src/package-json.test.mjs +67 -0
  15. package/src/rewrite-imports.mjs +85 -0
  16. package/src/rewrite-imports.test.mjs +58 -0
  17. package/templates/runtime/contracts/component-contracts.test.ts +1165 -0
  18. package/templates/runtime/contracts/component-contracts.ts +1340 -0
  19. package/templates/runtime/contracts/decision-contracts.test.ts +206 -0
  20. package/templates/runtime/contracts/decision-contracts.ts +283 -0
  21. package/templates/runtime/contracts/index.test.ts +14 -0
  22. package/templates/runtime/contracts/index.ts +3 -0
  23. package/templates/runtime/contracts/types.ts +56 -0
  24. package/templates/runtime/export/export.test.ts +203 -0
  25. package/templates/runtime/export/export.ts +132 -0
  26. package/templates/runtime/export/index.ts +1 -0
  27. package/templates/runtime/index.ts +14 -0
  28. package/templates/runtime/react/canvas-shell.test.tsx +424 -0
  29. package/templates/runtime/react/canvas-shell.tsx +408 -0
  30. package/templates/runtime/react/control-renderers.ts +31 -0
  31. package/templates/runtime/react/controls-panel.test.tsx +3736 -0
  32. package/templates/runtime/react/controls-panel.tsx +2327 -0
  33. package/templates/runtime/react/curve-geometry.test.ts +70 -0
  34. package/templates/runtime/react/index.ts +15 -0
  35. package/templates/runtime/react/layer-tree.ts +96 -0
  36. package/templates/runtime/react/layers-panel.test.tsx +487 -0
  37. package/templates/runtime/react/layers-panel.tsx +1348 -0
  38. package/templates/runtime/react/media-file.ts +82 -0
  39. package/templates/runtime/react/panel-host-config.ts +80 -0
  40. package/templates/runtime/react/panel-host-geometry.test.ts +66 -0
  41. package/templates/runtime/react/panel-host-geometry.ts +109 -0
  42. package/templates/runtime/react/panel-host-types.ts +74 -0
  43. package/templates/runtime/react/panel-host.test.tsx +102 -0
  44. package/templates/runtime/react/panel-host.tsx +353 -0
  45. package/templates/runtime/react/runtime-public-api.test.tsx +132 -0
  46. package/templates/runtime/react/settings-transfer.test.ts +150 -0
  47. package/templates/runtime/react/settings-transfer.ts +279 -0
  48. package/templates/runtime/react/storage-key-migration.ts +48 -0
  49. package/templates/runtime/react/theme-runtime.tsx +177 -0
  50. package/templates/runtime/react/timeline-panel.test.tsx +668 -0
  51. package/templates/runtime/react/timeline-panel.tsx +2953 -0
  52. package/templates/runtime/react/toolbar-panel.test.tsx +212 -0
  53. package/templates/runtime/react/toolbar-panel.tsx +205 -0
  54. package/templates/runtime/react/toolcraft-app.integration.test.tsx +350 -0
  55. package/templates/runtime/react/toolcraft-app.test.tsx +339 -0
  56. package/templates/runtime/react/toolcraft-app.tsx +81 -0
  57. package/templates/runtime/react/toolcraft-root.test.tsx +347 -0
  58. package/templates/runtime/react/toolcraft-root.tsx +203 -0
  59. package/templates/runtime/react/use-toolcraft.ts +41 -0
  60. package/templates/runtime/schema/define-toolcraft.test.ts +1524 -0
  61. package/templates/runtime/schema/define-toolcraft.ts +1442 -0
  62. package/templates/runtime/schema/keyframe-capability.test.ts +90 -0
  63. package/templates/runtime/schema/keyframe-capability.ts +51 -0
  64. package/templates/runtime/schema/runtime-targets.ts +40 -0
  65. package/templates/runtime/schema/types.ts +370 -0
  66. package/templates/runtime/state/canvas-zoom.ts +8 -0
  67. package/templates/runtime/state/create-template-state.test.ts +242 -0
  68. package/templates/runtime/state/create-template-state.ts +95 -0
  69. package/templates/runtime/state/keyframe-evaluation.test.ts +141 -0
  70. package/templates/runtime/state/keyframe-evaluation.ts +203 -0
  71. package/templates/runtime/state/persistence.test.ts +217 -0
  72. package/templates/runtime/state/persistence.ts +511 -0
  73. package/templates/runtime/state/reducer.test.ts +937 -0
  74. package/templates/runtime/state/reducer.ts +1212 -0
  75. package/templates/runtime/state/timeline-readiness.ts +43 -0
  76. package/templates/runtime/state/types.ts +242 -0
  77. package/templates/runtime/styles.css +125 -0
  78. package/templates/runtime/testing/performance.test.ts +1058 -0
  79. package/templates/runtime/testing/performance.ts +1078 -0
  80. package/templates/starter/AGENTS.md +186 -0
  81. package/templates/starter/LICENSE.md +98 -0
  82. package/templates/starter/NOTICE.md +8 -0
  83. package/templates/starter/docs/toolcraft/README.md +41 -0
  84. package/templates/starter/docs/toolcraft/acceptance-testing.md +205 -0
  85. package/templates/starter/docs/toolcraft/agent-worklog.md +81 -0
  86. package/templates/starter/docs/toolcraft/assembly-workflow.md +206 -0
  87. package/templates/starter/docs/toolcraft/component-rules.md +299 -0
  88. package/templates/starter/docs/toolcraft/custom-controls.md +71 -0
  89. package/templates/starter/docs/toolcraft/decision-contract.md +71 -0
  90. package/templates/starter/docs/toolcraft/performance.md +112 -0
  91. package/templates/starter/docs/toolcraft/renderer-technique.md +48 -0
  92. package/templates/starter/docs/toolcraft/schema-reference.md +265 -0
  93. package/templates/starter/docs/toolcraft/workflow.md +87 -0
  94. package/templates/starter/e2e/app-browser-acceptance.spec.ts +785 -0
  95. package/templates/starter/e2e/app-controls.spec.ts +41 -0
  96. package/templates/starter/e2e/app-performance.spec.ts +326 -0
  97. package/templates/starter/e2e/canvas-handle-helpers.ts +244 -0
  98. package/templates/starter/e2e/performance-helpers.ts +612 -0
  99. package/templates/starter/e2e/product-observable-helpers.ts +170 -0
  100. package/templates/starter/index.html +12 -0
  101. package/templates/starter/package.json +52 -0
  102. package/templates/starter/playwright.config.ts +43 -0
  103. package/templates/starter/scripts/check-ai-skills.mjs +95 -0
  104. package/templates/starter/scripts/check-toolcraft-docs.mjs +159 -0
  105. package/templates/starter/scripts/check-toolcraft-integrity.mjs +232 -0
  106. package/templates/starter/scripts/run-vite-on-free-port.mjs +48 -0
  107. package/templates/starter/scripts/toolcraft-port.mjs +54 -0
  108. package/templates/starter/scripts/toolcraft-port.test.mjs +73 -0
  109. package/templates/starter/src/app/starter-acceptance.test.ts +5959 -0
  110. package/templates/starter/src/app/starter-acceptance.ts +2646 -0
  111. package/templates/starter/src/app/starter-performance.test.ts +1390 -0
  112. package/templates/starter/src/app/starter-performance.ts +12 -0
  113. package/templates/starter/src/app/starter-schema.test.ts +70 -0
  114. package/templates/starter/src/app/starter-schema.ts +15 -0
  115. package/templates/starter/src/main.tsx +18 -0
  116. package/templates/starter/src/router.tsx +16 -0
  117. package/templates/starter/src/routes/index.tsx +7 -0
  118. package/templates/starter/src/routes/root.tsx +19 -0
  119. package/templates/starter/src/styles.css +120 -0
  120. package/templates/starter/tsconfig.json +11 -0
  121. package/templates/starter/vite.config.ts +13 -0
  122. package/templates/ui/components/composites/accordion.tsx +73 -0
  123. package/templates/ui/components/composites/alert-dialog.tsx +190 -0
  124. package/templates/ui/components/composites/alert.tsx +74 -0
  125. package/templates/ui/components/composites/aspect-ratio.tsx +22 -0
  126. package/templates/ui/components/composites/avatar.tsx +98 -0
  127. package/templates/ui/components/composites/badge.tsx +69 -0
  128. package/templates/ui/components/composites/breadcrumb.tsx +106 -0
  129. package/templates/ui/components/composites/card.tsx +91 -0
  130. package/templates/ui/components/composites/combobox.tsx +486 -0
  131. package/templates/ui/components/composites/command.tsx +296 -0
  132. package/templates/ui/components/composites/context-menu.tsx +247 -0
  133. package/templates/ui/components/composites/dialog.tsx +282 -0
  134. package/templates/ui/components/composites/dropdown-menu.tsx +299 -0
  135. package/templates/ui/components/composites/empty.tsx +110 -0
  136. package/templates/ui/components/composites/hover-card.tsx +44 -0
  137. package/templates/ui/components/composites/index.ts +30 -0
  138. package/templates/ui/components/composites/menubar.tsx +214 -0
  139. package/templates/ui/components/composites/navigation-menu.tsx +167 -0
  140. package/templates/ui/components/composites/pagination.tsx +131 -0
  141. package/templates/ui/components/composites/progress.tsx +72 -0
  142. package/templates/ui/components/composites/radio-group.tsx +84 -0
  143. package/templates/ui/components/composites/resizable.tsx +42 -0
  144. package/templates/ui/components/composites/sheet.tsx +153 -0
  145. package/templates/ui/components/composites/sidebar-structural.tsx +310 -0
  146. package/templates/ui/components/composites/sidebar.tsx +431 -0
  147. package/templates/ui/components/composites/sonner.tsx +35 -0
  148. package/templates/ui/components/composites/spinner.tsx +43 -0
  149. package/templates/ui/components/composites/table.tsx +108 -0
  150. package/templates/ui/components/composites/tabs.tsx +83 -0
  151. package/templates/ui/components/control-layout/index.tsx +437 -0
  152. package/templates/ui/components/controls/actions/actions-control.tsx +139 -0
  153. package/templates/ui/components/controls/actions/index.ts +9 -0
  154. package/templates/ui/components/controls/anchor-grid/anchor-grid-control.tsx +107 -0
  155. package/templates/ui/components/controls/anchor-grid/index.ts +4 -0
  156. package/templates/ui/components/controls/boolean/boolean-controls.tsx +79 -0
  157. package/templates/ui/components/controls/boolean/index.ts +4 -0
  158. package/templates/ui/components/controls/channel-mixer/channel-mixer-control.tsx +95 -0
  159. package/templates/ui/components/controls/channel-mixer/index.ts +4 -0
  160. package/templates/ui/components/controls/channel-tabs/channel-tabs.tsx +42 -0
  161. package/templates/ui/components/controls/channel-tabs/index.ts +6 -0
  162. package/templates/ui/components/controls/code-textarea/code-textarea-control.tsx +90 -0
  163. package/templates/ui/components/controls/code-textarea/index.ts +4 -0
  164. package/templates/ui/components/controls/color/color-control.tsx +571 -0
  165. package/templates/ui/components/controls/color/color-picker-popover.tsx +104 -0
  166. package/templates/ui/components/controls/color/index.ts +41 -0
  167. package/templates/ui/components/controls/color/palette-control-data.ts +436 -0
  168. package/templates/ui/components/controls/color/palette-control.tsx +535 -0
  169. package/templates/ui/components/controls/color/style-guide-color-picker-channel-utils.ts +162 -0
  170. package/templates/ui/components/controls/color/style-guide-color-picker-interactions.ts +190 -0
  171. package/templates/ui/components/controls/color/style-guide-color-picker-logic.ts +485 -0
  172. package/templates/ui/components/controls/color/style-guide-color-picker-parts.tsx +710 -0
  173. package/templates/ui/components/controls/color/style-guide-color-picker.tsx +503 -0
  174. package/templates/ui/components/controls/control-types.ts +43 -0
  175. package/templates/ui/components/controls/curves/curve-geometry.ts +355 -0
  176. package/templates/ui/components/controls/curves/curve-graph.tsx +390 -0
  177. package/templates/ui/components/controls/curves/curves-control.tsx +445 -0
  178. package/templates/ui/components/controls/curves/index.ts +6 -0
  179. package/templates/ui/components/controls/file-drop/file-drop-control.tsx +191 -0
  180. package/templates/ui/components/controls/file-drop/index.ts +5 -0
  181. package/templates/ui/components/controls/font-picker/font-catalog.json +15360 -0
  182. package/templates/ui/components/controls/font-picker/font-catalog.ts +116 -0
  183. package/templates/ui/components/controls/font-picker/font-picker-control.tsx +1202 -0
  184. package/templates/ui/components/controls/font-picker/font-preview-loader.ts +336 -0
  185. package/templates/ui/components/controls/font-picker/index.ts +24 -0
  186. package/templates/ui/components/controls/font-picker/use-hover-intent.ts +46 -0
  187. package/templates/ui/components/controls/gradient/gradient-control-utils.ts +190 -0
  188. package/templates/ui/components/controls/gradient/gradient-control.tsx +612 -0
  189. package/templates/ui/components/controls/gradient/gradient-stop-list.tsx +400 -0
  190. package/templates/ui/components/controls/gradient/gradient-toolbar.tsx +152 -0
  191. package/templates/ui/components/controls/gradient/index.ts +4 -0
  192. package/templates/ui/components/controls/image-picker/image-picker-control.tsx +139 -0
  193. package/templates/ui/components/controls/image-picker/index.ts +7 -0
  194. package/templates/ui/components/controls/index.ts +192 -0
  195. package/templates/ui/components/controls/range-input/index.ts +4 -0
  196. package/templates/ui/components/controls/range-input/range-input-control.tsx +173 -0
  197. package/templates/ui/components/controls/range-slider/index.ts +4 -0
  198. package/templates/ui/components/controls/range-slider/range-slider-control.tsx +122 -0
  199. package/templates/ui/components/controls/range-slider/range-slider-value.ts +61 -0
  200. package/templates/ui/components/controls/segmented/index.ts +8 -0
  201. package/templates/ui/components/controls/segmented/segmented-control.tsx +94 -0
  202. package/templates/ui/components/controls/select/index.ts +4 -0
  203. package/templates/ui/components/controls/select/select-control.tsx +223 -0
  204. package/templates/ui/components/controls/slider/index.ts +4 -0
  205. package/templates/ui/components/controls/slider/slider-control.tsx +150 -0
  206. package/templates/ui/components/controls/slider/slider-value.ts +56 -0
  207. package/templates/ui/components/controls/text-input/index.ts +4 -0
  208. package/templates/ui/components/controls/text-input/text-input-control.tsx +158 -0
  209. package/templates/ui/components/controls/use-measured-element-width.ts +42 -0
  210. package/templates/ui/components/controls/vector/index.ts +8 -0
  211. package/templates/ui/components/controls/vector/vector-control.tsx +401 -0
  212. package/templates/ui/components/panel/index.ts +19 -0
  213. package/templates/ui/components/panel/panel-actions.tsx +165 -0
  214. package/templates/ui/components/panel/panel-header.tsx +61 -0
  215. package/templates/ui/components/panel/panel-icon-button.tsx +96 -0
  216. package/templates/ui/components/panel/panel-section.tsx +168 -0
  217. package/templates/ui/components/panel/panel-surface.tsx +206 -0
  218. package/templates/ui/components/panel/panel.tsx +210 -0
  219. package/templates/ui/components/primitives/animated-loader.tsx +61 -0
  220. package/templates/ui/components/primitives/button-group.tsx +134 -0
  221. package/templates/ui/components/primitives/button.tsx +429 -0
  222. package/templates/ui/components/primitives/checkbox.tsx +62 -0
  223. package/templates/ui/components/primitives/editable-slider-value-label.tsx +337 -0
  224. package/templates/ui/components/primitives/field.tsx +225 -0
  225. package/templates/ui/components/primitives/index.ts +82 -0
  226. package/templates/ui/components/primitives/input-group.tsx +298 -0
  227. package/templates/ui/components/primitives/input.tsx +61 -0
  228. package/templates/ui/components/primitives/internal/button-loading.tsx +178 -0
  229. package/templates/ui/components/primitives/label.tsx +16 -0
  230. package/templates/ui/components/primitives/popover.tsx +126 -0
  231. package/templates/ui/components/primitives/portal-layer-context.tsx +33 -0
  232. package/templates/ui/components/primitives/primitive-arrow-icon.tsx +38 -0
  233. package/templates/ui/components/primitives/scroll-fade-logic.ts +441 -0
  234. package/templates/ui/components/primitives/scroll-fade-render.tsx +75 -0
  235. package/templates/ui/components/primitives/scroll-fade-types.ts +41 -0
  236. package/templates/ui/components/primitives/scroll-fade.tsx +72 -0
  237. package/templates/ui/components/primitives/select.tsx +408 -0
  238. package/templates/ui/components/primitives/selection-state.ts +31 -0
  239. package/templates/ui/components/primitives/separator.tsx +21 -0
  240. package/templates/ui/components/primitives/slider/index.ts +4 -0
  241. package/templates/ui/components/primitives/slider/slider-interaction.tsx +96 -0
  242. package/templates/ui/components/primitives/slider/slider-parts.tsx +303 -0
  243. package/templates/ui/components/primitives/slider/slider-reset.ts +152 -0
  244. package/templates/ui/components/primitives/slider/slider-value.ts +114 -0
  245. package/templates/ui/components/primitives/slider/slider.tsx +511 -0
  246. package/templates/ui/components/primitives/switch.tsx +35 -0
  247. package/templates/ui/components/primitives/textarea.tsx +49 -0
  248. package/templates/ui/components/primitives/toggle-group.tsx +114 -0
  249. package/templates/ui/components/primitives/toggle.tsx +46 -0
  250. package/templates/ui/components/primitives/tooltip.tsx +100 -0
  251. package/templates/ui/hooks/use-mobile.ts +21 -0
  252. package/templates/ui/index.ts +31 -0
  253. package/templates/ui/lib/control-outline.ts +3 -0
  254. package/templates/ui/lib/input-control-style.ts +131 -0
  255. package/templates/ui/lib/style-guide-color-utils.ts +111 -0
  256. package/templates/ui/lib/utils.ts +6 -0
  257. package/templates/ui/styles.css +291 -0
@@ -0,0 +1,2953 @@
1
+ 'use client';
2
+
3
+ import * as React from 'react';
4
+ import {
5
+ useCallback,
6
+ useEffect,
7
+ useLayoutEffect,
8
+ useRef,
9
+ useState,
10
+ type CSSProperties,
11
+ } from 'react';
12
+ import {
13
+ Button,
14
+ EditableSliderValueLabel,
15
+ PanelSurface,
16
+ Popover,
17
+ PopoverContent,
18
+ PopoverTrigger,
19
+ PrimitiveArrowIcon,
20
+ ScrollFade,
21
+ Tooltip,
22
+ TooltipContent,
23
+ TooltipTrigger,
24
+ } from '@repo/ui';
25
+ import { Eye, EyeOff, Pause, Play, Repeat, Repeat1, Trash2 } from 'lucide-react';
26
+ import { AnimatePresence, motion } from 'motion/react';
27
+
28
+ import type {
29
+ ToolcraftPanelState,
30
+ ToolcraftTimelineBezierControlPoints,
31
+ ToolcraftTimelineKeyframe,
32
+ ToolcraftTimelineKeyframeEasing,
33
+ ToolcraftTimelineKeyframeGroup,
34
+ } from '../state/types';
35
+ import { isTimelineReadyForPlayback } from '../state/timeline-readiness';
36
+ import { PanelContainer } from './panel-host';
37
+ import type { PanelPlacement, PanelStateChange } from './panel-host-types';
38
+ import { useToolcraft } from './use-toolcraft';
39
+
40
+ type TimelinePanelProps = {
41
+ className?: string;
42
+ defaultExpanded?: boolean;
43
+ framed?: boolean;
44
+ onPanelStateChange?: PanelStateChange;
45
+ panelPlacement?: PanelPlacement;
46
+ panelState?: ToolcraftPanelState;
47
+ };
48
+
49
+ function cn(...classNames: Array<string | false | null | undefined>): string {
50
+ return classNames.filter(Boolean).join(' ');
51
+ }
52
+
53
+ const timelinePanelCollapsedSize = { height: 36 } as const;
54
+ const timelinePanelCollapsedWidthPx = 256;
55
+ const timelinePanelExpandedWidthPx = 688;
56
+ const timelinePanelMinResponsiveWidthPx = 320;
57
+ const timelinePanelSideCollisionMarginPx = 10;
58
+ const timelinePanelSurfaceBorderHeightPx = 2;
59
+ const timelinePanelHeaderHeightPx = 36;
60
+ const timelineExpandedRulerHeightPx = 36;
61
+ const timelineKeyframeRowHeightPx = 36;
62
+ const timelineEmptyStateHeightPx = timelineKeyframeRowHeightPx;
63
+ const maxVisibleTimelineKeyframeRows = 8;
64
+ const timelineKeyframeListMaxHeightPx =
65
+ maxVisibleTimelineKeyframeRows * timelineKeyframeRowHeightPx;
66
+ const timelineTrackStartOffsetPx = 164;
67
+ const timelineTrackEndInsetPx = 0;
68
+ const timelineTrackColumnBorderWidthPx = 1;
69
+ const timelineTrackStartVisualOffsetPx = -timelineTrackColumnBorderWidthPx;
70
+ const timelineExpandedTrackStartOffsetPx =
71
+ timelineTrackStartOffsetPx + timelineTrackStartVisualOffsetPx;
72
+ const timelineRulerLeftInsetPx = timelineTrackStartVisualOffsetPx / 2;
73
+ const timelineRulerRightInsetPx = timelineTrackColumnBorderWidthPx / 2;
74
+ const timelineRowActionColumnWidthPx = 36;
75
+ const timelineExpandedTrackEndOffsetPx =
76
+ timelineTrackEndInsetPx + timelineRowActionColumnWidthPx + timelineTrackColumnBorderWidthPx;
77
+ const timelinePlayheadSafeZonePx = 3;
78
+ const timelinePlayheadHitAreaWidthPx =
79
+ timelineTrackColumnBorderWidthPx + timelinePlayheadSafeZonePx * 2;
80
+ const maxTimelineDurationSeconds = 60;
81
+ const minTimelineDurationSeconds = 1;
82
+ const timelineScrubStepSeconds = 0.25;
83
+ const timelinePanelExpandCollapseTransition = {
84
+ damping: 34,
85
+ mass: 0.85,
86
+ stiffness: 330,
87
+ type: 'spring',
88
+ } as const;
89
+ const timelinePanelResizeTransition = {
90
+ duration: 0.16,
91
+ ease: [0.22, 1, 0.36, 1],
92
+ } as const;
93
+ const timelineKeyframePresenceTransition = {
94
+ duration: 0.14,
95
+ ease: [0.22, 1, 0.36, 1],
96
+ } as const;
97
+ const selectedItemSurfaceClassName = 'bg-[color:color-mix(in_oklab,var(--link)_12%,transparent)]';
98
+ const selectedItemBorderClassName =
99
+ 'border-[color:color-mix(in_oklab,var(--border)_10%,transparent)]';
100
+
101
+ type TimelineBezierControlPoints = ToolcraftTimelineBezierControlPoints;
102
+
103
+ const defaultTimelineBezierControlPoints = [
104
+ 0.65, 0, 0.35, 1,
105
+ ] satisfies TimelineBezierControlPoints;
106
+
107
+ const defaultTimelineKeyframeEasing: ToolcraftTimelineKeyframeEasing = {
108
+ controlPoints: defaultTimelineBezierControlPoints,
109
+ type: 'bezier',
110
+ };
111
+
112
+ function getTimelinePanelExpandedSize(keyframeGroups: readonly ToolcraftTimelineKeyframeGroup[]): {
113
+ height: number;
114
+ width: number;
115
+ } {
116
+ const rowCount = keyframeGroups.length;
117
+ const rowAreaHeight =
118
+ rowCount > 0
119
+ ? Math.min(rowCount * timelineKeyframeRowHeightPx, timelineKeyframeListMaxHeightPx)
120
+ : timelineEmptyStateHeightPx;
121
+
122
+ return {
123
+ height:
124
+ timelinePanelSurfaceBorderHeightPx +
125
+ timelinePanelHeaderHeightPx +
126
+ timelineExpandedRulerHeightPx +
127
+ rowAreaHeight,
128
+ width: timelinePanelExpandedWidthPx,
129
+ };
130
+ }
131
+
132
+ function doRectsOverlapVertically(first: DOMRect, second: DOMRect): boolean {
133
+ return first.top < second.bottom && first.bottom > second.top;
134
+ }
135
+
136
+ function getTimelinePanelBoundsElement(panel: HTMLElement): HTMLElement | null {
137
+ return panel.closest<HTMLElement>(
138
+ '[data-slot="toolcraft-runtime-app"], [data-toolcraft-panel-stage]',
139
+ );
140
+ }
141
+
142
+ function getTimelinePanelAnchorRect(panel: HTMLElement): DOMRect {
143
+ const panelHost = panel.closest<HTMLElement>(
144
+ '[data-slot="toolcraft-runtime-panel-host"][data-panel-type="timeline"]',
145
+ );
146
+ const panelHostRect = panelHost?.getBoundingClientRect();
147
+
148
+ if (panelHostRect && panelHostRect.width > 0 && panelHostRect.height > 0) {
149
+ return panelHostRect;
150
+ }
151
+
152
+ return panel.getBoundingClientRect();
153
+ }
154
+
155
+ type TimelinePanelResponsiveLayout = {
156
+ offsetX: number;
157
+ width: number;
158
+ };
159
+
160
+ function getTimelinePanelResponsiveLayout(panel: HTMLElement): TimelinePanelResponsiveLayout | null {
161
+ const boundsElement = getTimelinePanelBoundsElement(panel);
162
+ const boundsRect = boundsElement?.getBoundingClientRect();
163
+ const anchorRect = getTimelinePanelAnchorRect(panel);
164
+ const panelRect = panel.getBoundingClientRect();
165
+
166
+ if (!boundsElement || !boundsRect || boundsRect.width <= 0 || anchorRect.width <= 0) {
167
+ return null;
168
+ }
169
+
170
+ const boundsLeft = boundsRect.left + timelinePanelSideCollisionMarginPx;
171
+ const boundsRight = boundsRect.right - timelinePanelSideCollisionMarginPx;
172
+ const panelCenterX = anchorRect.left + anchorRect.width / 2;
173
+ let leftLimit = boundsLeft;
174
+ let rightLimit = boundsRight;
175
+
176
+ for (const sidePanel of boundsElement.querySelectorAll<HTMLElement>(
177
+ '[data-panel-type="layers"], [data-panel-type="controls"]',
178
+ )) {
179
+ const sidePanelRect = sidePanel.getBoundingClientRect();
180
+
181
+ if (sidePanelRect.width <= 0 || !doRectsOverlapVertically(panelRect, sidePanelRect)) {
182
+ continue;
183
+ }
184
+
185
+ if (sidePanelRect.right <= panelCenterX) {
186
+ leftLimit = Math.max(leftLimit, sidePanelRect.right + timelinePanelSideCollisionMarginPx);
187
+ continue;
188
+ }
189
+
190
+ if (sidePanelRect.left >= panelCenterX) {
191
+ rightLimit = Math.min(rightLimit, sidePanelRect.left - timelinePanelSideCollisionMarginPx);
192
+ }
193
+ }
194
+
195
+ const availableWidth = Math.floor(Math.max(0, rightLimit - leftLimit));
196
+ const width =
197
+ availableWidth >= timelinePanelExpandedWidthPx
198
+ ? timelinePanelExpandedWidthPx
199
+ : Math.max(timelinePanelMinResponsiveWidthPx, availableWidth);
200
+ const halfWidth = width / 2;
201
+ const minCenterX = leftLimit + halfWidth;
202
+ const maxCenterX = rightLimit - halfWidth;
203
+ const nextCenterX =
204
+ minCenterX <= maxCenterX
205
+ ? Math.max(minCenterX, Math.min(maxCenterX, panelCenterX))
206
+ : leftLimit + Math.max(0, rightLimit - leftLimit) / 2;
207
+ const offsetX = Math.round(nextCenterX - panelCenterX);
208
+
209
+ return {
210
+ offsetX: Object.is(offsetX, -0) ? 0 : offsetX,
211
+ width,
212
+ };
213
+ }
214
+
215
+ function areTimelinePanelResponsiveLayoutsEqual(
216
+ first: TimelinePanelResponsiveLayout | null,
217
+ second: TimelinePanelResponsiveLayout | null,
218
+ ): boolean {
219
+ return first?.offsetX === second?.offsetX && first?.width === second?.width;
220
+ }
221
+
222
+ function useTimelinePanelResponsiveLayout(enabled: boolean): {
223
+ panelRef: React.RefObject<HTMLDivElement | null>;
224
+ responsiveLayout: TimelinePanelResponsiveLayout | null;
225
+ } {
226
+ const panelRef = useRef<HTMLDivElement | null>(null);
227
+ const frameRef = useRef<number | null>(null);
228
+ const [responsiveLayout, setResponsiveLayout] =
229
+ useState<TimelinePanelResponsiveLayout | null>(null);
230
+
231
+ const measureResponsiveLayout = useCallback((): void => {
232
+ const panel = panelRef.current;
233
+ const nextLayout = enabled && panel ? getTimelinePanelResponsiveLayout(panel) : null;
234
+
235
+ setResponsiveLayout((currentLayout) =>
236
+ areTimelinePanelResponsiveLayoutsEqual(currentLayout, nextLayout)
237
+ ? currentLayout
238
+ : nextLayout,
239
+ );
240
+ }, [enabled]);
241
+
242
+ const scheduleMeasure = useCallback((): void => {
243
+ if (frameRef.current !== null) {
244
+ return;
245
+ }
246
+
247
+ frameRef.current = window.requestAnimationFrame(() => {
248
+ frameRef.current = null;
249
+ measureResponsiveLayout();
250
+ });
251
+ }, [measureResponsiveLayout]);
252
+
253
+ const measureImmediately = useCallback((): void => {
254
+ if (frameRef.current !== null) {
255
+ window.cancelAnimationFrame(frameRef.current);
256
+ frameRef.current = null;
257
+ }
258
+
259
+ measureResponsiveLayout();
260
+ }, [measureResponsiveLayout]);
261
+
262
+ useLayoutEffect(() => {
263
+ measureImmediately();
264
+
265
+ if (!enabled) {
266
+ return undefined;
267
+ }
268
+
269
+ window.addEventListener('resize', measureImmediately);
270
+ document.addEventListener('pointermove', scheduleMeasure, { capture: true });
271
+ document.addEventListener('pointerup', measureImmediately, { capture: true });
272
+
273
+ const resizeObserver =
274
+ typeof ResizeObserver === 'undefined' ? null : new ResizeObserver(measureImmediately);
275
+ const panel = panelRef.current;
276
+ const boundsElement = panel ? getTimelinePanelBoundsElement(panel) : null;
277
+
278
+ if (resizeObserver) {
279
+ if (boundsElement) {
280
+ resizeObserver.observe(boundsElement);
281
+
282
+ for (const sidePanel of boundsElement.querySelectorAll<HTMLElement>(
283
+ '[data-panel-type="layers"], [data-panel-type="controls"]',
284
+ )) {
285
+ resizeObserver.observe(sidePanel);
286
+ }
287
+ }
288
+ }
289
+
290
+ return () => {
291
+ window.removeEventListener('resize', measureImmediately);
292
+ document.removeEventListener('pointermove', scheduleMeasure, { capture: true });
293
+ document.removeEventListener('pointerup', measureImmediately, { capture: true });
294
+ resizeObserver?.disconnect();
295
+
296
+ if (frameRef.current !== null) {
297
+ window.cancelAnimationFrame(frameRef.current);
298
+ frameRef.current = null;
299
+ }
300
+ };
301
+ }, [enabled, measureImmediately, scheduleMeasure]);
302
+
303
+ return { panelRef, responsiveLayout };
304
+ }
305
+
306
+ function clampTimelineDuration(value: number): number {
307
+ if (!Number.isFinite(value)) {
308
+ return 8;
309
+ }
310
+
311
+ return Math.max(minTimelineDurationSeconds, Math.min(maxTimelineDurationSeconds, value));
312
+ }
313
+
314
+ function clampTimelineTime(value: number, durationSeconds: number): number {
315
+ if (!Number.isFinite(value)) {
316
+ return 0;
317
+ }
318
+
319
+ return Math.max(0, Math.min(durationSeconds, value));
320
+ }
321
+
322
+ function formatTimelineSeconds(value: number): string {
323
+ return value.toFixed(2);
324
+ }
325
+
326
+ function getRoundedKeyframeTime(value: number): number {
327
+ return Number.parseFloat(value.toFixed(2));
328
+ }
329
+
330
+ function getKeyframeId(controlId: string, timeSeconds: number): string {
331
+ return `${controlId}::${formatTimelineSeconds(timeSeconds)}`;
332
+ }
333
+
334
+ function formatTimelineDisplaySeconds(value: number): string {
335
+ if (!Number.isFinite(value)) {
336
+ return '0';
337
+ }
338
+
339
+ return String(Number.parseFloat(value.toFixed(2)));
340
+ }
341
+
342
+ function formatDurationValueLabel(value: number): string {
343
+ return `${Number.parseFloat(value.toFixed(2))}s`;
344
+ }
345
+
346
+ function formatTimelineHeaderTimeLabel({
347
+ currentTimeSeconds,
348
+ durationSeconds,
349
+ }: {
350
+ currentTimeSeconds: number;
351
+ durationSeconds: number;
352
+ }): string {
353
+ return `${formatTimelineSeconds(currentTimeSeconds)} / ${formatTimelineDisplaySeconds(
354
+ durationSeconds,
355
+ )}s`;
356
+ }
357
+
358
+ function getTimelineProgressRatio(currentTimeSeconds: number, durationSeconds: number): number {
359
+ if (durationSeconds <= 0) {
360
+ return 0;
361
+ }
362
+
363
+ return clampTimelineTime(currentTimeSeconds, durationSeconds) / durationSeconds;
364
+ }
365
+
366
+ function getTimelineProgressPercent(currentTimeSeconds: number, durationSeconds: number): string {
367
+ return `${getTimelineProgressRatio(currentTimeSeconds, durationSeconds) * 100}%`;
368
+ }
369
+
370
+ function getTimelineHandlePosition(currentTimeSeconds: number, durationSeconds: number): string {
371
+ const progressPercent = getTimelineProgressPercent(currentTimeSeconds, durationSeconds);
372
+ const progressRatio = getTimelineProgressRatio(currentTimeSeconds, durationSeconds);
373
+ const offsetPx = Number((5 - progressRatio * 10).toFixed(4));
374
+
375
+ if (offsetPx < 0) {
376
+ return `calc(${progressPercent} - ${Math.abs(offsetPx)}px)`;
377
+ }
378
+
379
+ if (offsetPx > 0) {
380
+ return `calc(${progressPercent} + ${offsetPx}px)`;
381
+ }
382
+
383
+ return progressPercent;
384
+ }
385
+
386
+ function TimelinePanelDivider(): React.JSX.Element {
387
+ return (
388
+ <span
389
+ aria-hidden="true"
390
+ className="block h-5 w-px shrink-0 rounded-full bg-[color:color-mix(in_oklab,var(--border)_8%,transparent)]"
391
+ data-slot="timeline-panel-divider"
392
+ />
393
+ );
394
+ }
395
+
396
+ function TimelineIconButton({
397
+ active = false,
398
+ children,
399
+ disabled = false,
400
+ label,
401
+ onClick,
402
+ size = 'icon',
403
+ tooltipSide = 'top',
404
+ }: {
405
+ active?: boolean;
406
+ children: React.ReactNode;
407
+ disabled?: boolean;
408
+ label: string;
409
+ onClick: () => void;
410
+ size?: 'icon-sm' | 'icon';
411
+ tooltipSide?: 'top' | 'right' | 'bottom' | 'left';
412
+ }): React.JSX.Element {
413
+ return (
414
+ <Tooltip>
415
+ <TooltipTrigger
416
+ render={
417
+ <Button
418
+ aria-label={label}
419
+ aria-pressed={active}
420
+ className="data-[icon-active=true]:text-[color:var(--foreground)]"
421
+ data-icon-active={active}
422
+ disabled={disabled}
423
+ onClick={() => {
424
+ if (disabled) {
425
+ return;
426
+ }
427
+
428
+ onClick();
429
+ }}
430
+ size={size}
431
+ type="button"
432
+ variant="ghost"
433
+ />
434
+ }
435
+ >
436
+ {children}
437
+ </TooltipTrigger>
438
+ <TooltipContent side={tooltipSide}>{label}</TooltipContent>
439
+ </Tooltip>
440
+ );
441
+ }
442
+
443
+ function stopTimelineEasingEvent(
444
+ event: React.MouseEvent<HTMLElement> | React.PointerEvent<HTMLElement>,
445
+ ): void {
446
+ event.stopPropagation();
447
+ }
448
+
449
+ function getTimelineEasingPopoverAnchor(): HTMLElement | null {
450
+ if (typeof document === 'undefined') {
451
+ return null;
452
+ }
453
+
454
+ return (
455
+ document.querySelector<HTMLElement>('[data-slot="timeline-panel"][data-expanded-height]') ??
456
+ document.querySelector<HTMLElement>('[data-slot="timeline-panel"]')
457
+ );
458
+ }
459
+
460
+ type TimelineEasingPresetCategory = 'basic' | 'expressive' | 'in' | 'inOut' | 'out';
461
+
462
+ type TimelineEasingPreset = {
463
+ category: TimelineEasingPresetCategory;
464
+ controlPoints: TimelineBezierControlPoints;
465
+ label: string;
466
+ name: string;
467
+ };
468
+
469
+ type TimelineCurveEditorDragTarget = 'p1' | 'p2';
470
+
471
+ const timelineEasingPresetCategories = [
472
+ ['basic', 'Foundation'],
473
+ ['out', 'Out'],
474
+ ['in', 'In'],
475
+ ['inOut', 'In Out'],
476
+ ['expressive', 'Expressive'],
477
+ ] as const satisfies ReadonlyArray<readonly [TimelineEasingPresetCategory, string]>;
478
+
479
+ const timelineEasingPresets = [
480
+ { category: 'basic', controlPoints: [0, 0, 1, 1], label: 'Linear', name: 'linear' },
481
+ { category: 'basic', controlPoints: [0.65, 0, 0.35, 1], label: 'Smooth', name: 'smooth' },
482
+ {
483
+ category: 'expressive',
484
+ controlPoints: [1, -0.4, 0.35, 0.95],
485
+ label: 'Anticipate',
486
+ name: 'anticipate',
487
+ },
488
+ {
489
+ category: 'expressive',
490
+ controlPoints: [0.36, 0, 0.66, -0.56],
491
+ label: 'Back In',
492
+ name: 'backIn',
493
+ },
494
+ {
495
+ category: 'expressive',
496
+ controlPoints: [0.34, 1.56, 0.64, 1],
497
+ label: 'Back Out',
498
+ name: 'backOut',
499
+ },
500
+ { category: 'out', controlPoints: [0, 0, 0.2, 1], label: 'Quick Out', name: 'quickOut' },
501
+ {
502
+ category: 'out',
503
+ controlPoints: [0.175, 0.885, 0.32, 1.1],
504
+ label: 'Swift Out',
505
+ name: 'swiftOut',
506
+ },
507
+ { category: 'out', controlPoints: [0.19, 1, 0.22, 1], label: 'Snappy Out', name: 'snappyOut' },
508
+ {
509
+ category: 'out',
510
+ controlPoints: [0.215, 0.61, 0.355, 1],
511
+ label: 'Out Cubic',
512
+ name: 'outCubic',
513
+ },
514
+ { category: 'out', controlPoints: [0, 0, 0.58, 1], label: 'Ease Out', name: 'easeOut' },
515
+ { category: 'in', controlPoints: [0.42, 0, 1, 1], label: 'Ease In', name: 'easeIn' },
516
+ { category: 'in', controlPoints: [0.6, 0.04, 0.98, 0.335], label: 'In Circ', name: 'inCirc' },
517
+ { category: 'in', controlPoints: [0.755, 0.05, 0.855, 0.06], label: 'In Quint', name: 'inQuint' },
518
+ {
519
+ category: 'inOut',
520
+ controlPoints: [0.42, 0, 0.58, 1],
521
+ label: 'Ease In Out',
522
+ name: 'easeInOut',
523
+ },
524
+ {
525
+ category: 'inOut',
526
+ controlPoints: [0.77, 0, 0.175, 1],
527
+ label: 'In Out Quart',
528
+ name: 'inOutQuart',
529
+ },
530
+ {
531
+ category: 'inOut',
532
+ controlPoints: [0.86, 0, 0.07, 1],
533
+ label: 'In Out Quint',
534
+ name: 'inOutQuint',
535
+ },
536
+ { category: 'inOut', controlPoints: [1, 0, 0, 1], label: 'In Out Expo', name: 'inOutExpo' },
537
+ {
538
+ category: 'inOut',
539
+ controlPoints: [0.785, 0.135, 0.15, 0.86],
540
+ label: 'In Out Circ',
541
+ name: 'inOutCirc',
542
+ },
543
+ ] as const satisfies readonly TimelineEasingPreset[];
544
+
545
+ const easingNumberTokenPattern = /[+-]?(?:\d+(?:[.,]\d+)?|[.,]\d+)/g;
546
+ const easingEditorViewBoxSize = 180;
547
+ const easingEditorGridInset = 24;
548
+ const easingEditorGridSize = 132;
549
+ const easingEditorFrameWidth = 220;
550
+ const easingEditorFrameHeight = 240;
551
+ const easingEditorFrameOffsetX = (easingEditorFrameWidth - easingEditorViewBoxSize) / 2;
552
+ const easingEditorFrameOffsetY = (easingEditorFrameHeight - easingEditorViewBoxSize) / 2;
553
+ const timelineEasingPresetIconSize = 20;
554
+ const timelineEasingPresetIconViewBoxSize = 28;
555
+ const timelineEasingPresetIconLineColor = 'color-mix(in oklab, var(--border) 60%, transparent)';
556
+ const timelineEasingPresetButtonBaseClassName =
557
+ 'inline-flex items-center gap-2 rounded-lg border p-1 text-left font-mono text-[11px] leading-[14px] transition-[background-color,border-color,color,transform] duration-150 ease-out hover:bg-[color:color-mix(in_oklab,var(--foreground)_6%,transparent)] hover:border-[color:color-mix(in_oklab,var(--border)_14%,transparent)] active:scale-[0.985]';
558
+ const timelineEasingPopoverWidthPx = 688;
559
+ const timelineEasingCurveAnimationDurationMs = 180;
560
+
561
+ function getTimelineEasingPopoverWidthElement(): HTMLElement | null {
562
+ const panel = getTimelineEasingPopoverAnchor();
563
+
564
+ return panel?.querySelector<HTMLElement>('[data-slot="timeline-panel-header"]') ?? panel;
565
+ }
566
+
567
+ function useTimelineEasingPopoverWidth(): number {
568
+ const [popoverWidth, setPopoverWidth] = useState(timelineEasingPopoverWidthPx);
569
+
570
+ useEffect(() => {
571
+ const widthElement = getTimelineEasingPopoverWidthElement();
572
+
573
+ if (!widthElement) {
574
+ return;
575
+ }
576
+
577
+ const updatePopoverWidth = (): void => {
578
+ const nextWidth = widthElement.getBoundingClientRect().width;
579
+
580
+ if (nextWidth > 0) {
581
+ setPopoverWidth(nextWidth);
582
+ }
583
+ };
584
+
585
+ updatePopoverWidth();
586
+
587
+ if (typeof ResizeObserver === 'undefined') {
588
+ window.addEventListener('resize', updatePopoverWidth);
589
+
590
+ return () => {
591
+ window.removeEventListener('resize', updatePopoverWidth);
592
+ };
593
+ }
594
+
595
+ const observer = new ResizeObserver(updatePopoverWidth);
596
+
597
+ observer.observe(widthElement);
598
+
599
+ return () => {
600
+ observer.disconnect();
601
+ };
602
+ }, []);
603
+
604
+ return popoverWidth;
605
+ }
606
+
607
+ function clampEasingValue(value: number, min: number, max: number): number {
608
+ return Math.min(max, Math.max(min, value));
609
+ }
610
+
611
+ function cloneToolcraftTimelineKeyframeEasing(
612
+ easing: ToolcraftTimelineKeyframeEasing,
613
+ ): ToolcraftTimelineKeyframeEasing {
614
+ return easing.type === 'step'
615
+ ? { type: 'step' }
616
+ : { controlPoints: [...easing.controlPoints], type: 'bezier' };
617
+ }
618
+
619
+ function getToolcraftTimelineKeyframeEasing(
620
+ easing: ToolcraftTimelineKeyframeEasing | undefined,
621
+ ): ToolcraftTimelineKeyframeEasing {
622
+ return cloneToolcraftTimelineKeyframeEasing(easing ?? defaultTimelineKeyframeEasing);
623
+ }
624
+
625
+ function normalizeToolcraftTimelineKeyframeEasing(
626
+ easing: unknown,
627
+ ): ToolcraftTimelineKeyframeEasing | undefined {
628
+ if (typeof easing !== 'object' || easing === null || Array.isArray(easing)) {
629
+ return undefined;
630
+ }
631
+
632
+ const easingRecord = easing as Record<string, unknown>;
633
+
634
+ if (easingRecord.type === 'step') {
635
+ return { type: 'step' };
636
+ }
637
+
638
+ if (easingRecord.type !== 'bezier' || !Array.isArray(easingRecord.controlPoints)) {
639
+ return undefined;
640
+ }
641
+
642
+ const [x1, y1, x2, y2] = easingRecord.controlPoints;
643
+
644
+ if (
645
+ typeof x1 !== 'number' ||
646
+ typeof y1 !== 'number' ||
647
+ typeof x2 !== 'number' ||
648
+ typeof y2 !== 'number' ||
649
+ !Number.isFinite(x1) ||
650
+ !Number.isFinite(y1) ||
651
+ !Number.isFinite(x2) ||
652
+ !Number.isFinite(y2)
653
+ ) {
654
+ return undefined;
655
+ }
656
+
657
+ return {
658
+ controlPoints: [
659
+ clampEasingValue(x1, 0, 1),
660
+ clampEasingValue(y1, -1, 2),
661
+ clampEasingValue(x2, 0, 1),
662
+ clampEasingValue(y2, -1, 2),
663
+ ],
664
+ type: 'bezier',
665
+ };
666
+ }
667
+
668
+ function parseTimelineEasingNumberTokens(value: string): number[] {
669
+ return Array.from(value.matchAll(easingNumberTokenPattern), ([token]) =>
670
+ Number.parseFloat(token.replace(',', '.')),
671
+ ).filter(Number.isFinite);
672
+ }
673
+
674
+ function parseToolcraftTimelineKeyframeEasing(
675
+ value: string,
676
+ baseEasing?: ToolcraftTimelineKeyframeEasing,
677
+ ): ToolcraftTimelineKeyframeEasing | null {
678
+ const trimmedValue = value.trim();
679
+
680
+ if (!trimmedValue) {
681
+ return null;
682
+ }
683
+
684
+ if (/^step(?:\s+hold)?$/i.test(trimmedValue)) {
685
+ return { type: 'step' };
686
+ }
687
+
688
+ const cubicBezierMatch = trimmedValue.match(/^cubic-bezier\((.+)\)$/i);
689
+ const rawControlPoints = parseTimelineEasingNumberTokens(
690
+ cubicBezierMatch?.[1] ?? trimmedValue,
691
+ );
692
+
693
+ if (rawControlPoints.length === 0) {
694
+ return null;
695
+ }
696
+
697
+ const fallbackControlPoints =
698
+ baseEasing?.type === 'bezier' ? baseEasing.controlPoints : defaultTimelineBezierControlPoints;
699
+ const controlPoints = [
700
+ rawControlPoints[0] ?? fallbackControlPoints[0],
701
+ rawControlPoints[1] ?? fallbackControlPoints[1],
702
+ rawControlPoints[2] ?? fallbackControlPoints[2],
703
+ rawControlPoints[3] ?? fallbackControlPoints[3],
704
+ ] satisfies TimelineBezierControlPoints;
705
+
706
+ return (
707
+ normalizeToolcraftTimelineKeyframeEasing({
708
+ controlPoints,
709
+ type: 'bezier',
710
+ }) ?? null
711
+ );
712
+ }
713
+
714
+ function formatTimelineBezierControlPoints(
715
+ controlPoints: TimelineBezierControlPoints,
716
+ ): string {
717
+ return controlPoints.map((point) => Number(point.toFixed(3))).join(', ');
718
+ }
719
+
720
+ function findTimelineEasingPresetName(easing: ToolcraftTimelineKeyframeEasing): string | null {
721
+ if (easing.type === 'step') {
722
+ return null;
723
+ }
724
+
725
+ const [x1, y1, x2, y2] = easing.controlPoints;
726
+
727
+ return (
728
+ timelineEasingPresets.find((preset) => {
729
+ const [presetX1, presetY1, presetX2, presetY2] = preset.controlPoints;
730
+
731
+ return (
732
+ Math.abs(x1 - presetX1) < 0.005 &&
733
+ Math.abs(y1 - presetY1) < 0.005 &&
734
+ Math.abs(x2 - presetX2) < 0.005 &&
735
+ Math.abs(y2 - presetY2) < 0.005
736
+ );
737
+ })?.name ?? null
738
+ );
739
+ }
740
+
741
+ function getEasingEditorControlPoints(
742
+ easing: ToolcraftTimelineKeyframeEasing,
743
+ ): TimelineBezierControlPoints {
744
+ return easing.type === 'step' ? [0, 0, 1, 1] : easing.controlPoints;
745
+ }
746
+
747
+ function areEasingControlPointsEqual(
748
+ first: TimelineBezierControlPoints,
749
+ second: TimelineBezierControlPoints,
750
+ ): boolean {
751
+ return first.every((point, index) => Math.abs(point - second[index]) < 0.0001);
752
+ }
753
+
754
+ function getTimelineEasingAnimationProgress(progress: number): number {
755
+ return 1 - (1 - progress) ** 3;
756
+ }
757
+
758
+ function interpolateTimelineEasingControlPoints(
759
+ from: TimelineBezierControlPoints,
760
+ to: TimelineBezierControlPoints,
761
+ progress: number,
762
+ ): TimelineBezierControlPoints {
763
+ const easedProgress = getTimelineEasingAnimationProgress(Math.max(0, Math.min(1, progress)));
764
+
765
+ return from.map(
766
+ (point, index) => point + (to[index] - point) * easedProgress,
767
+ ) as TimelineBezierControlPoints;
768
+ }
769
+
770
+ function getEasingEditorPoint(x: number, y: number): [number, number] {
771
+ return [
772
+ easingEditorGridInset + easingEditorGridSize * x,
773
+ easingEditorGridInset + (1 - y) * easingEditorGridSize,
774
+ ];
775
+ }
776
+
777
+ function getEasingIconPath(
778
+ controlPoints: TimelineBezierControlPoints,
779
+ size: number,
780
+ ): string {
781
+ const pointX = (point: number): number => 3 + point * (size - 6);
782
+ const pointY = (point: number): number => size - 3 - point * (size - 6);
783
+
784
+ return `M ${pointX(0)} ${pointY(0)} C ${pointX(controlPoints[0])} ${pointY(
785
+ controlPoints[1],
786
+ )}, ${pointX(controlPoints[2])} ${pointY(controlPoints[3])}, ${pointX(1)} ${pointY(1)}`;
787
+ }
788
+
789
+ function getEasingPreviewPath(controlPoints: TimelineBezierControlPoints): string {
790
+ const [startX, startY] = getEasingEditorPoint(0, 0);
791
+ const [firstX, firstY] = getEasingEditorPoint(controlPoints[0], controlPoints[1]);
792
+ const [secondX, secondY] = getEasingEditorPoint(controlPoints[2], controlPoints[3]);
793
+ const [endX, endY] = getEasingEditorPoint(1, 1);
794
+
795
+ return `M ${startX} ${startY} C ${firstX} ${firstY}, ${secondX} ${secondY}, ${endX} ${endY}`;
796
+ }
797
+
798
+ function getEasingInputValue(easing: ToolcraftTimelineKeyframeEasing): string {
799
+ return easing.type === 'step'
800
+ ? 'step'
801
+ : formatTimelineBezierControlPoints(easing.controlPoints);
802
+ }
803
+
804
+ function getTimelineEasingPresetButtonClassName(isActive: boolean): string {
805
+ return cn(
806
+ timelineEasingPresetButtonBaseClassName,
807
+ isActive && selectedItemBorderClassName,
808
+ isActive && selectedItemSurfaceClassName,
809
+ isActive
810
+ ? 'text-[color:var(--foreground)]'
811
+ : 'border-[color:color-mix(in_oklab,var(--border)_8%,transparent)] text-[color:var(--muted-foreground)]',
812
+ );
813
+ }
814
+
815
+ function TimelineEasingCurveIcon({
816
+ className,
817
+ easing,
818
+ size = 20,
819
+ }: {
820
+ className?: string;
821
+ easing: ToolcraftTimelineKeyframeEasing;
822
+ size?: number;
823
+ }): React.JSX.Element {
824
+ if (easing.type === 'step') {
825
+ return (
826
+ <svg
827
+ aria-hidden="true"
828
+ className={cn('pointer-events-none', className)}
829
+ fill="none"
830
+ height={size}
831
+ viewBox={`0 0 ${size} ${size}`}
832
+ width={size}
833
+ >
834
+ <path
835
+ d={`M 3 ${size - 3} H ${size - 3} V 3`}
836
+ stroke="currentColor"
837
+ strokeLinecap="round"
838
+ strokeWidth={1.5}
839
+ />
840
+ </svg>
841
+ );
842
+ }
843
+
844
+ return (
845
+ <svg
846
+ aria-hidden="true"
847
+ className={cn('pointer-events-none', className)}
848
+ fill="none"
849
+ height={size}
850
+ viewBox={`0 0 ${size} ${size}`}
851
+ width={size}
852
+ >
853
+ <path
854
+ d={getEasingIconPath(easing.controlPoints, size)}
855
+ stroke="currentColor"
856
+ strokeLinecap="round"
857
+ strokeWidth={1.5}
858
+ />
859
+ </svg>
860
+ );
861
+ }
862
+
863
+ function TimelineEasingPresetIcon({
864
+ controlPoints,
865
+ }: {
866
+ controlPoints: TimelineBezierControlPoints;
867
+ }): React.JSX.Element {
868
+ const iconSize = timelineEasingPresetIconViewBoxSize;
869
+ const iconInset = 4;
870
+ const iconGridSize = iconSize - iconInset * 2;
871
+ const pointX = (point: number): number => iconInset + point * iconGridSize;
872
+ const pointY = (point: number): number => iconInset + (1 - point) * iconGridSize;
873
+ const path = `M ${pointX(0)} ${pointY(0)} C ${pointX(controlPoints[0])} ${pointY(
874
+ controlPoints[1],
875
+ )}, ${pointX(controlPoints[2])} ${pointY(controlPoints[3])}, ${pointX(1)} ${pointY(1)}`;
876
+
877
+ return (
878
+ <svg
879
+ aria-hidden="true"
880
+ className="pointer-events-none shrink-0"
881
+ data-slot="timeline-easing-preset-icon"
882
+ fill="none"
883
+ height={timelineEasingPresetIconSize}
884
+ viewBox={`0 0 ${iconSize} ${iconSize}`}
885
+ width={timelineEasingPresetIconSize}
886
+ >
887
+ <rect
888
+ fill="color-mix(in oklab, currentColor 8%, transparent)"
889
+ height={iconGridSize}
890
+ rx={2}
891
+ stroke="color-mix(in oklab, currentColor 18%, transparent)"
892
+ strokeWidth={1}
893
+ width={iconGridSize}
894
+ x={iconInset}
895
+ y={iconInset}
896
+ />
897
+ <path
898
+ d={path}
899
+ stroke={timelineEasingPresetIconLineColor}
900
+ strokeLinecap="round"
901
+ strokeWidth={1}
902
+ />
903
+ </svg>
904
+ );
905
+ }
906
+
907
+ function TimelineEasingStepPresetIcon(): React.JSX.Element {
908
+ return (
909
+ <svg
910
+ aria-hidden="true"
911
+ className="pointer-events-none shrink-0"
912
+ data-slot="timeline-easing-step-icon"
913
+ fill="none"
914
+ height={timelineEasingPresetIconSize}
915
+ viewBox="0 0 28 28"
916
+ width={timelineEasingPresetIconSize}
917
+ >
918
+ <rect
919
+ fill="color-mix(in oklab, currentColor 8%, transparent)"
920
+ height={20}
921
+ rx={2}
922
+ stroke="color-mix(in oklab, currentColor 18%, transparent)"
923
+ strokeWidth={1}
924
+ width={20}
925
+ x={4}
926
+ y={4}
927
+ />
928
+ <path
929
+ d="M 4 24 H 24 V 4"
930
+ stroke={timelineEasingPresetIconLineColor}
931
+ strokeLinecap="round"
932
+ strokeWidth={1}
933
+ />
934
+ </svg>
935
+ );
936
+ }
937
+
938
+ function TimelineEasingEditor({
939
+ easing,
940
+ onChange,
941
+ }: {
942
+ easing: ToolcraftTimelineKeyframeEasing;
943
+ onChange: (easing: ToolcraftTimelineKeyframeEasing) => void;
944
+ }): React.JSX.Element {
945
+ const svgRef = useRef<SVGSVGElement | null>(null);
946
+ const [dragTarget, setDragTarget] = useState<TimelineCurveEditorDragTarget | null>(null);
947
+ const targetControlPoints = getEasingEditorControlPoints(easing);
948
+ const targetControlPointsKey = `${easing.type}:${targetControlPoints.join(',')}`;
949
+ const [displayedControlPoints, setDisplayedControlPoints] =
950
+ useState<TimelineBezierControlPoints>(targetControlPoints);
951
+ const displayedControlPointsRef =
952
+ useRef<TimelineBezierControlPoints>(displayedControlPoints);
953
+ const animationFrameRef = useRef<number | null>(null);
954
+ const [isCurveAnimating, setIsCurveAnimating] = useState(false);
955
+ const isStep = easing.type === 'step';
956
+ const renderedControlPoints = dragTarget ? targetControlPoints : displayedControlPoints;
957
+ const [startX, startY] = getEasingEditorPoint(0, 0);
958
+ const [endX, endY] = getEasingEditorPoint(1, 1);
959
+ const [firstX, firstY] = getEasingEditorPoint(renderedControlPoints[0], renderedControlPoints[1]);
960
+ const [secondX, secondY] = getEasingEditorPoint(
961
+ renderedControlPoints[2],
962
+ renderedControlPoints[3],
963
+ );
964
+ const setAnimatedControlPoints = (
965
+ nextControlPoints: TimelineBezierControlPoints,
966
+ ): void => {
967
+ displayedControlPointsRef.current = nextControlPoints;
968
+ setDisplayedControlPoints(nextControlPoints);
969
+ };
970
+ const getEditorPointFromClient = (
971
+ clientX: number,
972
+ clientY: number,
973
+ ): { x: number; y: number } | null => {
974
+ if (!svgRef.current) {
975
+ return null;
976
+ }
977
+ const rect = svgRef.current.getBoundingClientRect();
978
+
979
+ if (!(rect.width > 0 && rect.height > 0)) {
980
+ return null;
981
+ }
982
+
983
+ const scale = Math.min(
984
+ rect.width / easingEditorFrameWidth,
985
+ rect.height / easingEditorFrameHeight,
986
+ );
987
+
988
+ if (!(scale > 0)) {
989
+ return null;
990
+ }
991
+
992
+ const viewBoxLeft = rect.left + (rect.width - easingEditorFrameWidth * scale) / 2;
993
+ const viewBoxTop = rect.top + (rect.height - easingEditorFrameHeight * scale) / 2;
994
+ const pointerX = (clientX - viewBoxLeft) / scale - easingEditorFrameOffsetX;
995
+ const pointerY = (clientY - viewBoxTop) / scale - easingEditorFrameOffsetY;
996
+
997
+ return {
998
+ x: Math.max(0, Math.min(1, (pointerX - easingEditorGridInset) / easingEditorGridSize)),
999
+ y: Math.max(-1, Math.min(2, 1 - (pointerY - easingEditorGridInset) / easingEditorGridSize)),
1000
+ };
1001
+ };
1002
+ const updateControlPoint = (
1003
+ target: TimelineCurveEditorDragTarget,
1004
+ nextPoint: { x: number; y: number },
1005
+ ): void => {
1006
+ const nextControlPoints = [...targetControlPoints] as TimelineBezierControlPoints;
1007
+
1008
+ if (target === 'p1') {
1009
+ nextControlPoints[0] = nextPoint.x;
1010
+ nextControlPoints[1] = nextPoint.y;
1011
+ } else {
1012
+ nextControlPoints[2] = nextPoint.x;
1013
+ nextControlPoints[3] = nextPoint.y;
1014
+ }
1015
+
1016
+ onChange({ controlPoints: nextControlPoints, type: 'bezier' });
1017
+ };
1018
+ const handleDragMove = (event: PointerEvent): void => {
1019
+ if (!dragTarget) {
1020
+ return;
1021
+ }
1022
+
1023
+ const nextPoint = getEditorPointFromClient(event.clientX, event.clientY);
1024
+
1025
+ if (!nextPoint) {
1026
+ return;
1027
+ }
1028
+
1029
+ updateControlPoint(dragTarget, nextPoint);
1030
+ };
1031
+
1032
+ useEffect(() => {
1033
+ const nextControlPoints = [...targetControlPoints] as TimelineBezierControlPoints;
1034
+
1035
+ if (animationFrameRef.current !== null) {
1036
+ window.cancelAnimationFrame(animationFrameRef.current);
1037
+ animationFrameRef.current = null;
1038
+ }
1039
+
1040
+ if (
1041
+ dragTarget ||
1042
+ typeof window === 'undefined' ||
1043
+ !window.requestAnimationFrame ||
1044
+ window.matchMedia?.('(prefers-reduced-motion: reduce)').matches
1045
+ ) {
1046
+ setIsCurveAnimating(false);
1047
+ setAnimatedControlPoints(nextControlPoints);
1048
+ return undefined;
1049
+ }
1050
+
1051
+ const startControlPoints = displayedControlPointsRef.current;
1052
+
1053
+ if (areEasingControlPointsEqual(startControlPoints, nextControlPoints)) {
1054
+ setIsCurveAnimating(false);
1055
+ setAnimatedControlPoints(nextControlPoints);
1056
+ return undefined;
1057
+ }
1058
+
1059
+ let animationStartTime: number | null = null;
1060
+
1061
+ setIsCurveAnimating(true);
1062
+
1063
+ const tick = (timestamp: number): void => {
1064
+ animationStartTime ??= timestamp;
1065
+
1066
+ const progress = Math.min(
1067
+ 1,
1068
+ (timestamp - animationStartTime) / timelineEasingCurveAnimationDurationMs,
1069
+ );
1070
+
1071
+ setAnimatedControlPoints(
1072
+ interpolateTimelineEasingControlPoints(startControlPoints, nextControlPoints, progress),
1073
+ );
1074
+
1075
+ if (progress < 1) {
1076
+ animationFrameRef.current = window.requestAnimationFrame(tick);
1077
+ return;
1078
+ }
1079
+
1080
+ animationFrameRef.current = null;
1081
+ setIsCurveAnimating(false);
1082
+ setAnimatedControlPoints(nextControlPoints);
1083
+ };
1084
+
1085
+ animationFrameRef.current = window.requestAnimationFrame(tick);
1086
+
1087
+ return () => {
1088
+ if (animationFrameRef.current !== null) {
1089
+ window.cancelAnimationFrame(animationFrameRef.current);
1090
+ animationFrameRef.current = null;
1091
+ }
1092
+ };
1093
+ }, [dragTarget, targetControlPointsKey]);
1094
+
1095
+ useEffect(() => {
1096
+ if (!dragTarget) {
1097
+ return;
1098
+ }
1099
+
1100
+ const handlePointerUp = (): void => {
1101
+ setDragTarget(null);
1102
+ };
1103
+ const previousCursor = document.body.style.cursor;
1104
+
1105
+ document.body.style.cursor = 'grabbing';
1106
+ window.addEventListener('pointermove', handleDragMove);
1107
+ window.addEventListener('pointerup', handlePointerUp);
1108
+ window.addEventListener('pointercancel', handlePointerUp);
1109
+
1110
+ return () => {
1111
+ document.body.style.cursor = previousCursor;
1112
+ window.removeEventListener('pointermove', handleDragMove);
1113
+ window.removeEventListener('pointerup', handlePointerUp);
1114
+ window.removeEventListener('pointercancel', handlePointerUp);
1115
+ };
1116
+ }, [targetControlPoints, dragTarget, handleDragMove]);
1117
+
1118
+ const startControlPointDrag =
1119
+ (target: TimelineCurveEditorDragTarget) => (event: React.PointerEvent<SVGElement>) => {
1120
+ event.preventDefault();
1121
+ event.stopPropagation();
1122
+ setDragTarget(target);
1123
+ };
1124
+ const startCurveDrag = (event: React.PointerEvent<SVGPathElement>): void => {
1125
+ event.preventDefault();
1126
+ event.stopPropagation();
1127
+
1128
+ const point = getEditorPointFromClient(event.clientX, event.clientY);
1129
+
1130
+ if (!point) {
1131
+ return;
1132
+ }
1133
+
1134
+ setDragTarget(point.x <= 0.5 ? 'p1' : 'p2');
1135
+ };
1136
+
1137
+ return (
1138
+ <svg
1139
+ aria-label="Easing curve editor"
1140
+ className="w-[240px] shrink-0 select-none"
1141
+ data-animating={isCurveAnimating ? 'true' : undefined}
1142
+ data-curve-animation-duration={timelineEasingCurveAnimationDurationMs}
1143
+ data-slot="timeline-easing-editor"
1144
+ height={easingEditorFrameHeight}
1145
+ ref={svgRef}
1146
+ role="img"
1147
+ style={{ touchAction: 'none' }}
1148
+ viewBox={`0 0 ${easingEditorFrameWidth} ${easingEditorFrameHeight}`}
1149
+ width={easingEditorFrameWidth}
1150
+ >
1151
+ <g transform={`translate(${easingEditorFrameOffsetX} ${easingEditorFrameOffsetY})`}>
1152
+ <rect
1153
+ fill="none"
1154
+ height={easingEditorGridSize}
1155
+ rx={2}
1156
+ stroke="color-mix(in oklab, var(--border) 10%, transparent)"
1157
+ strokeWidth={1}
1158
+ width={easingEditorGridSize}
1159
+ x={easingEditorGridInset}
1160
+ y={easingEditorGridInset}
1161
+ />
1162
+ {[0.25, 0.5, 0.75].map((point) => {
1163
+ const [gridX] = getEasingEditorPoint(point, 0);
1164
+ const [, gridY] = getEasingEditorPoint(0, point);
1165
+
1166
+ return (
1167
+ <g key={point}>
1168
+ <line
1169
+ stroke="color-mix(in oklab, var(--border) 6%, transparent)"
1170
+ strokeWidth={1}
1171
+ x1={gridX}
1172
+ x2={gridX}
1173
+ y1={easingEditorGridInset}
1174
+ y2={easingEditorGridInset + easingEditorGridSize}
1175
+ />
1176
+ <line
1177
+ stroke="color-mix(in oklab, var(--border) 6%, transparent)"
1178
+ strokeWidth={1}
1179
+ x1={easingEditorGridInset}
1180
+ x2={easingEditorGridInset + easingEditorGridSize}
1181
+ y1={gridY}
1182
+ y2={gridY}
1183
+ />
1184
+ </g>
1185
+ );
1186
+ })}
1187
+ <line
1188
+ stroke="color-mix(in oklab, var(--foreground) 12%, transparent)"
1189
+ strokeDasharray="3 3"
1190
+ strokeWidth={1}
1191
+ x1={startX}
1192
+ x2={endX}
1193
+ y1={startY}
1194
+ y2={endY}
1195
+ />
1196
+ {isStep ? (
1197
+ <>
1198
+ <line
1199
+ data-slot="timeline-easing-step-horizontal"
1200
+ stroke="color-mix(in oklab, var(--foreground) 85%, transparent)"
1201
+ strokeLinecap="round"
1202
+ strokeWidth={2}
1203
+ x1={startX}
1204
+ x2={endX}
1205
+ y1={startY}
1206
+ y2={startY}
1207
+ />
1208
+ <line
1209
+ data-slot="timeline-easing-step-vertical"
1210
+ stroke="color-mix(in oklab, var(--foreground) 85%, transparent)"
1211
+ strokeLinecap="round"
1212
+ strokeWidth={1.5}
1213
+ x1={endX}
1214
+ x2={endX}
1215
+ y1={startY}
1216
+ y2={endY}
1217
+ />
1218
+ </>
1219
+ ) : (
1220
+ <>
1221
+ <line
1222
+ stroke="color-mix(in oklab, var(--foreground) 25%, transparent)"
1223
+ strokeWidth={1}
1224
+ x1={startX}
1225
+ x2={firstX}
1226
+ y1={startY}
1227
+ y2={firstY}
1228
+ />
1229
+ <line
1230
+ stroke="color-mix(in oklab, var(--foreground) 25%, transparent)"
1231
+ strokeWidth={1}
1232
+ x1={endX}
1233
+ x2={secondX}
1234
+ y1={endY}
1235
+ y2={secondY}
1236
+ />
1237
+ <line
1238
+ className="cursor-grab active:cursor-grabbing"
1239
+ data-slot="timeline-easing-control-line-hit-area"
1240
+ onPointerDown={startControlPointDrag('p1')}
1241
+ pointerEvents="stroke"
1242
+ stroke="transparent"
1243
+ strokeLinecap="round"
1244
+ strokeWidth={18}
1245
+ x1={startX}
1246
+ x2={firstX}
1247
+ y1={startY}
1248
+ y2={firstY}
1249
+ />
1250
+ <line
1251
+ className="cursor-grab active:cursor-grabbing"
1252
+ data-slot="timeline-easing-control-line-hit-area"
1253
+ onPointerDown={startControlPointDrag('p2')}
1254
+ pointerEvents="stroke"
1255
+ stroke="transparent"
1256
+ strokeLinecap="round"
1257
+ strokeWidth={18}
1258
+ x1={endX}
1259
+ x2={secondX}
1260
+ y1={endY}
1261
+ y2={secondY}
1262
+ />
1263
+ <path
1264
+ d={getEasingPreviewPath(renderedControlPoints)}
1265
+ data-slot="timeline-easing-curve"
1266
+ fill="none"
1267
+ stroke="color-mix(in oklab, var(--foreground) 85%, transparent)"
1268
+ strokeLinecap="round"
1269
+ strokeWidth={2}
1270
+ />
1271
+ <path
1272
+ className="cursor-grab active:cursor-grabbing"
1273
+ d={getEasingPreviewPath(renderedControlPoints)}
1274
+ data-slot="timeline-easing-curve-hit-area"
1275
+ fill="none"
1276
+ onPointerDown={startCurveDrag}
1277
+ pointerEvents="stroke"
1278
+ stroke="transparent"
1279
+ strokeLinecap="round"
1280
+ strokeWidth={18}
1281
+ />
1282
+ <circle
1283
+ cx={startX}
1284
+ cy={startY}
1285
+ fill="color-mix(in oklab, var(--foreground) 50%, transparent)"
1286
+ r={3}
1287
+ />
1288
+ <circle
1289
+ cx={endX}
1290
+ cy={endY}
1291
+ fill="color-mix(in oklab, var(--foreground) 50%, transparent)"
1292
+ r={3}
1293
+ />
1294
+ <circle
1295
+ className="cursor-grab active:cursor-grabbing"
1296
+ cx={firstX}
1297
+ cy={firstY}
1298
+ fill="var(--foreground)"
1299
+ onPointerDown={startControlPointDrag('p1')}
1300
+ r={5}
1301
+ stroke="color-mix(in oklab, var(--background) 70%, transparent)"
1302
+ strokeWidth={1}
1303
+ />
1304
+ <circle
1305
+ className="cursor-grab active:cursor-grabbing"
1306
+ cx={secondX}
1307
+ cy={secondY}
1308
+ fill="var(--foreground)"
1309
+ onPointerDown={startControlPointDrag('p2')}
1310
+ r={5}
1311
+ stroke="color-mix(in oklab, var(--background) 70%, transparent)"
1312
+ strokeWidth={1}
1313
+ />
1314
+ </>
1315
+ )}
1316
+ </g>
1317
+ </svg>
1318
+ );
1319
+ }
1320
+
1321
+ function TimelineEasingPopoverContent({
1322
+ easing,
1323
+ onChange,
1324
+ }: {
1325
+ easing: ToolcraftTimelineKeyframeEasing;
1326
+ onChange: (easing: ToolcraftTimelineKeyframeEasing) => void;
1327
+ }): React.JSX.Element {
1328
+ const [inputValue, setInputValue] = useState(getEasingInputValue(easing));
1329
+ const [inputError, setInputError] = useState<string | null>(null);
1330
+ const [inputEditing, setInputEditing] = useState(false);
1331
+ const activePresetName = findTimelineEasingPresetName(easing);
1332
+ const committedInputValue = getEasingInputValue(easing);
1333
+ const isStep = easing.type === 'step';
1334
+ const popoverWidth = useTimelineEasingPopoverWidth();
1335
+
1336
+ useEffect(() => {
1337
+ if (inputEditing) {
1338
+ return;
1339
+ }
1340
+
1341
+ setInputValue(committedInputValue);
1342
+ setInputError(null);
1343
+ }, [committedInputValue, inputEditing]);
1344
+
1345
+ const commitInputValue = (
1346
+ value = inputValue,
1347
+ { revertOnInvalid = false }: { revertOnInvalid?: boolean } = {},
1348
+ ): void => {
1349
+ const nextEasing = parseToolcraftTimelineKeyframeEasing(value, easing);
1350
+
1351
+ if (!nextEasing) {
1352
+ if (revertOnInvalid) {
1353
+ setInputValue(committedInputValue);
1354
+ setInputError(null);
1355
+ return;
1356
+ }
1357
+
1358
+ setInputError('Use cubic-bezier(x1, y1, x2, y2) or step.');
1359
+ return;
1360
+ }
1361
+
1362
+ setInputError(null);
1363
+ setInputValue(getEasingInputValue(nextEasing));
1364
+
1365
+ if (getEasingInputValue(nextEasing) === committedInputValue) {
1366
+ return;
1367
+ }
1368
+
1369
+ onChange(nextEasing);
1370
+ };
1371
+
1372
+ return (
1373
+ <div className="flex flex-row items-stretch" style={{ width: popoverWidth }}>
1374
+ <TimelineEasingEditor easing={easing} onChange={onChange} />
1375
+ <span
1376
+ aria-hidden="true"
1377
+ className="w-px shrink-0 self-stretch bg-[color:color-mix(in_oklab,var(--border)_10%,transparent)]"
1378
+ data-slot="timeline-easing-divider"
1379
+ />
1380
+ <ScrollFade
1381
+ className="max-h-[240px] min-w-0 flex-1 py-3 pr-3 pl-3"
1382
+ containerClassName="min-w-0 flex-1"
1383
+ data-slot="timeline-easing-presets"
1384
+ height={36}
1385
+ preset="default"
1386
+ showOppositeSide
1387
+ side="bottom"
1388
+ visibilityMode="terminal"
1389
+ >
1390
+ <div className="flex flex-col gap-4">
1391
+ {timelineEasingPresetCategories.map(([category, categoryLabel]) => {
1392
+ const presets = timelineEasingPresets.filter(
1393
+ (preset) => preset.category === category,
1394
+ );
1395
+ const showStepPreset = category === 'basic';
1396
+
1397
+ if (presets.length === 0 && !showStepPreset) {
1398
+ return null;
1399
+ }
1400
+
1401
+ return (
1402
+ <div className="flex flex-col gap-1.5" key={category}>
1403
+ <span
1404
+ className="text-[11px] leading-4 opacity-60"
1405
+ data-slot="timeline-easing-section-label"
1406
+ >
1407
+ {categoryLabel}
1408
+ </span>
1409
+ <div className="grid grid-cols-2 gap-1.5">
1410
+ {showStepPreset ? (
1411
+ <button
1412
+ className={getTimelineEasingPresetButtonClassName(isStep)}
1413
+ onClick={() => onChange({ type: 'step' })}
1414
+ type="button"
1415
+ >
1416
+ <TimelineEasingStepPresetIcon />
1417
+ <span>Step Hold</span>
1418
+ </button>
1419
+ ) : null}
1420
+ {presets.map((preset) => {
1421
+ const isActive = activePresetName === preset.name;
1422
+
1423
+ return (
1424
+ <button
1425
+ className={getTimelineEasingPresetButtonClassName(isActive)}
1426
+ key={preset.name}
1427
+ onClick={() =>
1428
+ onChange({
1429
+ controlPoints: [...preset.controlPoints],
1430
+ type: 'bezier',
1431
+ })
1432
+ }
1433
+ type="button"
1434
+ >
1435
+ <TimelineEasingPresetIcon controlPoints={preset.controlPoints} />
1436
+ <span>{preset.label}</span>
1437
+ </button>
1438
+ );
1439
+ })}
1440
+ </div>
1441
+ </div>
1442
+ );
1443
+ })}
1444
+ <div className="flex flex-col gap-1.5">
1445
+ <div className="flex items-center gap-2">
1446
+ <span
1447
+ className="text-[11px] leading-4 opacity-60"
1448
+ data-slot="timeline-easing-section-label"
1449
+ >
1450
+ Curve Values
1451
+ </span>
1452
+ </div>
1453
+ <input
1454
+ className="h-8 cursor-text rounded-lg border border-[color:color-mix(in_oklab,var(--border)_10%,transparent)] bg-[color:color-mix(in_oklab,var(--background)_20%,transparent)] px-2.5 font-mono text-[12px] text-[color:var(--foreground)] transition-[background-color,border-color] duration-150 ease-out outline-none placeholder:text-[color:var(--muted-foreground)] in-data-[focus-visible-mode=keyboard]:focus-visible:border-[color:color-mix(in_oklab,var(--border)_22%,transparent)] in-data-[focus-visible-mode=keyboard]:focus-visible:bg-[color:color-mix(in_oklab,var(--foreground)_4%,transparent)]"
1455
+ onBlur={(event) => {
1456
+ commitInputValue(event.currentTarget.value, { revertOnInvalid: true });
1457
+ setInputEditing(false);
1458
+ }}
1459
+ onChange={(event) => {
1460
+ setInputValue(event.currentTarget.value);
1461
+ if (inputError) {
1462
+ setInputError(null);
1463
+ }
1464
+ }}
1465
+ onFocus={(event) => {
1466
+ setInputEditing(true);
1467
+ event.currentTarget.select();
1468
+ }}
1469
+ onKeyDown={(event) => {
1470
+ if (
1471
+ event.key === 'Backspace' ||
1472
+ event.key === 'Delete' ||
1473
+ event.key === 'ArrowLeft' ||
1474
+ event.key === 'ArrowRight' ||
1475
+ event.key === 'ArrowUp' ||
1476
+ event.key === 'ArrowDown'
1477
+ ) {
1478
+ event.stopPropagation();
1479
+ }
1480
+
1481
+ if (event.key === 'Enter') {
1482
+ event.preventDefault();
1483
+ event.stopPropagation();
1484
+ commitInputValue(event.currentTarget.value);
1485
+ }
1486
+ if (event.key === 'Escape') {
1487
+ event.preventDefault();
1488
+ event.stopPropagation();
1489
+ setInputValue(committedInputValue);
1490
+ setInputError(null);
1491
+ event.currentTarget.blur();
1492
+ }
1493
+ }}
1494
+ placeholder="0.19, 1, 0.22, 1 or step"
1495
+ type="text"
1496
+ value={inputValue}
1497
+ />
1498
+ {inputError ? (
1499
+ <p className="font-mono text-[10px] leading-3 text-[color:var(--destructive)]">
1500
+ {inputError}
1501
+ </p>
1502
+ ) : null}
1503
+ </div>
1504
+ </div>
1505
+ </ScrollFade>
1506
+ </div>
1507
+ );
1508
+ }
1509
+
1510
+ function TimelineKeyframeEasingPopover({
1511
+ easing,
1512
+ label,
1513
+ onChange,
1514
+ }: {
1515
+ easing?: ToolcraftTimelineKeyframeEasing;
1516
+ label: string;
1517
+ onChange?: (easing: ToolcraftTimelineKeyframeEasing) => void;
1518
+ }): React.JSX.Element {
1519
+ const resolvedEasing = getToolcraftTimelineKeyframeEasing(easing);
1520
+
1521
+ return (
1522
+ <Popover modal={false}>
1523
+ <PopoverTrigger
1524
+ render={
1525
+ <Button
1526
+ aria-label={`Edit ${label} keyframe curve`}
1527
+ className="text-[color:color-mix(in_oklab,var(--foreground)_75%,transparent)] hover:text-[color:var(--foreground)] data-popup-open:text-[color:var(--foreground)]"
1528
+ onClick={stopTimelineEasingEvent}
1529
+ onPointerDown={stopTimelineEasingEvent}
1530
+ size="icon-sm"
1531
+ type="button"
1532
+ variant="ghost"
1533
+ />
1534
+ }
1535
+ >
1536
+ <TimelineEasingCurveIcon easing={resolvedEasing} size={16} />
1537
+ </PopoverTrigger>
1538
+ <PopoverContent
1539
+ align="center"
1540
+ anchor={getTimelineEasingPopoverAnchor}
1541
+ className="toolcraft-panel-surface isolate w-auto gap-0 overflow-hidden rounded-lg border p-0 supports-backdrop-filter:backdrop-blur-2xl supports-backdrop-filter:backdrop-saturate-150"
1542
+ data-timeline-keyframe-easing-popover=""
1543
+ onClick={stopTimelineEasingEvent}
1544
+ onPointerDown={stopTimelineEasingEvent}
1545
+ side="bottom"
1546
+ sideOffset={6}
1547
+ >
1548
+ <TimelineEasingPopoverContent
1549
+ easing={resolvedEasing}
1550
+ onChange={(nextEasing) => onChange?.(nextEasing)}
1551
+ />
1552
+ </PopoverContent>
1553
+ </Popover>
1554
+ );
1555
+ }
1556
+
1557
+ function TimelinePanelMask({
1558
+ currentTimeSeconds,
1559
+ durationSeconds,
1560
+ isHandleVisible,
1561
+ }: {
1562
+ currentTimeSeconds: number;
1563
+ durationSeconds: number;
1564
+ isHandleVisible: boolean;
1565
+ }): React.JSX.Element {
1566
+ const progressRatio = getTimelineProgressRatio(currentTimeSeconds, durationSeconds);
1567
+ const progressPercent = getTimelineProgressPercent(currentTimeSeconds, durationSeconds);
1568
+ const progressMask =
1569
+ progressRatio >= 1 && isHandleVisible
1570
+ ? 'linear-gradient(to right, transparent 0%, black 5%, black 100%)'
1571
+ : 'linear-gradient(to right, transparent 0%, black 5%, black 95%, transparent 100%)';
1572
+
1573
+ return (
1574
+ <div
1575
+ aria-hidden="true"
1576
+ className="pointer-events-none absolute inset-x-0 inset-y-[-1px] overflow-hidden rounded-t-lg rounded-b-lg"
1577
+ data-slot="timeline-panel-mask"
1578
+ >
1579
+ <span
1580
+ className="absolute inset-x-1 bottom-0 h-px [mask-image:var(--timeline-progress-edge-fade-mask)] [-webkit-mask-image:var(--timeline-progress-edge-fade-mask)]"
1581
+ data-slot="timeline-playback-line"
1582
+ style={
1583
+ {
1584
+ '--timeline-progress-edge-fade-mask': progressMask,
1585
+ } as CSSProperties
1586
+ }
1587
+ >
1588
+ <span
1589
+ className="absolute bottom-0 left-0 h-px rounded-b-lg bg-[color:color-mix(in_oklab,var(--link)_90%,transparent)]"
1590
+ data-slot="timeline-playback-progress"
1591
+ style={{ width: progressRatio <= 0 ? '0px' : progressPercent }}
1592
+ />
1593
+ </span>
1594
+ </div>
1595
+ );
1596
+ }
1597
+
1598
+ function getKeyboardScrubTime({
1599
+ currentTimeSeconds,
1600
+ durationSeconds,
1601
+ key,
1602
+ }: {
1603
+ currentTimeSeconds: number;
1604
+ durationSeconds: number;
1605
+ key: string;
1606
+ }): number | null {
1607
+ if (key === 'ArrowLeft') {
1608
+ return currentTimeSeconds - timelineScrubStepSeconds;
1609
+ }
1610
+
1611
+ if (key === 'ArrowRight') {
1612
+ return currentTimeSeconds + timelineScrubStepSeconds;
1613
+ }
1614
+
1615
+ if (key === 'Home') {
1616
+ return 0;
1617
+ }
1618
+
1619
+ if (key === 'End') {
1620
+ return durationSeconds;
1621
+ }
1622
+
1623
+ return null;
1624
+ }
1625
+
1626
+ function TimelinePlaybackStrip({
1627
+ currentTimeSeconds,
1628
+ durationSeconds,
1629
+ isScrubbing,
1630
+ onKeyDown,
1631
+ onPointerDown,
1632
+ onPointerMove,
1633
+ onPointerUp,
1634
+ stripRef,
1635
+ }: {
1636
+ currentTimeSeconds: number;
1637
+ durationSeconds: number;
1638
+ isScrubbing: boolean;
1639
+ onKeyDown: (event: React.KeyboardEvent<HTMLDivElement>) => void;
1640
+ onPointerDown: (event: React.PointerEvent<HTMLDivElement>) => void;
1641
+ onPointerMove: (event: React.PointerEvent<HTMLDivElement>) => void;
1642
+ onPointerUp: (event: React.PointerEvent<HTMLDivElement>) => void;
1643
+ stripRef: React.RefObject<HTMLDivElement | null>;
1644
+ }): React.JSX.Element {
1645
+ const handlePosition = getTimelineHandlePosition(currentTimeSeconds, durationSeconds);
1646
+
1647
+ return (
1648
+ <div
1649
+ aria-label="Playback position"
1650
+ aria-valuemax={Number(formatTimelineSeconds(durationSeconds))}
1651
+ aria-valuemin={0}
1652
+ aria-valuenow={Number(formatTimelineSeconds(currentTimeSeconds))}
1653
+ className={cn(
1654
+ 'group/timeline-strip absolute right-[-11px] bottom-[-5px] left-[-5px] z-0 h-2 cursor-ew-resize touch-none outline-none',
1655
+ isScrubbing && 'cursor-grabbing',
1656
+ )}
1657
+ data-dragging={isScrubbing ? 'true' : undefined}
1658
+ onKeyDown={onKeyDown}
1659
+ onPointerCancel={onPointerUp}
1660
+ onPointerDown={onPointerDown}
1661
+ onPointerMove={onPointerMove}
1662
+ onPointerUp={onPointerUp}
1663
+ ref={stripRef}
1664
+ role="slider"
1665
+ tabIndex={0}
1666
+ >
1667
+ <span
1668
+ aria-hidden="true"
1669
+ className={cn(
1670
+ 'absolute bottom-px size-2 -translate-x-1/2 translate-y-1/2 rounded-full bg-[color:var(--link)] opacity-0 shadow-[0_2px_2px_color-mix(in_oklab,var(--background)_70%,transparent)] transition-[opacity,transform] duration-[120ms] ease-out group-hover/timeline-panel-surface:opacity-100 group-hover/timeline-strip:opacity-100 group-focus-visible/timeline-strip:opacity-100',
1671
+ isScrubbing && 'scale-110 opacity-100',
1672
+ )}
1673
+ data-slot="timeline-playback-handle"
1674
+ style={{ left: handlePosition }}
1675
+ />
1676
+ </div>
1677
+ );
1678
+ }
1679
+
1680
+ function getTimelineRulerTicks(durationSeconds: number): number[] {
1681
+ return [0, 0.25, 0.5, 0.75, 1].map((ratio) => durationSeconds * ratio);
1682
+ }
1683
+
1684
+ function getTimelineRulerMarkRatios(): number[] {
1685
+ return Array.from({ length: 33 }, (_value, index) => index / 32);
1686
+ }
1687
+
1688
+ function getTimelineTrackPositionStyle(
1689
+ currentTimeSeconds: number,
1690
+ durationSeconds: number,
1691
+ ): CSSProperties {
1692
+ const ratio = Math.max(0, Math.min(1, currentTimeSeconds / durationSeconds));
1693
+
1694
+ return getTimelineCalcPositionStyle(
1695
+ ratio,
1696
+ timelineExpandedTrackStartOffsetPx * (1 - ratio) - timelineExpandedTrackEndOffsetPx * ratio,
1697
+ );
1698
+ }
1699
+
1700
+ function getTimelineKeyframePositionStyle(
1701
+ timeSeconds: number,
1702
+ durationSeconds: number,
1703
+ ): CSSProperties {
1704
+ const ratio = Math.max(0, Math.min(1, timeSeconds / durationSeconds));
1705
+
1706
+ return getTimelineCalcPositionStyle(ratio, timelineTrackStartVisualOffsetPx * (1 - ratio));
1707
+ }
1708
+
1709
+ function getTimelineTrackTimeFromClientX({
1710
+ clientX,
1711
+ durationSeconds,
1712
+ trackElement,
1713
+ }: {
1714
+ clientX: number;
1715
+ durationSeconds: number;
1716
+ trackElement: HTMLElement;
1717
+ }): number {
1718
+ const rect = trackElement.getBoundingClientRect();
1719
+ const trackLeft = rect.left + timelineTrackStartVisualOffsetPx;
1720
+ const trackWidth = Math.max(
1721
+ 1,
1722
+ rect.width - timelineTrackStartVisualOffsetPx - timelineTrackEndInsetPx,
1723
+ );
1724
+ const ratio = Math.max(0, Math.min(1, (clientX - trackLeft) / trackWidth));
1725
+
1726
+ return getRoundedKeyframeTime(clampTimelineTime(durationSeconds * ratio, durationSeconds));
1727
+ }
1728
+
1729
+ function getTimelineCalcPositionStyle(ratio: number, pixelOffset: number): CSSProperties {
1730
+ const roundedPixelOffset = Number.parseFloat(pixelOffset.toFixed(3));
1731
+ const offsetOperator = roundedPixelOffset < 0 ? '-' : '+';
1732
+
1733
+ return {
1734
+ left: `calc(${ratio * 100}% ${offsetOperator} ${Math.abs(roundedPixelOffset)}px)`,
1735
+ };
1736
+ }
1737
+
1738
+ function getTimelineEventTargetElement(target: EventTarget | null): Element | null {
1739
+ return target instanceof Element ? target : null;
1740
+ }
1741
+
1742
+ function isTimelineInteractiveElement(target: EventTarget | null): boolean {
1743
+ return Boolean(
1744
+ getTimelineEventTargetElement(target)?.closest(
1745
+ "button, input, textarea, select, [contenteditable='true'], [role='button']",
1746
+ ),
1747
+ );
1748
+ }
1749
+
1750
+ function isEditableTimelineEventTarget(target: EventTarget | null): boolean {
1751
+ return Boolean(
1752
+ getTimelineEventTargetElement(target)?.closest(
1753
+ "input, textarea, select, [contenteditable='true']",
1754
+ ),
1755
+ );
1756
+ }
1757
+
1758
+ type TimelineKeyframeDragState = {
1759
+ controlId: string;
1760
+ didMove: boolean;
1761
+ initialTimeSeconds: number;
1762
+ keyframeId: string;
1763
+ latestTimeSeconds: number;
1764
+ pointerId: number;
1765
+ trackElement: HTMLElement;
1766
+ wasSelectedOnPointerDown: boolean;
1767
+ };
1768
+
1769
+ function findTimelineKeyframe(
1770
+ keyframeGroups: readonly ToolcraftTimelineKeyframeGroup[],
1771
+ keyframeId: string | null,
1772
+ ): ToolcraftTimelineKeyframe | undefined {
1773
+ if (!keyframeId) {
1774
+ return undefined;
1775
+ }
1776
+
1777
+ for (const group of keyframeGroups) {
1778
+ const keyframe = group.keyframes.find((currentKeyframe) => currentKeyframe.id === keyframeId);
1779
+
1780
+ if (keyframe) {
1781
+ return keyframe;
1782
+ }
1783
+ }
1784
+
1785
+ return undefined;
1786
+ }
1787
+
1788
+ function TimelineKeyframeRow({
1789
+ durationSeconds,
1790
+ group,
1791
+ isScrubbing,
1792
+ onChangeKeyframeEasing,
1793
+ onDeleteControlKeyframes,
1794
+ onKeyframeDragStart,
1795
+ onMoveKeyframe,
1796
+ onSelectedKeyframeChange,
1797
+ selectedKeyframeId,
1798
+ }: {
1799
+ durationSeconds: number;
1800
+ group: ToolcraftTimelineKeyframeGroup;
1801
+ isScrubbing: boolean;
1802
+ onChangeKeyframeEasing: (keyframeId: string, easing: ToolcraftTimelineKeyframeEasing) => void;
1803
+ onDeleteControlKeyframes: (controlId: string) => void;
1804
+ onKeyframeDragStart: () => void;
1805
+ onMoveKeyframe: (keyframeId: string, timeSeconds: number) => string | null;
1806
+ onSelectedKeyframeChange: (keyframeId: string | null) => void;
1807
+ selectedKeyframeId: string | null;
1808
+ }): React.JSX.Element {
1809
+ const [isVisible, setIsVisible] = useState(true);
1810
+ const [draftKeyframeTimes, setDraftKeyframeTimes] = useState<Record<string, number>>({});
1811
+ const keyframeDragRef = useRef<TimelineKeyframeDragState | null>(null);
1812
+ const keyframeClickIntentRef = useRef<{
1813
+ didMove: boolean;
1814
+ keyframeId: string;
1815
+ wasSelectedOnPointerDown: boolean;
1816
+ } | null>(null);
1817
+ const selectedGroupKeyframe = group.keyframes.find(
1818
+ (keyframe) => keyframe.id === selectedKeyframeId,
1819
+ );
1820
+ const getKeyframeTrackElement = (target: Element): HTMLElement | null =>
1821
+ target.closest('[data-slot="timeline-keyframe-track"]');
1822
+ const handleKeyframePointerDown = (
1823
+ event: React.PointerEvent<HTMLButtonElement>,
1824
+ keyframe: ToolcraftTimelineKeyframe,
1825
+ ): void => {
1826
+ const trackElement = getKeyframeTrackElement(event.currentTarget);
1827
+
1828
+ if (!trackElement) {
1829
+ return;
1830
+ }
1831
+
1832
+ event.preventDefault();
1833
+ event.stopPropagation();
1834
+ event.currentTarget.setPointerCapture?.(event.pointerId);
1835
+ onSelectedKeyframeChange(keyframe.id);
1836
+ onKeyframeDragStart();
1837
+ const wasSelectedOnPointerDown = keyframe.id === selectedKeyframeId;
1838
+
1839
+ keyframeClickIntentRef.current = {
1840
+ didMove: false,
1841
+ keyframeId: keyframe.id,
1842
+ wasSelectedOnPointerDown,
1843
+ };
1844
+ keyframeDragRef.current = {
1845
+ controlId: keyframe.controlId,
1846
+ didMove: false,
1847
+ initialTimeSeconds: keyframe.timeSeconds,
1848
+ keyframeId: keyframe.id,
1849
+ latestTimeSeconds: keyframe.timeSeconds,
1850
+ pointerId: event.pointerId,
1851
+ trackElement,
1852
+ wasSelectedOnPointerDown,
1853
+ };
1854
+ };
1855
+ const handleKeyframePointerMove = (event: React.PointerEvent<HTMLButtonElement>): void => {
1856
+ const dragState = keyframeDragRef.current;
1857
+
1858
+ if (!dragState || dragState.pointerId !== event.pointerId) {
1859
+ return;
1860
+ }
1861
+
1862
+ event.preventDefault();
1863
+ event.stopPropagation();
1864
+
1865
+ const nextTimeSeconds = getTimelineTrackTimeFromClientX({
1866
+ clientX: event.clientX,
1867
+ durationSeconds,
1868
+ trackElement: dragState.trackElement,
1869
+ });
1870
+
1871
+ dragState.latestTimeSeconds = nextTimeSeconds;
1872
+ const didMove = nextTimeSeconds !== dragState.initialTimeSeconds;
1873
+ dragState.didMove = didMove;
1874
+
1875
+ if (keyframeClickIntentRef.current?.keyframeId === dragState.keyframeId) {
1876
+ keyframeClickIntentRef.current.didMove = didMove;
1877
+ }
1878
+
1879
+ setDraftKeyframeTimes((currentDrafts) =>
1880
+ currentDrafts[dragState.keyframeId] === nextTimeSeconds
1881
+ ? currentDrafts
1882
+ : { ...currentDrafts, [dragState.keyframeId]: nextTimeSeconds },
1883
+ );
1884
+ };
1885
+ const endKeyframeDrag = (event: React.PointerEvent<HTMLButtonElement>): void => {
1886
+ const dragState = keyframeDragRef.current;
1887
+
1888
+ if (!dragState || dragState.pointerId !== event.pointerId) {
1889
+ return;
1890
+ }
1891
+
1892
+ event.preventDefault();
1893
+ event.stopPropagation();
1894
+
1895
+ if (event.currentTarget.hasPointerCapture?.(event.pointerId)) {
1896
+ event.currentTarget.releasePointerCapture(event.pointerId);
1897
+ }
1898
+
1899
+ if (dragState.didMove) {
1900
+ const nextSelectedKeyframeId = onMoveKeyframe(
1901
+ dragState.keyframeId,
1902
+ dragState.latestTimeSeconds,
1903
+ );
1904
+
1905
+ onSelectedKeyframeChange(
1906
+ nextSelectedKeyframeId ?? getKeyframeId(dragState.controlId, dragState.latestTimeSeconds),
1907
+ );
1908
+ }
1909
+
1910
+ setDraftKeyframeTimes((currentDrafts) => {
1911
+ const nextDrafts = { ...currentDrafts };
1912
+
1913
+ delete nextDrafts[dragState.keyframeId];
1914
+ return nextDrafts;
1915
+ });
1916
+ keyframeDragRef.current = null;
1917
+ };
1918
+
1919
+ return (
1920
+ <motion.div
1921
+ animate={{ height: timelineKeyframeRowHeightPx, opacity: 1 }}
1922
+ className={cn(
1923
+ 'w-full shrink-0 overflow-hidden border-t border-[color:color-mix(in_oklab,var(--border)_6%,transparent)] transition-colors duration-150 ease-out select-none first:border-t-0',
1924
+ selectedGroupKeyframe
1925
+ ? 'bg-[color:color-mix(in_oklab,var(--foreground)_3%,transparent)]'
1926
+ : !isScrubbing && 'hover:bg-[color:color-mix(in_oklab,var(--foreground)_3%,transparent)]',
1927
+ )}
1928
+ data-scrubbing={isScrubbing ? 'true' : 'false'}
1929
+ data-slot="timeline-keyframe-row"
1930
+ data-visible={isVisible ? 'true' : 'false'}
1931
+ exit={{ height: 0, opacity: 0 }}
1932
+ initial={{ height: 0, opacity: 0 }}
1933
+ transition={timelineKeyframePresenceTransition}
1934
+ >
1935
+ <div className="grid h-9 w-full grid-cols-[164px_minmax(0,1fr)_36px] overflow-visible">
1936
+ <div className="flex h-full min-w-0 items-center gap-1.5 border-r border-[color:color-mix(in_oklab,var(--border)_6%,transparent)] pr-1.5 pl-1 text-[11px] leading-4 text-[color:color-mix(in_oklab,var(--foreground)_75%,transparent)] select-none">
1937
+ <TimelineIconButton
1938
+ label={`Toggle ${group.label} visibility`}
1939
+ onClick={() => setIsVisible((currentValue) => !currentValue)}
1940
+ size="icon-sm"
1941
+ tooltipSide="top"
1942
+ >
1943
+ {isVisible ? (
1944
+ <Eye data-icon="visibility-visible" />
1945
+ ) : (
1946
+ <EyeOff data-icon="visibility-hidden" />
1947
+ )}
1948
+ </TimelineIconButton>
1949
+ <span
1950
+ className={cn(
1951
+ 'block min-w-0 flex-1 truncate pr-2 transition-[color,opacity] duration-150 ease-out',
1952
+ !isVisible && 'text-[color:var(--foreground)] opacity-40',
1953
+ )}
1954
+ title={group.label}
1955
+ >
1956
+ {group.label}
1957
+ </span>
1958
+ {selectedGroupKeyframe ? (
1959
+ <span className="ml-auto flex shrink-0" data-slot="timeline-keyframe-easing-control">
1960
+ <TimelineKeyframeEasingPopover
1961
+ easing={selectedGroupKeyframe.easing}
1962
+ label={group.label}
1963
+ onChange={(nextEasing) =>
1964
+ onChangeKeyframeEasing(selectedGroupKeyframe.id, nextEasing)
1965
+ }
1966
+ />
1967
+ </span>
1968
+ ) : null}
1969
+ </div>
1970
+ <div
1971
+ className="relative h-full min-h-0 overflow-visible border-r border-[color:color-mix(in_oklab,var(--border)_6%,transparent)]"
1972
+ data-slot="timeline-keyframe-track"
1973
+ >
1974
+ <div
1975
+ className={cn(
1976
+ 'absolute inset-0 overflow-visible',
1977
+ isVisible ? 'text-[color:var(--link)]' : 'text-[color:var(--foreground)]',
1978
+ )}
1979
+ data-slot="timeline-keyframe-track-content"
1980
+ style={{ opacity: isVisible ? undefined : 0.15 }}
1981
+ >
1982
+ <span
1983
+ className={cn(
1984
+ 'absolute top-1/2 right-0 h-px -translate-y-1/2',
1985
+ isVisible
1986
+ ? 'bg-[color:color-mix(in_oklab,currentColor_40%,transparent)]'
1987
+ : 'bg-current',
1988
+ )}
1989
+ style={{ left: timelineTrackStartVisualOffsetPx }}
1990
+ />
1991
+ <AnimatePresence initial={false}>
1992
+ {group.keyframes.map((keyframe) => {
1993
+ const isSelected = keyframe.id === selectedKeyframeId;
1994
+ const displayTimeSeconds = draftKeyframeTimes[keyframe.id] ?? keyframe.timeSeconds;
1995
+
1996
+ return (
1997
+ <motion.button
1998
+ animate={{ opacity: 1 }}
1999
+ aria-label={`${group.label} keyframe at ${formatTimelineSeconds(
2000
+ keyframe.timeSeconds,
2001
+ )}s`}
2002
+ aria-pressed={isSelected}
2003
+ className="absolute top-1/2 z-30 m-0 size-2 -translate-x-1/2 -translate-y-1/2 cursor-default appearance-none border-0 bg-transparent p-0 text-current outline-none"
2004
+ data-selected={isSelected ? 'true' : undefined}
2005
+ data-slot="timeline-keyframe"
2006
+ exit={{ opacity: 0 }}
2007
+ initial={{ opacity: 0 }}
2008
+ key={keyframe.id}
2009
+ onClick={(event) => {
2010
+ event.preventDefault();
2011
+ event.stopPropagation();
2012
+ const clickIntent = keyframeClickIntentRef.current;
2013
+
2014
+ keyframeClickIntentRef.current = null;
2015
+
2016
+ if (clickIntent?.didMove) {
2017
+ return;
2018
+ }
2019
+
2020
+ if (clickIntent?.keyframeId === keyframe.id) {
2021
+ onSelectedKeyframeChange(
2022
+ clickIntent.wasSelectedOnPointerDown ? null : keyframe.id,
2023
+ );
2024
+ return;
2025
+ }
2026
+
2027
+ onSelectedKeyframeChange(isSelected ? null : keyframe.id);
2028
+ }}
2029
+ onPointerCancel={endKeyframeDrag}
2030
+ onPointerDown={(event) => handleKeyframePointerDown(event, keyframe)}
2031
+ onPointerMove={handleKeyframePointerMove}
2032
+ onPointerUp={endKeyframeDrag}
2033
+ style={getTimelineKeyframePositionStyle(displayTimeSeconds, durationSeconds)}
2034
+ title={keyframe.valueLabel}
2035
+ transition={timelineKeyframePresenceTransition}
2036
+ type="button"
2037
+ >
2038
+ <span
2039
+ aria-hidden="true"
2040
+ className={cn(
2041
+ 'absolute top-1/2 left-1/2 block size-[7px] -translate-x-1/2 -translate-y-1/2 rotate-45 rounded-[1px]',
2042
+ isSelected && isVisible ? 'bg-[color:var(--foreground)]' : 'bg-current',
2043
+ )}
2044
+ />
2045
+ </motion.button>
2046
+ );
2047
+ })}
2048
+ </AnimatePresence>
2049
+ </div>
2050
+ </div>
2051
+ <div className="flex h-full min-w-0 items-center justify-center">
2052
+ <TimelineIconButton
2053
+ label={`Delete ${group.label} keyframes`}
2054
+ onClick={() => {
2055
+ onSelectedKeyframeChange(null);
2056
+ onDeleteControlKeyframes(group.controlId);
2057
+ }}
2058
+ size="icon-sm"
2059
+ tooltipSide="top"
2060
+ >
2061
+ <Trash2 />
2062
+ </TimelineIconButton>
2063
+ </div>
2064
+ </div>
2065
+ </motion.div>
2066
+ );
2067
+ }
2068
+
2069
+ function TimelineExpandedContent({
2070
+ currentTimeSeconds,
2071
+ durationSeconds,
2072
+ isScrubbing,
2073
+ keyframeGroups,
2074
+ onChangeKeyframeEasing,
2075
+ onDeleteControlKeyframes,
2076
+ onDeleteKeyframe,
2077
+ onKeyframeDragStart,
2078
+ onKeyDown,
2079
+ onMoveKeyframe,
2080
+ onPointerDown,
2081
+ onPointerMove,
2082
+ onPointerUp,
2083
+ onSelectedKeyframeChange,
2084
+ selectedKeyframeId,
2085
+ stripRef,
2086
+ }: {
2087
+ currentTimeSeconds: number;
2088
+ durationSeconds: number;
2089
+ isScrubbing: boolean;
2090
+ keyframeGroups: readonly ToolcraftTimelineKeyframeGroup[];
2091
+ onChangeKeyframeEasing: (keyframeId: string, easing: ToolcraftTimelineKeyframeEasing) => void;
2092
+ onDeleteControlKeyframes: (controlId: string) => void;
2093
+ onDeleteKeyframe: (keyframeId: string) => void;
2094
+ onKeyframeDragStart: () => void;
2095
+ onKeyDown: (event: React.KeyboardEvent<HTMLDivElement>) => void;
2096
+ onMoveKeyframe: (keyframeId: string, timeSeconds: number) => string | null;
2097
+ onPointerDown: (event: React.PointerEvent<HTMLDivElement>) => void;
2098
+ onPointerMove: (event: React.PointerEvent<HTMLDivElement>) => void;
2099
+ onPointerUp: (event: React.PointerEvent<HTMLDivElement>) => void;
2100
+ onSelectedKeyframeChange: (keyframeId: string | null) => void;
2101
+ selectedKeyframeId: string | null;
2102
+ stripRef: React.RefObject<HTMLDivElement | null>;
2103
+ }): React.JSX.Element {
2104
+ const trackPlayheadStyle = getTimelineTrackPositionStyle(currentTimeSeconds, durationSeconds);
2105
+ const selectedKeyframe = findTimelineKeyframe(keyframeGroups, selectedKeyframeId);
2106
+ const deleteSelectedKeyframe = (): void => {
2107
+ if (!selectedKeyframeId) {
2108
+ return;
2109
+ }
2110
+
2111
+ onDeleteKeyframe(selectedKeyframeId);
2112
+ onSelectedKeyframeChange(null);
2113
+ };
2114
+ const moveSelectedKeyframeByStep = (direction: -1 | 1): void => {
2115
+ if (!selectedKeyframe) {
2116
+ return;
2117
+ }
2118
+
2119
+ const nextTimeSeconds = getRoundedKeyframeTime(
2120
+ clampTimelineTime(
2121
+ selectedKeyframe.timeSeconds + timelineScrubStepSeconds * direction,
2122
+ durationSeconds,
2123
+ ),
2124
+ );
2125
+
2126
+ if (nextTimeSeconds === selectedKeyframe.timeSeconds) {
2127
+ return;
2128
+ }
2129
+
2130
+ const nextSelectedKeyframeId = onMoveKeyframe(selectedKeyframe.id, nextTimeSeconds);
2131
+
2132
+ onSelectedKeyframeChange(nextSelectedKeyframeId ?? selectedKeyframe.id);
2133
+ };
2134
+ const handleExpandedKeyDown = (event: React.KeyboardEvent<HTMLDivElement>): void => {
2135
+ if (isEditableTimelineEventTarget(event.target)) {
2136
+ return;
2137
+ }
2138
+
2139
+ if (event.key === 'Delete' || event.key === 'Backspace') {
2140
+ if (!selectedKeyframeId) {
2141
+ return;
2142
+ }
2143
+
2144
+ event.preventDefault();
2145
+ deleteSelectedKeyframe();
2146
+ return;
2147
+ }
2148
+
2149
+ if (event.key === 'Escape' && selectedKeyframeId) {
2150
+ event.preventDefault();
2151
+ onSelectedKeyframeChange(null);
2152
+ return;
2153
+ }
2154
+
2155
+ if (event.key === 'ArrowLeft' && selectedKeyframe) {
2156
+ event.preventDefault();
2157
+ moveSelectedKeyframeByStep(-1);
2158
+ return;
2159
+ }
2160
+
2161
+ if (event.key === 'ArrowRight' && selectedKeyframe) {
2162
+ event.preventDefault();
2163
+ moveSelectedKeyframeByStep(1);
2164
+ return;
2165
+ }
2166
+
2167
+ onKeyDown(event);
2168
+ };
2169
+ const handleExpandedPointerDown = (event: React.PointerEvent<HTMLDivElement>): void => {
2170
+ const targetElement = getTimelineEventTargetElement(event.target);
2171
+ const clickedKeyframe = targetElement?.closest('[data-slot="timeline-keyframe"]');
2172
+ const clickedInteractiveElement = isTimelineInteractiveElement(event.target);
2173
+
2174
+ if (!clickedKeyframe && !clickedInteractiveElement && selectedKeyframeId) {
2175
+ onSelectedKeyframeChange(null);
2176
+ }
2177
+
2178
+ if (clickedInteractiveElement && !clickedKeyframe) {
2179
+ return;
2180
+ }
2181
+
2182
+ onPointerDown(event);
2183
+ };
2184
+
2185
+ return (
2186
+ <div className="flex min-h-0 flex-1 flex-col" data-slot="timeline-expanded">
2187
+ <div className="relative grid h-9 min-w-0 shrink-0 grid-cols-[164px_minmax(0,1fr)_36px] after:pointer-events-none after:absolute after:inset-x-0 after:-bottom-px after:h-px after:bg-[color:color-mix(in_oklab,var(--border)_20%,transparent)]">
2188
+ <div className="flex min-w-0 items-center px-3 text-[11px] leading-4 text-[color:color-mix(in_oklab,var(--foreground)_75%,transparent)] select-none">
2189
+ <span className="min-w-0 truncate opacity-60">Properties</span>
2190
+ </div>
2191
+ <div className="relative min-w-0 text-[10px] leading-none text-[color:color-mix(in_oklab,var(--muted-foreground)_80%,transparent)] tabular-nums">
2192
+ <div
2193
+ className="absolute top-[13px] h-2 overflow-visible"
2194
+ data-slot="timeline-expanded-ruler-labels"
2195
+ style={{ left: timelineRulerLeftInsetPx, right: timelineRulerRightInsetPx }}
2196
+ >
2197
+ {getTimelineRulerTicks(durationSeconds).map((tick, index, ticks) => (
2198
+ <span
2199
+ className="absolute top-0 -translate-x-1/2 text-center"
2200
+ key={tick.toFixed(2)}
2201
+ style={{ left: `${(index / (ticks.length - 1)) * 100}%` }}
2202
+ >
2203
+ {Math.round(tick)}
2204
+ </span>
2205
+ ))}
2206
+ </div>
2207
+ <div
2208
+ aria-hidden="true"
2209
+ className="pointer-events-none absolute bottom-0 h-2"
2210
+ data-slot="timeline-expanded-ruler"
2211
+ style={{ left: timelineRulerLeftInsetPx, right: timelineRulerRightInsetPx }}
2212
+ >
2213
+ {getTimelineRulerMarkRatios().map((ratio, index) => (
2214
+ <span
2215
+ className={cn(
2216
+ 'absolute bottom-0 w-px -translate-x-1/2',
2217
+ index % 8 === 0
2218
+ ? 'h-2.5 bg-[color:color-mix(in_oklab,var(--border)_20%,transparent)]'
2219
+ : 'h-1.5 bg-[color:color-mix(in_oklab,var(--border)_10%,transparent)]',
2220
+ )}
2221
+ data-major-tick={index % 8 === 0 ? 'true' : undefined}
2222
+ key={ratio}
2223
+ style={{ left: `${ratio * 100}%` }}
2224
+ />
2225
+ ))}
2226
+ </div>
2227
+ </div>
2228
+ <div aria-hidden="true" className="min-w-0" />
2229
+ </div>
2230
+ <div
2231
+ aria-label="Playback position"
2232
+ aria-valuemax={Number(formatTimelineSeconds(durationSeconds))}
2233
+ aria-valuemin={0}
2234
+ aria-valuenow={Number(formatTimelineSeconds(currentTimeSeconds))}
2235
+ className="group/timeline-expanded-scrubber relative min-h-0 flex-1 touch-none overflow-visible outline-none select-none"
2236
+ data-dragging={isScrubbing ? 'true' : undefined}
2237
+ data-slot="timeline-expanded-scrubber"
2238
+ data-timeline-track-end={timelineExpandedTrackEndOffsetPx}
2239
+ data-timeline-track-start={timelineExpandedTrackStartOffsetPx}
2240
+ onKeyDown={handleExpandedKeyDown}
2241
+ onPointerCancel={onPointerUp}
2242
+ onPointerDown={handleExpandedPointerDown}
2243
+ onPointerMove={onPointerMove}
2244
+ onPointerUp={onPointerUp}
2245
+ ref={stripRef}
2246
+ role="slider"
2247
+ tabIndex={0}
2248
+ >
2249
+ <span
2250
+ aria-hidden="true"
2251
+ className="absolute top-0 bottom-0 z-20 w-px -translate-x-1/2 bg-[color:var(--foreground)]"
2252
+ data-slot="timeline-expanded-playhead"
2253
+ style={trackPlayheadStyle}
2254
+ />
2255
+ <span
2256
+ aria-hidden="true"
2257
+ className={cn(
2258
+ 'absolute top-0 bottom-0 z-[25] -translate-x-1/2 cursor-ew-resize',
2259
+ isScrubbing && 'cursor-grabbing',
2260
+ )}
2261
+ data-slot="timeline-expanded-playhead-hit-area"
2262
+ style={{ ...trackPlayheadStyle, width: timelinePlayheadHitAreaWidthPx }}
2263
+ />
2264
+ <span
2265
+ aria-hidden="true"
2266
+ className={cn(
2267
+ 'absolute top-[-1px] z-30 size-[9px] -translate-x-1/2 -translate-y-1/2 cursor-ew-resize rounded-[2px] bg-[color:var(--foreground)] shadow-[0_2px_2px_color-mix(in_oklab,var(--background)_20%,transparent)] transition-transform duration-[120ms] ease-out',
2268
+ isScrubbing && 'scale-[1.25] cursor-grabbing',
2269
+ )}
2270
+ data-slot="timeline-expanded-playhead-handle"
2271
+ style={trackPlayheadStyle}
2272
+ />
2273
+ <motion.div
2274
+ animate={{ opacity: keyframeGroups.length === 0 ? 1 : 0 }}
2275
+ aria-hidden={keyframeGroups.length === 0 ? undefined : 'true'}
2276
+ className="pointer-events-none absolute inset-0 flex min-h-0 items-center justify-center px-4 text-center text-[11px] leading-4 text-[color:color-mix(in_oklab,var(--foreground)_30%,transparent)]"
2277
+ initial={false}
2278
+ transition={timelineKeyframePresenceTransition}
2279
+ >
2280
+ Add your first keyframe from the properties panel.
2281
+ </motion.div>
2282
+ <AnimatePresence initial={false}>
2283
+ {keyframeGroups.map((group) => (
2284
+ <TimelineKeyframeRow
2285
+ durationSeconds={durationSeconds}
2286
+ group={group}
2287
+ isScrubbing={isScrubbing}
2288
+ key={group.controlId}
2289
+ onChangeKeyframeEasing={onChangeKeyframeEasing}
2290
+ onDeleteControlKeyframes={onDeleteControlKeyframes}
2291
+ onKeyframeDragStart={onKeyframeDragStart}
2292
+ onMoveKeyframe={onMoveKeyframe}
2293
+ onSelectedKeyframeChange={onSelectedKeyframeChange}
2294
+ selectedKeyframeId={selectedKeyframeId}
2295
+ />
2296
+ ))}
2297
+ </AnimatePresence>
2298
+ </div>
2299
+ </div>
2300
+ );
2301
+ }
2302
+
2303
+ function useTimelineClock({
2304
+ durationSeconds,
2305
+ isHoverPaused,
2306
+ isLooping,
2307
+ isPlaying,
2308
+ isScrubbing,
2309
+ setCurrentTimeSeconds,
2310
+ setIsPlaying,
2311
+ }: {
2312
+ durationSeconds: number;
2313
+ isHoverPaused: boolean;
2314
+ isLooping: boolean;
2315
+ isPlaying: boolean;
2316
+ isScrubbing: boolean;
2317
+ setCurrentTimeSeconds: React.Dispatch<React.SetStateAction<number>>;
2318
+ setIsPlaying: React.Dispatch<React.SetStateAction<boolean>>;
2319
+ }): void {
2320
+ useEffect(() => {
2321
+ if (
2322
+ !isPlaying ||
2323
+ isHoverPaused ||
2324
+ isScrubbing ||
2325
+ typeof window === 'undefined' ||
2326
+ typeof window.requestAnimationFrame !== 'function'
2327
+ ) {
2328
+ return;
2329
+ }
2330
+
2331
+ let frame = 0;
2332
+ let previousTimestamp = window.performance.now();
2333
+ const tick = (timestamp: number) => {
2334
+ const elapsedSeconds = (timestamp - previousTimestamp) / 1000;
2335
+
2336
+ previousTimestamp = timestamp;
2337
+ setCurrentTimeSeconds((currentValue) => {
2338
+ const nextValue = currentValue + elapsedSeconds;
2339
+
2340
+ if (nextValue <= durationSeconds) {
2341
+ return nextValue;
2342
+ }
2343
+
2344
+ if (isLooping) {
2345
+ return nextValue % durationSeconds;
2346
+ }
2347
+
2348
+ setIsPlaying(false);
2349
+ return durationSeconds;
2350
+ });
2351
+ frame = window.requestAnimationFrame(tick);
2352
+ };
2353
+
2354
+ frame = window.requestAnimationFrame(tick);
2355
+
2356
+ return () => window.cancelAnimationFrame(frame);
2357
+ }, [
2358
+ durationSeconds,
2359
+ isHoverPaused,
2360
+ isLooping,
2361
+ isPlaying,
2362
+ isScrubbing,
2363
+ setCurrentTimeSeconds,
2364
+ setIsPlaying,
2365
+ ]);
2366
+ }
2367
+
2368
+ function useTimelineScrubber({
2369
+ currentTimeSeconds,
2370
+ disabled = false,
2371
+ durationSeconds,
2372
+ setCurrentTimeSeconds,
2373
+ setIsPlaying,
2374
+ }: {
2375
+ currentTimeSeconds: number;
2376
+ disabled?: boolean;
2377
+ durationSeconds: number;
2378
+ setCurrentTimeSeconds: React.Dispatch<React.SetStateAction<number>>;
2379
+ setIsPlaying: React.Dispatch<React.SetStateAction<boolean>>;
2380
+ }) {
2381
+ const [isScrubbing, setIsScrubbing] = useState(false);
2382
+ const stripRef = useRef<HTMLDivElement | null>(null);
2383
+
2384
+ useEffect(() => {
2385
+ if (disabled && isScrubbing) {
2386
+ setIsScrubbing(false);
2387
+ }
2388
+ }, [disabled, isScrubbing]);
2389
+
2390
+ const getScrubGeometry = (): { rect: DOMRect; trackStart: number; trackWidth: number } | null => {
2391
+ const rect = stripRef.current?.getBoundingClientRect();
2392
+
2393
+ if (!(rect && rect.width > 0)) {
2394
+ return null;
2395
+ }
2396
+
2397
+ const rawTrackStart = Number.parseFloat(stripRef.current?.dataset.timelineTrackStart ?? '0');
2398
+ const trackStart = Number.isFinite(rawTrackStart) ? rawTrackStart : 0;
2399
+ const rawTrackEndInset = Number.parseFloat(stripRef.current?.dataset.timelineTrackEnd ?? '0');
2400
+ const trackEndInset = Number.isFinite(rawTrackEndInset) ? rawTrackEndInset : 0;
2401
+ const trackWidth = Math.max(1, rect.width - trackStart - trackEndInset);
2402
+
2403
+ return { rect, trackStart, trackWidth };
2404
+ };
2405
+ const canStartScrubbingFromPointerEvent = (
2406
+ event: React.PointerEvent<HTMLDivElement>,
2407
+ ): boolean => {
2408
+ const geometry = getScrubGeometry();
2409
+
2410
+ if (!geometry) {
2411
+ return false;
2412
+ }
2413
+
2414
+ const isExpandedTimeline = geometry.trackStart > 0;
2415
+ const startedFromExpandedPlayhead =
2416
+ event.target instanceof Element &&
2417
+ event.target.closest(
2418
+ [
2419
+ '[data-slot="timeline-expanded-playhead"]',
2420
+ '[data-slot="timeline-expanded-playhead-handle"]',
2421
+ '[data-slot="timeline-expanded-playhead-hit-area"]',
2422
+ ].join(','),
2423
+ );
2424
+
2425
+ if (isExpandedTimeline) {
2426
+ return Boolean(startedFromExpandedPlayhead);
2427
+ }
2428
+
2429
+ return event.clientX >= geometry.rect.left + geometry.trackStart;
2430
+ };
2431
+ const setCurrentTimeFromClientX = (clientX: number): void => {
2432
+ const geometry = getScrubGeometry();
2433
+
2434
+ if (!geometry) {
2435
+ return;
2436
+ }
2437
+
2438
+ const { rect, trackStart, trackWidth } = geometry;
2439
+ const ratio = Math.max(0, Math.min(1, (clientX - rect.left - trackStart) / trackWidth));
2440
+
2441
+ setCurrentTimeSeconds(clampTimelineTime(durationSeconds * ratio, durationSeconds));
2442
+ };
2443
+ const handleScrubKeyDown = (event: React.KeyboardEvent<HTMLDivElement>): void => {
2444
+ if (disabled) {
2445
+ return;
2446
+ }
2447
+
2448
+ const nextTime = getKeyboardScrubTime({
2449
+ currentTimeSeconds,
2450
+ durationSeconds,
2451
+ key: event.key,
2452
+ });
2453
+
2454
+ if (nextTime === null) {
2455
+ return;
2456
+ }
2457
+
2458
+ event.preventDefault();
2459
+ setCurrentTimeSeconds(clampTimelineTime(nextTime, durationSeconds));
2460
+ };
2461
+ const handleScrubPointerDown = (event: React.PointerEvent<HTMLDivElement>): void => {
2462
+ if (disabled || !canStartScrubbingFromPointerEvent(event)) {
2463
+ return;
2464
+ }
2465
+
2466
+ event.preventDefault();
2467
+ event.currentTarget.setPointerCapture(event.pointerId);
2468
+ setIsPlaying(false);
2469
+ setIsScrubbing(true);
2470
+ setCurrentTimeFromClientX(event.clientX);
2471
+ };
2472
+ const handleScrubPointerMove = (event: React.PointerEvent<HTMLDivElement>): void => {
2473
+ if (!isScrubbing) {
2474
+ return;
2475
+ }
2476
+
2477
+ setCurrentTimeFromClientX(event.clientX);
2478
+ };
2479
+ const handleScrubPointerUp = (event: React.PointerEvent<HTMLDivElement>): void => {
2480
+ if (!isScrubbing) {
2481
+ return;
2482
+ }
2483
+
2484
+ if (event.currentTarget.hasPointerCapture(event.pointerId)) {
2485
+ event.currentTarget.releasePointerCapture(event.pointerId);
2486
+ }
2487
+
2488
+ setIsScrubbing(false);
2489
+ };
2490
+
2491
+ return {
2492
+ handleScrubKeyDown,
2493
+ handleScrubPointerDown,
2494
+ handleScrubPointerMove,
2495
+ handleScrubPointerUp,
2496
+ isScrubbing,
2497
+ stripRef,
2498
+ };
2499
+ }
2500
+
2501
+ function TimelinePanelHeader({
2502
+ canExpand,
2503
+ currentTimeSeconds,
2504
+ durationSeconds,
2505
+ isExpanded,
2506
+ isLooping,
2507
+ isPlaying,
2508
+ isScrubbing,
2509
+ playbackReady,
2510
+ onDurationCommit,
2511
+ onScrubKeyDown,
2512
+ onScrubPointerDown,
2513
+ onScrubPointerMove,
2514
+ onScrubPointerUp,
2515
+ onToggleExpanded,
2516
+ onToggleLoop,
2517
+ onTogglePlayback,
2518
+ stripRef,
2519
+ }: {
2520
+ canExpand: boolean;
2521
+ currentTimeSeconds: number;
2522
+ durationSeconds: number;
2523
+ isExpanded: boolean;
2524
+ isLooping: boolean;
2525
+ isPlaying: boolean;
2526
+ isScrubbing: boolean;
2527
+ playbackReady: boolean;
2528
+ onDurationCommit: (value: string) => void;
2529
+ onScrubKeyDown: (event: React.KeyboardEvent<HTMLDivElement>) => void;
2530
+ onScrubPointerDown: (event: React.PointerEvent<HTMLDivElement>) => void;
2531
+ onScrubPointerMove: (event: React.PointerEvent<HTMLDivElement>) => void;
2532
+ onScrubPointerUp: (event: React.PointerEvent<HTMLDivElement>) => void;
2533
+ onToggleExpanded: () => void;
2534
+ onToggleLoop: () => void;
2535
+ onTogglePlayback: () => void;
2536
+ stripRef: React.RefObject<HTMLDivElement | null>;
2537
+ }): React.JSX.Element {
2538
+ return (
2539
+ <div
2540
+ className={cn(
2541
+ 'relative flex min-w-0 shrink-0 items-center gap-1',
2542
+ isExpanded
2543
+ ? 'h-9 border-b border-[color:color-mix(in_oklab,var(--border)_8%,transparent)] p-1'
2544
+ : 'h-full',
2545
+ )}
2546
+ data-slot="timeline-panel-header"
2547
+ >
2548
+ <div
2549
+ className="relative z-10 inline-flex shrink-0 items-center gap-1"
2550
+ data-slot="timeline-transport-controls"
2551
+ >
2552
+ <TimelineIconButton
2553
+ disabled={!playbackReady}
2554
+ label={isPlaying ? 'Pause playback' : 'Play playback'}
2555
+ onClick={onTogglePlayback}
2556
+ >
2557
+ {isPlaying ? <Pause /> : <Play />}
2558
+ </TimelineIconButton>
2559
+ <TimelineIconButton
2560
+ label={isLooping ? 'Disable loop' : 'Enable loop'}
2561
+ onClick={onToggleLoop}
2562
+ >
2563
+ {isLooping ? <Repeat data-icon="loop-enabled" /> : <Repeat1 data-icon="loop-disabled" />}
2564
+ </TimelineIconButton>
2565
+ </div>
2566
+ <TimelinePanelDivider />
2567
+ <div className="ml-2 inline-flex shrink-0 items-center gap-1 text-xs leading-5 text-[color:color-mix(in_oklab,var(--foreground)_90%,transparent)]">
2568
+ <span>{isExpanded ? 'Duration:' : 'Dur:'}</span>
2569
+ <EditableSliderValueLabel
2570
+ ariaLabel="timeline duration"
2571
+ layout="content"
2572
+ maxValueLabel={`${maxTimelineDurationSeconds}s`}
2573
+ onCommit={onDurationCommit}
2574
+ textAlign="left"
2575
+ valueLabel={formatDurationValueLabel(durationSeconds)}
2576
+ />
2577
+ </div>
2578
+ <span
2579
+ className={cn(
2580
+ 'flex-1 cursor-default overflow-hidden text-right font-sans text-[11px] leading-5 whitespace-nowrap text-[color:var(--muted-foreground)] tabular-nums [contain:paint] select-none',
2581
+ isExpanded ? 'min-w-[5.5rem]' : 'min-w-0',
2582
+ )}
2583
+ >
2584
+ {formatTimelineHeaderTimeLabel({ currentTimeSeconds, durationSeconds })}
2585
+ </span>
2586
+ {canExpand ? (
2587
+ <span className="relative z-10 flex shrink-0" data-slot="timeline-panel-expand-toggle">
2588
+ <TimelineIconButton
2589
+ label={isExpanded ? 'Collapse timeline panel' : 'Expand timeline panel'}
2590
+ onClick={onToggleExpanded}
2591
+ tooltipSide="top"
2592
+ >
2593
+ <PrimitiveArrowIcon direction={isExpanded ? 'up' : 'down'} />
2594
+ </TimelineIconButton>
2595
+ </span>
2596
+ ) : null}
2597
+ {isExpanded ? null : (
2598
+ <TimelinePlaybackStrip
2599
+ currentTimeSeconds={currentTimeSeconds}
2600
+ durationSeconds={durationSeconds}
2601
+ isScrubbing={isScrubbing}
2602
+ onKeyDown={onScrubKeyDown}
2603
+ onPointerDown={onScrubPointerDown}
2604
+ onPointerMove={onScrubPointerMove}
2605
+ onPointerUp={onScrubPointerUp}
2606
+ stripRef={stripRef}
2607
+ />
2608
+ )}
2609
+ </div>
2610
+ );
2611
+ }
2612
+
2613
+ export function TimelinePanel({
2614
+ className,
2615
+ defaultExpanded = false,
2616
+ framed = true,
2617
+ onPanelStateChange,
2618
+ panelPlacement,
2619
+ panelState,
2620
+ }: TimelinePanelProps): React.JSX.Element | null {
2621
+ const { dispatch, state } = useToolcraft();
2622
+
2623
+ if (!state.schema.panels.timeline) {
2624
+ return null;
2625
+ }
2626
+
2627
+ const keyframesEnabled = state.schema.assembly.capabilities.includes('timeline.keyframes');
2628
+ const playbackReady = isTimelineReadyForPlayback(state.schema, state.mediaAssets);
2629
+
2630
+ const {
2631
+ currentTimeSeconds,
2632
+ durationSeconds,
2633
+ expanded,
2634
+ isLooping,
2635
+ isPlaying,
2636
+ keyframeGroups,
2637
+ selectedKeyframeId,
2638
+ } = state.timeline;
2639
+ const [defaultExpandedPending, setDefaultExpandedPending] = useState(defaultExpanded);
2640
+ const [isHoverPaused, setIsHoverPaused] = useState(false);
2641
+ const displayedIsPlaying = playbackReady && isPlaying;
2642
+ const isExpanded = keyframesEnabled && (expanded || defaultExpandedPending);
2643
+ const expandedPanelSize = getTimelinePanelExpandedSize(keyframeGroups);
2644
+ const previousIsExpandedRef = useRef(isExpanded);
2645
+ const timelineRef = useRef(state.timeline);
2646
+ const isExpandCollapseTransition = previousIsExpandedRef.current !== isExpanded;
2647
+ const timelinePanelTransition = isExpandCollapseTransition
2648
+ ? timelinePanelExpandCollapseTransition
2649
+ : timelinePanelResizeTransition;
2650
+
2651
+ useEffect(() => {
2652
+ timelineRef.current = state.timeline;
2653
+ }, [state.timeline]);
2654
+ useEffect(() => {
2655
+ previousIsExpandedRef.current = isExpanded;
2656
+ }, [isExpanded]);
2657
+ useEffect(() => {
2658
+ if (playbackReady) {
2659
+ return;
2660
+ }
2661
+
2662
+ if (currentTimeSeconds !== 0) {
2663
+ dispatch({ currentTimeSeconds: 0, type: 'timeline.setCurrentTime' });
2664
+ }
2665
+
2666
+ if (isPlaying) {
2667
+ dispatch({ isPlaying: false, type: 'timeline.setPlaying' });
2668
+ }
2669
+ }, [currentTimeSeconds, dispatch, isPlaying, playbackReady]);
2670
+ useEffect(() => {
2671
+ if (!defaultExpanded || !keyframesEnabled) {
2672
+ return;
2673
+ }
2674
+
2675
+ dispatch({ expanded: true, type: 'timeline.setExpanded' });
2676
+ setDefaultExpandedPending(false);
2677
+ }, [defaultExpanded, dispatch, keyframesEnabled]);
2678
+
2679
+ const setCurrentTimeSeconds = useCallback(
2680
+ (nextValue: React.SetStateAction<number>): void => {
2681
+ const resolvedValue =
2682
+ typeof nextValue === 'function'
2683
+ ? nextValue(timelineRef.current.currentTimeSeconds)
2684
+ : nextValue;
2685
+
2686
+ dispatch({ currentTimeSeconds: resolvedValue, type: 'timeline.setCurrentTime' });
2687
+ },
2688
+ [dispatch],
2689
+ );
2690
+ const setIsPlaying = useCallback(
2691
+ (nextValue: React.SetStateAction<boolean>): void => {
2692
+ const resolvedValue =
2693
+ typeof nextValue === 'function' ? nextValue(timelineRef.current.isPlaying) : nextValue;
2694
+
2695
+ dispatch({ isPlaying: resolvedValue, type: 'timeline.setPlaying' });
2696
+ },
2697
+ [dispatch],
2698
+ );
2699
+ const setSelectedKeyframeId = useCallback(
2700
+ (keyframeId: string | null): void => {
2701
+ dispatch({ keyframeId, type: 'timeline.selectKeyframe' });
2702
+ },
2703
+ [dispatch],
2704
+ );
2705
+ const scrubber = useTimelineScrubber({
2706
+ currentTimeSeconds,
2707
+ disabled: !playbackReady,
2708
+ durationSeconds,
2709
+ setCurrentTimeSeconds,
2710
+ setIsPlaying,
2711
+ });
2712
+ const deleteKeyframe = useCallback(
2713
+ (keyframeId: string): void => {
2714
+ dispatch({ keyframeId, type: 'timeline.deleteKeyframe' });
2715
+ },
2716
+ [dispatch],
2717
+ );
2718
+ const moveKeyframe = useCallback(
2719
+ (keyframeId: string, nextTimeSeconds: number): string | null => {
2720
+ const targetKeyframe = findTimelineKeyframe(keyframeGroups, keyframeId);
2721
+
2722
+ if (!targetKeyframe) {
2723
+ return null;
2724
+ }
2725
+
2726
+ const nextSelectedKeyframeId = getKeyframeId(targetKeyframe.controlId, nextTimeSeconds);
2727
+
2728
+ dispatch({
2729
+ keyframeId,
2730
+ timeSeconds: nextTimeSeconds,
2731
+ type: 'timeline.moveKeyframe',
2732
+ });
2733
+
2734
+ return nextSelectedKeyframeId;
2735
+ },
2736
+ [dispatch, keyframeGroups],
2737
+ );
2738
+
2739
+ useTimelineClock({
2740
+ durationSeconds,
2741
+ isHoverPaused,
2742
+ isLooping,
2743
+ isPlaying: displayedIsPlaying,
2744
+ isScrubbing: scrubber.isScrubbing,
2745
+ setCurrentTimeSeconds,
2746
+ setIsPlaying,
2747
+ });
2748
+
2749
+ useEffect(() => {
2750
+ if (!selectedKeyframeId || typeof document === 'undefined') {
2751
+ return;
2752
+ }
2753
+
2754
+ const handleDocumentKeyDown = (event: KeyboardEvent): void => {
2755
+ if (
2756
+ event.defaultPrevented ||
2757
+ (event.key !== 'Delete' && event.key !== 'Backspace' && event.key !== 'Escape') ||
2758
+ isEditableTimelineEventTarget(event.target)
2759
+ ) {
2760
+ return;
2761
+ }
2762
+
2763
+ event.preventDefault();
2764
+
2765
+ if (event.key === 'Escape') {
2766
+ setSelectedKeyframeId(null);
2767
+ return;
2768
+ }
2769
+
2770
+ deleteKeyframe(selectedKeyframeId);
2771
+ };
2772
+
2773
+ document.addEventListener('keydown', handleDocumentKeyDown);
2774
+
2775
+ return () => {
2776
+ document.removeEventListener('keydown', handleDocumentKeyDown);
2777
+ };
2778
+ }, [deleteKeyframe, selectedKeyframeId]);
2779
+
2780
+ useEffect(() => {
2781
+ if (!selectedKeyframeId || typeof document === 'undefined') {
2782
+ return;
2783
+ }
2784
+
2785
+ const handleDocumentPointerDown = (event: PointerEvent): void => {
2786
+ const targetElement = getTimelineEventTargetElement(event.target);
2787
+ const clickedKeyframe = targetElement?.closest('[data-slot="timeline-keyframe"]');
2788
+ const clickedEasingPopover = targetElement?.closest(
2789
+ '[data-timeline-keyframe-easing-popover]',
2790
+ );
2791
+ const clickedTimelinePanel = targetElement?.closest('[data-slot="timeline-panel"]');
2792
+ const clickedTimelineInteractiveElement =
2793
+ clickedTimelinePanel && isTimelineInteractiveElement(event.target);
2794
+
2795
+ if (!clickedKeyframe && !clickedEasingPopover && !clickedTimelineInteractiveElement) {
2796
+ setSelectedKeyframeId(null);
2797
+ }
2798
+ };
2799
+
2800
+ document.addEventListener('pointerdown', handleDocumentPointerDown, { capture: true });
2801
+
2802
+ return () => {
2803
+ document.removeEventListener('pointerdown', handleDocumentPointerDown, { capture: true });
2804
+ };
2805
+ }, [selectedKeyframeId]);
2806
+
2807
+ const commitDurationValue = (nextValue: string): void => {
2808
+ const nextDuration = clampTimelineDuration(Number.parseFloat(nextValue));
2809
+
2810
+ dispatch({ durationSeconds: nextDuration, type: 'timeline.setDuration' });
2811
+ };
2812
+ const deleteControlKeyframes = (controlId: string): void => {
2813
+ dispatch({ controlId, type: 'timeline.deleteControlKeyframes' });
2814
+ };
2815
+ const changeKeyframeEasing = (
2816
+ keyframeId: string,
2817
+ nextEasing: ToolcraftTimelineKeyframeEasing,
2818
+ ): void => {
2819
+ dispatch({ easing: nextEasing, keyframeId, type: 'timeline.changeKeyframeEasing' });
2820
+ };
2821
+ const resolvedPanelPlacement = panelPlacement ?? (framed ? 'frame' : 'surface');
2822
+ const shouldConstrainToContainer = resolvedPanelPlacement === 'surface';
2823
+ const { panelRef: timelineSurfaceRef, responsiveLayout } = useTimelinePanelResponsiveLayout(
2824
+ isExpanded && !shouldConstrainToContainer,
2825
+ );
2826
+ const unconstrainedTimelinePanelWidth = isExpanded
2827
+ ? expandedPanelSize.width
2828
+ : timelinePanelCollapsedWidthPx;
2829
+ const timelinePanelWidth =
2830
+ isExpanded && responsiveLayout !== null
2831
+ ? Math.min(unconstrainedTimelinePanelWidth, responsiveLayout.width)
2832
+ : unconstrainedTimelinePanelWidth;
2833
+ const timelinePanelOffsetX =
2834
+ isExpanded && responsiveLayout !== null ? responsiveLayout.offsetX : 0;
2835
+ const timelinePanelLayoutStyle: CSSProperties = {
2836
+ transform: timelinePanelOffsetX !== 0 ? `translateX(${timelinePanelOffsetX}px)` : undefined,
2837
+ };
2838
+ const timelinePanelAnimation = {
2839
+ height: isExpanded ? expandedPanelSize.height : timelinePanelCollapsedSize.height,
2840
+ ...(shouldConstrainToContainer
2841
+ ? { maxWidth: timelinePanelWidth }
2842
+ : { width: timelinePanelWidth }),
2843
+ };
2844
+
2845
+ const timelineSurface = (
2846
+ <motion.div
2847
+ animate={timelinePanelAnimation}
2848
+ className={cn(
2849
+ 'pointer-events-auto origin-top',
2850
+ shouldConstrainToContainer ? 'w-full' : 'max-w-full',
2851
+ !framed && className,
2852
+ )}
2853
+ data-expanded-height={isExpanded ? expandedPanelSize.height : undefined}
2854
+ data-responsive-width={
2855
+ timelinePanelWidth < unconstrainedTimelinePanelWidth ? timelinePanelWidth : undefined
2856
+ }
2857
+ data-responsive-offset-x={timelinePanelOffsetX !== 0 ? timelinePanelOffsetX : undefined}
2858
+ data-hover-paused={isHoverPaused ? 'true' : 'false'}
2859
+ data-playback-ready={playbackReady ? 'true' : 'false'}
2860
+ data-scrubbing={scrubber.isScrubbing ? 'true' : 'false'}
2861
+ data-slot="timeline-panel"
2862
+ initial={false}
2863
+ ref={timelineSurfaceRef}
2864
+ style={timelinePanelLayoutStyle}
2865
+ transition={timelinePanelTransition}
2866
+ >
2867
+ <PanelSurface
2868
+ className={cn(
2869
+ 'group/timeline-panel-surface relative flex h-full w-full flex-col rounded-t-lg rounded-b-lg',
2870
+ isExpanded ? 'overflow-hidden' : 'overflow-visible p-1',
2871
+ !isExpanded && !keyframesEnabled && 'pr-3',
2872
+ )}
2873
+ data-panel-id="timeline"
2874
+ onPointerEnter={() => setIsHoverPaused(true)}
2875
+ onPointerLeave={(event) => {
2876
+ const nextTarget = event.relatedTarget;
2877
+
2878
+ if (nextTarget instanceof Node && event.currentTarget.contains(nextTarget)) {
2879
+ return;
2880
+ }
2881
+
2882
+ setIsHoverPaused(false);
2883
+ }}
2884
+ >
2885
+ {isExpanded ? null : (
2886
+ <TimelinePanelMask
2887
+ currentTimeSeconds={currentTimeSeconds}
2888
+ durationSeconds={durationSeconds}
2889
+ isHandleVisible={isHoverPaused || scrubber.isScrubbing}
2890
+ />
2891
+ )}
2892
+ <TimelinePanelHeader
2893
+ canExpand={keyframesEnabled}
2894
+ currentTimeSeconds={currentTimeSeconds}
2895
+ durationSeconds={durationSeconds}
2896
+ isExpanded={isExpanded}
2897
+ isLooping={isLooping}
2898
+ isPlaying={displayedIsPlaying}
2899
+ isScrubbing={scrubber.isScrubbing}
2900
+ playbackReady={playbackReady}
2901
+ onDurationCommit={commitDurationValue}
2902
+ onScrubKeyDown={scrubber.handleScrubKeyDown}
2903
+ onScrubPointerDown={scrubber.handleScrubPointerDown}
2904
+ onScrubPointerMove={scrubber.handleScrubPointerMove}
2905
+ onScrubPointerUp={scrubber.handleScrubPointerUp}
2906
+ onToggleExpanded={() => {
2907
+ setDefaultExpandedPending(false);
2908
+ dispatch({ expanded: !isExpanded, type: 'timeline.setExpanded' });
2909
+ }}
2910
+ onToggleLoop={() => dispatch({ type: 'timeline.toggleLoop' })}
2911
+ onTogglePlayback={() => {
2912
+ setIsHoverPaused(false);
2913
+ dispatch({ type: 'timeline.togglePlayback' });
2914
+ }}
2915
+ stripRef={scrubber.stripRef}
2916
+ />
2917
+ {isExpanded && keyframesEnabled ? (
2918
+ <TimelineExpandedContent
2919
+ currentTimeSeconds={currentTimeSeconds}
2920
+ durationSeconds={durationSeconds}
2921
+ isScrubbing={scrubber.isScrubbing}
2922
+ keyframeGroups={keyframeGroups}
2923
+ onChangeKeyframeEasing={changeKeyframeEasing}
2924
+ onDeleteControlKeyframes={deleteControlKeyframes}
2925
+ onDeleteKeyframe={deleteKeyframe}
2926
+ onKeyframeDragStart={() => setIsPlaying(false)}
2927
+ onKeyDown={scrubber.handleScrubKeyDown}
2928
+ onMoveKeyframe={moveKeyframe}
2929
+ onPointerDown={scrubber.handleScrubPointerDown}
2930
+ onPointerMove={scrubber.handleScrubPointerMove}
2931
+ onPointerUp={scrubber.handleScrubPointerUp}
2932
+ onSelectedKeyframeChange={setSelectedKeyframeId}
2933
+ selectedKeyframeId={selectedKeyframeId}
2934
+ stripRef={scrubber.stripRef}
2935
+ />
2936
+ ) : null}
2937
+ </PanelSurface>
2938
+ </motion.div>
2939
+ );
2940
+
2941
+ return (
2942
+ <PanelContainer
2943
+ onPanelStateChange={onPanelStateChange}
2944
+ panelState={panelState}
2945
+ panelType="timeline"
2946
+ placement={resolvedPanelPlacement}
2947
+ >
2948
+ {timelineSurface}
2949
+ </PanelContainer>
2950
+ );
2951
+ }
2952
+
2953
+ export { TimelinePanel as KeyframesPanel };