@hyperframes/studio 0.7.76 → 0.7.78

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 (138) hide show
  1. package/dist/assets/{hyperframes-player-DsRdxz7A.js → hyperframes-player-uiAAPKvw.js} +1 -1
  2. package/dist/assets/index-BSZrK0bx.js +428 -0
  3. package/dist/assets/{index-Du1RoLiB.js → index-CDSTMtUs.js} +1 -1
  4. package/dist/assets/index-DpkO2Hvw.css +1 -0
  5. package/dist/assets/{index-BLICKger.js → index-r1tKKlU7.js} +1 -1
  6. package/dist/index.d.ts +64 -17
  7. package/dist/index.html +2 -2
  8. package/dist/index.js +25030 -23214
  9. package/dist/index.js.map +1 -1
  10. package/package.json +7 -7
  11. package/src/App.tsx +16 -17
  12. package/src/components/EditorShell.tsx +3 -0
  13. package/src/components/TimelineToolbar.test.tsx +48 -3
  14. package/src/components/TimelineToolbar.tsx +89 -36
  15. package/src/components/editor/AnimationCard.test.tsx +18 -75
  16. package/src/components/editor/AnimationCard.tsx +26 -2
  17. package/src/components/editor/GsapAddAnimationControl.tsx +68 -0
  18. package/src/components/editor/GsapAnimationSection.tsx +18 -157
  19. package/src/components/editor/KeyframeEaseList.tsx +5 -1
  20. package/src/components/editor/KeyframeNavigation.tsx +46 -14
  21. package/src/components/editor/KeyframeNavigationDiamond.test.tsx +59 -0
  22. package/src/components/editor/MotionPathOverlay.tsx +32 -10
  23. package/src/components/editor/PropertyPanel.tsx +15 -5
  24. package/src/components/editor/PropertyPanelFlat.tsx +48 -23
  25. package/src/components/editor/gsapAnimationCallbacks.test.ts +122 -0
  26. package/src/components/editor/gsapAnimationCallbacks.ts +108 -4
  27. package/src/components/editor/keyframeRetime.test.ts +25 -22
  28. package/src/components/editor/keyframeRetime.ts +1 -2
  29. package/src/components/editor/propertyPanelFlatLayoutSection.tsx +2 -2
  30. package/src/components/editor/propertyPanelFlatMotionSection.tsx +25 -139
  31. package/src/components/editor/propertyPanelFlatTextSection.test.tsx +32 -16
  32. package/src/components/editor/propertyPanelFlatTextSection.tsx +21 -4
  33. package/src/components/nle/PreviewOverlays.tsx +3 -1
  34. package/src/components/nle/useTimelineEditCallbacks.test.ts +30 -0
  35. package/src/components/nle/useTimelineEditCallbacks.test.tsx +693 -0
  36. package/src/components/nle/useTimelineEditCallbacks.ts +197 -57
  37. package/src/components/sidebar/CompositionsTab.drag.test.tsx +41 -0
  38. package/src/components/sidebar/CompositionsTab.tsx +66 -32
  39. package/src/contexts/TimelineEditContext.tsx +1 -1
  40. package/src/hooks/deleteSelectedKeyframes.ts +24 -8
  41. package/src/hooks/gsapDragCommit.ts +2 -2
  42. package/src/hooks/gsapDragPositionCommit.ts +84 -5
  43. package/src/hooks/gsapKeyframeCacheHelpers.test.ts +176 -1
  44. package/src/hooks/gsapKeyframeCacheHelpers.ts +93 -52
  45. package/src/hooks/gsapRuntimeBridge.test.ts +100 -0
  46. package/src/hooks/gsapScriptCommitHelpers.ts +3 -2
  47. package/src/hooks/gsapScriptCommitTypes.ts +2 -0
  48. package/src/hooks/gsapShared.test.ts +109 -1
  49. package/src/hooks/gsapShared.ts +142 -17
  50. package/src/hooks/gsapTweenSynth.test.ts +30 -1
  51. package/src/hooks/gsapTweenSynth.ts +61 -14
  52. package/src/hooks/keyframeCacheAstLoad.ts +181 -0
  53. package/src/hooks/timelineEditingHelpers.test.ts +80 -0
  54. package/src/hooks/timelineMoveAdapter.ts +9 -2
  55. package/src/hooks/useDomEditWiring.ts +2 -2
  56. package/src/hooks/useDomSelection.ts +4 -0
  57. package/src/hooks/useEnableKeyframes.test.ts +122 -0
  58. package/src/hooks/useEnableKeyframes.ts +116 -56
  59. package/src/hooks/useGestureCommit.ts +2 -2
  60. package/src/hooks/useGsapAnimationFetchFallback.ts +1 -1
  61. package/src/hooks/useGsapKeyframeOps.test.tsx +92 -13
  62. package/src/hooks/useGsapKeyframeOps.ts +30 -13
  63. package/src/hooks/useGsapScriptCommits.test.tsx +21 -0
  64. package/src/hooks/useGsapScriptCommits.ts +2 -0
  65. package/src/hooks/useGsapSelectionHandlers.test.tsx +69 -3
  66. package/src/hooks/useGsapSelectionHandlers.ts +90 -65
  67. package/src/hooks/useGsapTweenCache.test.ts +16 -0
  68. package/src/hooks/useGsapTweenCache.ts +64 -178
  69. package/src/hooks/usePopulateKeyframeCacheForFile.test.tsx +68 -0
  70. package/src/hooks/useStudioContextValue.test.ts +93 -0
  71. package/src/hooks/useStudioContextValue.ts +17 -3
  72. package/src/hooks/useStudioTestHooks.ts +63 -0
  73. package/src/hooks/useTimelineEditing.test.tsx +187 -22
  74. package/src/hooks/useTimelineEditing.ts +43 -32
  75. package/src/hooks/useTimelineEditingTypes.ts +2 -0
  76. package/src/hooks/useTimelineGroupEditing.ts +106 -82
  77. package/src/player/components/KeyframeDiamondContextMenu.test.tsx +69 -0
  78. package/src/player/components/KeyframeDiamondContextMenu.tsx +19 -8
  79. package/src/player/components/LayerDisclosureRow.tsx +76 -0
  80. package/src/player/components/Player.test.ts +143 -12
  81. package/src/player/components/Player.tsx +84 -43
  82. package/src/player/components/Timeline.test.ts +420 -90
  83. package/src/player/components/Timeline.tsx +80 -78
  84. package/src/player/components/TimelineCanvas.tsx +33 -21
  85. package/src/player/components/TimelineClip.tsx +3 -1
  86. package/src/player/components/TimelineClipDiamonds.test.tsx +540 -16
  87. package/src/player/components/TimelineClipDiamonds.tsx +320 -117
  88. package/src/player/components/TimelineDiamondConnectors.tsx +145 -0
  89. package/src/player/components/TimelineLanes.tsx +153 -79
  90. package/src/player/components/TimelineOverlays.tsx +4 -18
  91. package/src/player/components/TimelinePropertyLanes.test.tsx +450 -0
  92. package/src/player/components/TimelinePropertyLanes.tsx +191 -0
  93. package/src/player/components/TimelineRazorInteraction.tsx +60 -0
  94. package/src/player/components/TimelineRuler.tsx +11 -21
  95. package/src/player/components/TimelineTrackHeader.test.tsx +346 -0
  96. package/src/player/components/TimelineTrackHeader.tsx +363 -0
  97. package/src/player/components/TrackClipCount.tsx +17 -0
  98. package/src/player/components/timelineCallbacks.ts +21 -6
  99. package/src/player/components/timelineClipDragPreview.test.ts +50 -3
  100. package/src/player/components/timelineClipDragPreview.ts +33 -15
  101. package/src/player/components/timelineCollision.ts +4 -13
  102. package/src/player/components/timelineDiamondTypes.ts +120 -0
  103. package/src/player/components/timelineDragDrop.ts +8 -3
  104. package/src/player/components/timelineEditing.test.ts +19 -27
  105. package/src/player/components/timelineEditing.ts +7 -8
  106. package/src/player/components/timelineKeyframeIdentity.test.ts +49 -0
  107. package/src/player/components/timelineKeyframeIdentity.ts +66 -0
  108. package/src/player/components/timelineLayout.test.ts +162 -9
  109. package/src/player/components/timelineLayout.ts +198 -38
  110. package/src/player/components/timelineMarquee.test.ts +76 -21
  111. package/src/player/components/timelineMarquee.ts +17 -14
  112. package/src/player/components/trackHeaderLaneState.ts +121 -0
  113. package/src/player/components/trackHeaderLaneValues.ts +139 -0
  114. package/src/player/components/useAutoExpandKeyframedClips.test.tsx +75 -0
  115. package/src/player/components/useAutoExpandKeyframedClips.ts +50 -0
  116. package/src/player/components/useTimelineClipDrag.ts +4 -1
  117. package/src/player/components/useTimelineGeometry.ts +4 -1
  118. package/src/player/components/useTimelineKeyframeHandlers.test.tsx +108 -0
  119. package/src/player/components/useTimelineKeyframeHandlers.ts +50 -15
  120. package/src/player/components/useTimelinePlayhead.ts +21 -12
  121. package/src/player/components/useTimelineRangeSelection.ts +35 -14
  122. package/src/player/components/useTimelineTrackLayout.test.ts +53 -0
  123. package/src/player/components/useTimelineTrackLayout.ts +162 -0
  124. package/src/player/hooks/useExpandedTimelineElements.test.ts +43 -0
  125. package/src/player/hooks/useExpandedTimelineElements.ts +21 -12
  126. package/src/player/lib/timelineElementHelpers.ts +26 -11
  127. package/src/player/store/keyframeSlice.ts +122 -0
  128. package/src/player/store/playerStore.test.ts +25 -0
  129. package/src/player/store/playerStore.ts +7 -43
  130. package/src/telemetry/events.test.ts +9 -3
  131. package/src/telemetry/events.ts +16 -7
  132. package/src/utils/globalTimeCompiler.test.ts +10 -0
  133. package/src/utils/globalTimeCompiler.ts +3 -3
  134. package/dist/assets/index-B37rWXo4.css +0 -1
  135. package/dist/assets/index-BdOfFsR8.js +0 -428
  136. package/src/player/components/TimelineDragGhost.tsx +0 -59
  137. package/src/utils/keyframeSelection.test.ts +0 -45
  138. package/src/utils/keyframeSelection.ts +0 -29
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hyperframes/studio",
3
- "version": "0.7.76",
3
+ "version": "0.7.78",
4
4
  "description": "",
