@pixel-point/toolcraft 0.0.13 → 0.0.15

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