5
5
  "repository": {
6
6
  "type": "git",
@@ -46,11 +46,11 @@
46
46
  "gsap": "^3.13.0",
47
47
  "marked": "^14.1.4",
48
48
  "mediabunny": "^1.45.3",
49
- "@hyperframes/core": "0.7.76",
50
- "@hyperframes/parsers": "0.7.76",
51
- "@hyperframes/sdk": "0.7.76",
52
- "@hyperframes/studio-server": "0.7.76",
53
- "@hyperframes/player": "0.7.76"
49
+ "@hyperframes/parsers": "0.7.78",
50
+ "@hyperframes/core": "0.7.78",
51
+ "@hyperframes/sdk": "0.7.78",
52
+ "@hyperframes/studio-server": "0.7.78",
53
+ "@hyperframes/player": "0.7.78"
54
54
  },
55
55
  "devDependencies": {
56
56
  "@types/react": "19",
@@ -65,7 +65,7 @@
65
65
  "vite": "^6.4.2",
66
66
  "vitest": "^3.2.4",
67
67
  "zustand": "^5.0.0",
68
- "@hyperframes/producer": "0.7.76"
68
+ "@hyperframes/producer": "0.7.78"
69
69
  },
70
70
  "peerDependencies": {
71
71
  "react": "19",
package/src/App.tsx CHANGED
@@ -1,7 +1,7 @@
1
1
  import { useState, useCallback, useRef, useMemo, useEffect, useLayoutEffect } from "react";
2
2
  import type { LeftSidebarHandle, SidebarTab } from "./components/sidebar/LeftSidebar";
3
3
  import { useRenderQueue } from "./components/renders/useRenderQueue";
4
- import { usePlayerStore, type TimelineElement } from "./player";
4
+ import { usePlayerStore } from "./player";
5
5
  import { StudioOverlays } from "./components/StudioOverlays";
6
6
  import { SaveQueuePausedBanner } from "./components/SaveQueuePausedBanner";
7
7
  import { useCaptionStore } from "./captions/store";
@@ -12,9 +12,12 @@ import { useFileManager } from "./hooks/useFileManager";
12
12
  import { usePreviewPersistence } from "./hooks/usePreviewPersistence";
13
13
  import { usePreviewDocumentVersion } from "./hooks/usePreviewDocumentVersion";
14
14
  import { useTimelineEditing } from "./hooks/useTimelineEditing";
15
- import { persistTimelineMoveEditsAtomically } from "./hooks/timelineMoveAdapter";
15
+ import {
16
+ persistTimelineMoveEditsAtomically,
17
+ type TimelineMoveEditsHandler,
18
+ type TimelineMoveOperation,
19
+ } from "./hooks/timelineMoveAdapter";
16
20
  import type { TimelineZIndexReorderCommit } from "./hooks/useTimelineEditingTypes";
17
- import type { TimelineStackingReorderIntent } from "./player/components/timelineStacking";
18
21
  import type { BlockPreviewInfo } from "./components/sidebar/BlocksTab";
19
22
  import { useDomEditSession } from "./hooks/useDomEditSession";
20
23
  import { useSdkSelectionSync } from "./hooks/useSdkSelectionSync";
@@ -62,7 +65,6 @@ import {
62
65
  } from "./utils/studioUrlState";
63
66
  import { trackStudioSessionStart } from "./telemetry/events";
64
67
  import { hasFiredSessionStart, markSessionStartFired } from "./telemetry/config";
65
- type TimelineMoveOperation = Parameters<typeof persistTimelineMoveEditsAtomically>[2];
66
68
  // fallow-ignore-next-line complexity
67
69
  export function StudioApp() {
68
70
  const { projectId, resolving, waitingForServer } = useServerConnection();
@@ -154,6 +156,10 @@ export function StudioApp() {
154
156
  reloadPreview: () => setRefreshKey((k) => k + 1),
155
157
  pendingTimelineEditPathRef,
156
158
  });
159
+ const invalidateGsapCacheRef = useRef<() => void>(() => {});
160
+ // Stable identity — what the ref indirection is for. An inline arrow re-created
161
+ // the memoized timeline handlers (it is in their deps) on every render.
162
+ const invalidateGsapCache = useCallback(() => invalidateGsapCacheRef.current(), []);
157
163
  const timelineEditing = useTimelineEditing({
158
164
  projectId,
159
165
  activeCompPath,
@@ -171,20 +177,11 @@ export function StudioApp() {
171
177
  sdkSession: editFlowSdkSession,
172
178
  publishSdkSession: sdkHandle.publish,
173
179
  forceReloadSdkSession: sdkHandle.forceReload,
180
+ invalidateGsapCache,
174
181
  handleDomZIndexReorderCommitRef,
175
182
  });
176
- const handleTimelineElementsMove = useCallback(
177
- async (
178
- edits: Array<{
179
- element: TimelineElement;
180
- updates: Pick<TimelineElement, "start" | "track"> & {
181
- stackingReorder?: TimelineStackingReorderIntent | null;
182
- };
183
- }>,
184
- coalesceKey?: string,
185
- operation: TimelineMoveOperation = "timing",
186
- coalesceMs?: number,
187
- ) => {
183
+ const handleTimelineElementsMove: TimelineMoveEditsHandler = useCallback(
184
+ async (edits, coalesceKey, operation: TimelineMoveOperation = "timing", coalesceMs) => {
188
185
  const deps = { handleTimelineGroupMove: timelineEditing.handleTimelineGroupMove };
189
186
  await persistTimelineMoveEditsAtomically(edits, coalesceKey, operation, deps, coalesceMs);
190
187
  },
@@ -228,7 +225,6 @@ export function StudioApp() {
228
225
  const domEditDeleteBridge = (s: DomEditSelection) => handleDomEditElementDeleteRef.current(s);
229
226
  const resetKeyframesRef = useRef<() => boolean>(() => false);
230
227
  const deleteSelectedKeyframesRef = useRef<() => void>(() => {});
231
- const invalidateGsapCacheRef = useRef<() => void>(() => {});
232
228
  const { handleCopy, handlePaste, handleCut } = useClipboard({
233
229
  projectId,
234
230
  activeCompPath,
@@ -402,12 +398,14 @@ export function StudioApp() {
402
398
  designPanelActive,
403
399
  inspectorPanelActive,
404
400
  inspectorButtonActive,
401
+ shouldShowMotionPath,
405
402
  shouldShowSelectedDomBounds,
406
403
  } = useInspectorState(
407
404
  panelLayout.rightPanelTab,
408
405
  panelLayout.rightInspectorPanes,
409
406
  panelLayout.rightCollapsed,
410
407
  isPlaying,
408
+ domEditSession.domEditSelection,
411
409
  gestureState === "recording",
412
410
  );
413
411
  useStudioUrlState({
@@ -558,6 +556,7 @@ export function StudioApp() {
558
556
  handleRazorSplitAll={timelineEditing.handleRazorSplitAll}
559
557
  setCompIdToSrc={setCompIdToSrc}
560
558
  setCompositionLoading={setCompositionLoading}
559
+ shouldShowMotionPath={shouldShowMotionPath}
561
560
  shouldShowSelectedDomBounds={shouldShowSelectedDomBounds}
562
561
  isGestureRecording={gestureState === "recording"}
563
562
  recordingState={gestureState}
@@ -56,6 +56,7 @@ export interface EditorShellProps extends TimelineEditCallbackDeps {
56
56
  ) => Promise<void> | void;
57
57
  setCompIdToSrc: (map: Map<string, string>) => void;
58
58
  setCompositionLoading: (loading: boolean) => void;
59
+ shouldShowMotionPath: boolean;
59
60
  shouldShowSelectedDomBounds: boolean;
60
61
  blockPreview?: BlockPreviewInfo | null;
61
62
  isGestureRecording?: boolean;
@@ -90,6 +91,7 @@ export function EditorShell({
90
91
  handleRazorSplitAll,
91
92
  setCompIdToSrc,
92
93
  setCompositionLoading,
94
+ shouldShowMotionPath,
93
95
  shouldShowSelectedDomBounds,
94
96
  isGestureRecording,
95
97
  recordingState,
@@ -149,6 +151,7 @@ export function EditorShell({
149
151
  onDeleteElement={handleTimelineElementDelete}
150
152
  previewOverlay={
151
153
  <PreviewOverlays
154
+ shouldShowMotionPath={shouldShowMotionPath}
152
155
  shouldShowSelectedDomBounds={shouldShowSelectedDomBounds}
153
156
  blockPreview={blockPreview}
154
157
  isGestureRecording={isGestureRecording}
@@ -2,8 +2,10 @@
2
2
 
3
3
  import React, { act } from "react";
4
4
  import { createRoot } from "react-dom/client";
5
- import { afterEach, describe, expect, it } from "vitest";
5
+ import { afterEach, describe, expect, it, vi } from "vitest";
6
+ import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
6
7
  import { usePlayerStore } from "../player/store/playerStore";
8
+ import { makeSelection } from "../hooks/domSelectionTestHarness";
7
9
  import { TimelineToolbar } from "./TimelineToolbar";
8
10
 
9
11
  (globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
@@ -13,12 +15,14 @@ afterEach(() => {
13
15
  usePlayerStore.setState({ autoKeyframeEnabled: true });
14
16
  });
15
17
 
16
- function renderToolbar() {
18
+ function renderToolbar(
19
+ domEditSession?: React.ComponentProps<typeof TimelineToolbar>["domEditSession"],
20
+ ) {
17
21
  const host = document.createElement("div");
18
22
  document.body.append(host);
19
23
  const root = createRoot(host);
20
24
  act(() => {
21
- root.render(<TimelineToolbar />);
25
+ root.render(<TimelineToolbar domEditSession={domEditSession} />);
22
26
  });
23
27
  return { host, root };
24
28
  }
@@ -54,3 +58,44 @@ describe("TimelineToolbar — auto-keyframe toggle (#1808)", () => {
54
58
  act(() => root.unmount());
55
59
  });
56
60
  });
61
+ describe("TimelineToolbar — motion path endpoints", () => {
62
+ it("does not advertise a destructive keyframe toggle for a required endpoint", () => {
63
+ usePlayerStore.setState({ currentTime: 10 });
64
+ const animation: GsapAnimation = {
65
+ id: "#el-to-0-position",
66
+ targetSelector: "#el",
67
+ method: "to",
68
+ position: 0,
69
+ duration: 10,
70
+ properties: {},
71
+ keyframes: {
72
+ format: "object-array",
73
+ keyframes: [
74
+ { percentage: 0, properties: { x: 0, y: 0 } },
75
+ { percentage: 100, properties: { x: 100, y: 0 } },
76
+ ],
77
+ },
78
+ arcPath: {
79
+ enabled: true,
80
+ autoRotate: false,
81
+ segments: [{ curviness: 1 }],
82
+ },
83
+ };
84
+ const element = document.createElement("div");
85
+ element.id = "el";
86
+ const session = {
87
+ domEditSelection: makeSelection("Element", element),
88
+ selectedGsapAnimations: [animation],
89
+ handleGsapAddAnimation: vi.fn(),
90
+ handleGsapConvertToKeyframes: vi.fn(),
91
+ handleGsapRemoveKeyframe: vi.fn(),
92
+ } satisfies NonNullable<React.ComponentProps<typeof TimelineToolbar>["domEditSession"]>;
93
+
94
+ const { host, root } = renderToolbar(session);
95
+ const button = host.querySelector<HTMLButtonElement>(
96
+ 'button[aria-label="Motion path endpoint"]',
97
+ );
98
+ expect(button?.disabled).toBe(true);
99
+ act(() => root.unmount());
100
+ });
101
+ });
@@ -5,7 +5,7 @@ import {
5
5
  isPlayheadWithinTween,
6
6
  type EnableKeyframesSession,
7
7
  } from "../hooks/useEnableKeyframes";
8
- import { computeElementPercentage } from "../hooks/gsapShared";
8
+ import { computeElementPercentage, KEYFRAME_PCT_MATCH } from "../hooks/gsapShared";
9
9
  import { useKeyframeKeyboard } from "../hooks/useKeyframeKeyboard";
10
10
  import {
11
11
  getNextTimelineZoomPercent,
@@ -36,6 +36,60 @@ interface TimelineToolbarProps {
36
36
  onSplitElement?: (element: TimelineElement, splitTime: number) => void;
37
37
  }
38
38
 
39
+ interface KeyframeToggleState {
40
+ state: "active" | "inactive" | "none";
41
+ isMotionPath: boolean;
42
+ pathEndpoint: boolean;
43
+ willExtend: boolean;
44
+ }
45
+
46
+ const NO_KEYFRAME_TOGGLE: KeyframeToggleState = {
47
+ state: "none",
48
+ isMotionPath: false,
49
+ pathEndpoint: false,
50
+ willExtend: false,
51
+ };
52
+
53
+ function isMotionPathEndpoint(animation: GsapAnimation | undefined, percentage: number): boolean {
54
+ if (!animation?.keyframes) return false;
55
+ const keyframes = animation.keyframes.keyframes;
56
+ return (
57
+ Math.abs((keyframes[0]?.percentage ?? -Infinity) - percentage) <= KEYFRAME_PCT_MATCH ||
58
+ Math.abs((keyframes.at(-1)?.percentage ?? Infinity) - percentage) <= KEYFRAME_PCT_MATCH
59
+ );
60
+ }
61
+
62
+ function resolveKeyframeToggleState(
63
+ session: DomEditSessionSlice | undefined,
64
+ currentTime: number,
65
+ ): KeyframeToggleState {
66
+ if (!session?.domEditSelection) return NO_KEYFRAME_TOGGLE;
67
+ const arcAnimation = session.selectedGsapAnimations.find(
68
+ (animation) => animation.arcPath && animation.keyframes,
69
+ );
70
+ const animation =
71
+ arcAnimation ??
72
+ session.selectedGsapAnimations.find((candidate) => candidate.keyframes && !candidate.arcPath);
73
+ if (!animation?.keyframes) return NO_KEYFRAME_TOGGLE;
74
+
75
+ const isMotionPath = Boolean(arcAnimation);
76
+ if (!isPlayheadWithinTween(animation, currentTime, session.domEditSelection)) {
77
+ return { state: "inactive", isMotionPath, pathEndpoint: false, willExtend: true };
78
+ }
79
+
80
+ const percentage = computeElementPercentage(currentTime, session.domEditSelection, animation);
81
+ const pathEndpoint = isMotionPathEndpoint(arcAnimation, percentage);
82
+ const active = animation.keyframes.keyframes.some(
83
+ (keyframe) => Math.abs(keyframe.percentage - percentage) <= KEYFRAME_PCT_MATCH,
84
+ );
85
+ return {
86
+ state: pathEndpoint ? "none" : active ? "active" : "inactive",
87
+ isMotionPath,
88
+ pathEndpoint,
89
+ willExtend: false,
90
+ };
91
+ }
92
+
39
93
  function useKeyframeToggle(session?: DomEditSessionSlice) {
40
94
  const currentTime = usePlayerStore((s) => s.currentTime);
41
95
  const sessionRef = useRef(session);
@@ -45,31 +99,12 @@ function useKeyframeToggle(session?: DomEditSessionSlice) {
45
99
  sessionRef as React.RefObject<EnableKeyframesSession | undefined>,
46
100
  );
47
101
 
48
- if (!session) return { state: "none" as const, onToggle: undefined };
49
-
50
- const sel = session.domEditSelection;
51
- const anims = session.selectedGsapAnimations;
52
- const kfAnim = anims.find((a) => a.keyframes);
53
-
54
- let state: "active" | "inactive" | "none" = "none";
55
- // Outside the tween, clicking extends the animation to the playhead rather than
56
- // toggling a (clamped) edge keyframe — so the button stays an "add" affordance.
57
- let willExtend = false;
58
- if (kfAnim?.keyframes && sel) {
59
- if (!isPlayheadWithinTween(kfAnim, currentTime)) {
60
- state = "inactive";
61
- willExtend = true;
62
- } else {
63
- // Tween-relative percentage (not the clip range) so the button state matches
64
- // where the keyframe would actually land.
65
- const pct = computeElementPercentage(currentTime, sel, kfAnim);
66
- state = kfAnim.keyframes.keyframes.some((k) => Math.abs(k.percentage - pct) <= 1)
67
- ? "active"
68
- : "inactive";
69
- }
70
- }
102
+ const toggleState = resolveKeyframeToggleState(session, currentTime);
71
103
 
72
- return { state, willExtend, onToggle: sel ? onToggle : undefined };
104
+ return {
105
+ ...toggleState,
106
+ onToggle: session?.domEditSelection && !toggleState.pathEndpoint ? onToggle : undefined,
107
+ };
73
108
  }
74
109
 
75
110
  // fallow-ignore-next-line complexity
@@ -91,6 +126,8 @@ export function TimelineToolbar({ domEditSession, onSplitElement }: TimelineTool
91
126
  const displayedTimelineZoomPercent = getTimelineZoomPercent(zoomMode, manualZoomPercent);
92
127
  const {
93
128
  state: keyframeState,
129
+ isMotionPath: keyframeIsMotionPath,
130
+ pathEndpoint: keyframePathEndpoint,
94
131
  willExtend: keyframeWillExtend,
95
132
  onToggle: onToggleKeyframe,
96
133
  } = useKeyframeToggle(domEditSession);
@@ -180,15 +217,23 @@ export function TimelineToolbar({ domEditSession, onSplitElement }: TimelineTool
180
217
  // toolbar layout never shifts.
181
218
  <Tooltip
182
219
  label={
183
- !onToggleKeyframe
184
- ? "Select an animated element to add keyframes"
185
- : keyframeState === "active"
186
- ? "Remove keyframe at playhead (K)"
187
- : keyframeState === "inactive"
220
+ keyframePathEndpoint
221
+ ? "Motion path endpoints cannot be removed"
222
+ : !onToggleKeyframe
223
+ ? "Select an animated element to add keyframes"
224
+ : keyframeIsMotionPath
188
225
  ? keyframeWillExtend
189
- ? "Add keyframe at playhead, extends animation (K)"
190
- : "Add keyframe at playhead (K)"
191
- : "Add keyframe (K)"
226
+ ? "Extend motion path to playhead (K)"
227
+ : keyframeState === "active"
228
+ ? "Remove waypoint from motion path (K)"
229
+ : "Add waypoint to motion path (K)"
230
+ : keyframeState === "active"
231
+ ? "Remove keyframe at playhead (K)"
232
+ : keyframeState === "inactive"
233
+ ? keyframeWillExtend
234
+ ? "Add keyframe at playhead, extends animation (K)"
235
+ : "Add keyframe at playhead (K)"
236
+ : "Add keyframe (K)"
192
237
  }
193
238
  >
194
239
  <button
@@ -196,9 +241,17 @@ export function TimelineToolbar({ domEditSession, onSplitElement }: TimelineTool
196
241
  disabled={!onToggleKeyframe}
197
242
  onClick={onToggleKeyframe}
198
243
  aria-label={
199
- keyframeState === "active"
200
- ? "Remove keyframe at playhead"
201
- : "Add keyframe at playhead"
244
+ keyframePathEndpoint
245
+ ? "Motion path endpoint"
246
+ : keyframeIsMotionPath
247
+ ? keyframeState === "active"
248
+ ? "Remove motion path waypoint"
249
+ : keyframeWillExtend
250
+ ? "Extend motion path to playhead"
251
+ : "Add motion path waypoint"
252
+ : keyframeState === "active"
253
+ ? "Remove keyframe at playhead"
254
+ : "Add keyframe at playhead"
202
255
  }
203
256
  className={
204
257
  !onToggleKeyframe
@@ -45,17 +45,22 @@ function selectPreset(host: HTMLElement, presetId: string): string {
45
45
  return presetConfig.ease;
46
46
  }
47
47
 
48
- function renderExpandedCard({
49
- animation,
48
+ /** Every test mounts the same card; only expansion, flat mode, and the spies differ. */
49
+ function renderCard({
50
+ animation = baseAnimation(),
51
+ defaultExpanded = true,
50
52
  flat,
51
53
  onUpdateMeta = vi.fn(),
52
54
  onUpdateKeyframeEase = vi.fn(),
55
+ onDeleteAnimation = noop,
53
56
  }: {
54
- animation: GsapAnimation;
57
+ animation?: GsapAnimation;
58
+ defaultExpanded?: boolean;
55
59
  flat?: boolean;
56
60
  onUpdateMeta?: ReturnType<typeof vi.fn>;
57
61
  onUpdateKeyframeEase?: ReturnType<typeof vi.fn>;
58
- }) {
62
+ onDeleteAnimation?: (id: string) => void;
63
+ } = {}) {
59
64
  const host = document.createElement("div");
60
65
  document.body.append(host);
61
66
  const root = createRoot(host);
@@ -63,11 +68,11 @@ function renderExpandedCard({
63
68
  root.render(
64
69
  <AnimationCard
65
70
  animation={animation}
66
- defaultExpanded
71
+ defaultExpanded={defaultExpanded}
67
72
  flat={flat}
68
73
  onUpdateProperty={noop}
69
74
  onUpdateMeta={onUpdateMeta}
70
- onDeleteAnimation={noop}
75
+ onDeleteAnimation={onDeleteAnimation}
71
76
  onAddProperty={noop}
72
77
  onRemoveProperty={noop}
73
78
  onUpdateKeyframeEase={onUpdateKeyframeEase}
@@ -90,7 +95,7 @@ describe("AnimationCard ease editing", () => {
90
95
  ],
91
96
  },
92
97
  });
93
- const view = renderExpandedCard({ animation, onUpdateKeyframeEase });
98
+ const view = renderCard({ animation, onUpdateKeyframeEase });
94
99
 
95
100
  const segment = Array.from(view.host.querySelectorAll("button")).find((button) =>
96
101
  button.textContent?.includes("0% → 50%"),
@@ -101,6 +106,7 @@ describe("AnimationCard ease editing", () => {
101
106
 
102
107
  expect(onUpdateKeyframeEase).toHaveBeenCalledExactlyOnceWith(animation.id, 50, ease);
103
108
  expect(trackStudioSegmentEaseEdit).toHaveBeenCalledExactlyOnceWith({
109
+ action: "commit",
104
110
  ease,
105
111
  });
106
112
  act(() => view.root.unmount());
@@ -110,7 +116,7 @@ describe("AnimationCard ease editing", () => {
110
116
  const onUpdateMeta = vi.fn();
111
117
  const onUpdateKeyframeEase = vi.fn();
112
118
  const animation = baseAnimation({ id: "flat-tween" });
113
- const view = renderExpandedCard({
119
+ const view = renderCard({
114
120
  animation,
115
121
  flat: true,
116
122
  onUpdateMeta,
@@ -128,23 +134,7 @@ describe("AnimationCard ease editing", () => {
128
134
 
129
135
  describe("AnimationCard flat branch", () => {
130
136
  it("renders a mint border-left and panel-token colors when flat", () => {
131
- const host = document.createElement("div");
132
- document.body.append(host);
133
- const root = createRoot(host);
134
- act(() => {
135
- root.render(
136
- <AnimationCard
137
- animation={baseAnimation()}
138
- defaultExpanded={false}
139
- flat
140
- onUpdateProperty={noop}
141
- onUpdateMeta={noop}
142
- onDeleteAnimation={noop}
143
- onAddProperty={noop}
144
- onRemoveProperty={noop}
145
- />,
146
- );
147
- });
137
+ const { host, root } = renderCard({ defaultExpanded: false, flat: true });
148
138
  const card = host.querySelector('[data-flat-effect-card="true"]');
149
139
  expect(card).not.toBeNull();
150
140
  expect(card?.className).toContain("border-panel-accent");
@@ -152,22 +142,7 @@ describe("AnimationCard flat branch", () => {
152
142
  });
153
143
 
154
144
  it("still renders the legacy (non-flat) appearance when flat is omitted", () => {
155
- const host = document.createElement("div");
156
- document.body.append(host);
157
- const root = createRoot(host);
158
- act(() => {
159
- root.render(
160
- <AnimationCard
161
- animation={baseAnimation()}
162
- defaultExpanded={false}
163
- onUpdateProperty={noop}
164
- onUpdateMeta={noop}
165
- onDeleteAnimation={noop}
166
- onAddProperty={noop}
167
- onRemoveProperty={noop}
168
- />,
169
- );
170
- });
145
+ const { host, root } = renderCard({ defaultExpanded: false });
171
146
  expect(host.querySelector('[data-flat-effect-card="true"]')).toBeNull();
172
147
  expect(host.textContent).toContain("power2.out");
173
148
  act(() => root.unmount());
@@ -175,23 +150,7 @@ describe("AnimationCard flat branch", () => {
175
150
 
176
151
  it("toggles expanded state when the collapsed header button is clicked, in both modes", () => {
177
152
  for (const flat of [false, true]) {
178
- const host = document.createElement("div");
179
- document.body.append(host);
180
- const root = createRoot(host);
181
- act(() => {
182
- root.render(
183
- <AnimationCard
184
- animation={baseAnimation()}
185
- defaultExpanded={false}
186
- flat={flat || undefined}
187
- onUpdateProperty={noop}
188
- onUpdateMeta={noop}
189
- onDeleteAnimation={noop}
190
- onAddProperty={noop}
191
- onRemoveProperty={noop}
192
- />,
193
- );
194
- });
153
+ const { host, root } = renderCard({ defaultExpanded: false, flat: flat || undefined });
195
154
  expect(host.textContent).not.toContain("Remove");
196
155
  const button = host.querySelector("button");
197
156
  expect(button).not.toBeNull();
@@ -205,23 +164,7 @@ describe("AnimationCard flat branch", () => {
205
164
 
206
165
  it("invokes onDeleteAnimation with the animation id when Remove is clicked, in flat mode", () => {
207
166
  const onDeleteAnimation = vi.fn();
208
- const host = document.createElement("div");
209
- document.body.append(host);
210
- const root = createRoot(host);
211
- act(() => {
212
- root.render(
213
- <AnimationCard
214
- animation={baseAnimation()}
215
- defaultExpanded={true}
216
- flat
217
- onUpdateProperty={noop}
218
- onUpdateMeta={noop}
219
- onDeleteAnimation={onDeleteAnimation}
220
- onAddProperty={noop}
221
- onRemoveProperty={noop}
222
- />,
223
- );
224
- });
167
+ const { host, root } = renderCard({ flat: true, onDeleteAnimation });
225
168
  const buttons = Array.from(host.querySelectorAll("button"));
226
169
  const removeButton = buttons.find((b) => b.textContent === "Remove");
227
170
  expect(removeButton).not.toBeUndefined();
@@ -1,4 +1,4 @@
1
- import { memo, useCallback, useMemo, useState } from "react";
1
+ import { memo, useCallback, useEffect, useMemo, useRef, useState } from "react";
2
2
  import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
3
3
  import { SUPPORTED_EASES, SUPPORTED_PROPS } from "@hyperframes/core/gsap-constants";
4
4
  import { trackStudioSegmentEaseEdit } from "../../telemetry/events";
@@ -23,6 +23,8 @@ interface AnimationCardProps extends GsapAnimationEditCallbacks {
23
23
  animation: GsapAnimation;
24
24
  defaultExpanded: boolean;
25
25
  flat?: boolean;
26
+ focusedSegment?: { tweenPercentage: number } | null;
27
+ onFocusSegmentConsumed?: () => void;
26
28
  }
27
29
 
28
30
  // fallow-ignore-next-line complexity
@@ -30,6 +32,8 @@ export const AnimationCard = memo(function AnimationCard({
30
32
  animation,
31
33
  defaultExpanded,
32
34
  flat,
35
+ focusedSegment,
36
+ onFocusSegmentConsumed,
33
37
  onUpdateProperty,
34
38
  onUpdateMeta,
35
39
  onDeleteAnimation,
@@ -50,6 +54,25 @@ export const AnimationCard = memo(function AnimationCard({
50
54
  const [addingProp, setAddingProp] = useState(false);
51
55
  const [addingFromProp, setAddingFromProp] = useState(false);
52
56
  const [expandedKfPct, setExpandedKfPct] = useState<number | null>(null);
57
+ const cardRef = useRef<HTMLDivElement>(null);
58
+ const pendingAutoScrollRef = useRef(false);
59
+
60
+ useEffect(() => {
61
+ if (!focusedSegment) return;
62
+ setExpanded(true);
63
+ pendingAutoScrollRef.current = true;
64
+ setExpandedKfPct(focusedSegment.tweenPercentage);
65
+ onFocusSegmentConsumed?.();
66
+ }, [focusedSegment, onFocusSegmentConsumed]);
67
+
68
+ useEffect(() => {
69
+ if (!pendingAutoScrollRef.current || expandedKfPct === null) return;
70
+ const segment = cardRef.current?.querySelector<HTMLElement>(
71
+ `[data-ease-segment-pct="${expandedKfPct}"]`,
72
+ );
73
+ segment?.scrollIntoView({ block: "nearest", behavior: "smooth" });
74
+ pendingAutoScrollRef.current = false;
75
+ }, [expandedKfPct]);
53
76
 
54
77
  const usedProps = useMemo(
55
78
  () => new Set(Object.keys(animation.properties)),
@@ -154,6 +177,7 @@ export const AnimationCard = memo(function AnimationCard({
154
177
 
155
178
  return (
156
179
  <div
180
+ ref={cardRef}
157
181
  data-flat-effect-card={flat ? "true" : undefined}
158
182
  className={
159
183
  flat
@@ -267,7 +291,7 @@ export const AnimationCard = memo(function AnimationCard({
267
291
  onToggle={setExpandedKfPct}
268
292
  onEaseCommit={(pct, ease) => {
269
293
  onUpdateKeyframeEase(animation.id, pct, ease);
270
- trackStudioSegmentEaseEdit({ ease });
294
+ trackStudioSegmentEaseEdit({ action: "commit", ease });
271
295
  }}
272
296
  onApplyAll={
273
297
  onSetAllKeyframeEases