@hyperframes/studio 0.7.75 → 0.7.77

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 (134) hide show
  1. package/dist/assets/{hyperframes-player-BrfzYqX3.js → hyperframes-player-CEggaaxR.js} +1 -1
  2. package/dist/assets/{index-BvGKYVrH.js → index-B1INQNmj.js} +1 -1
  3. package/dist/assets/{index-CjGZkNxS.js → index-B1Tjjdse.js} +1 -1
  4. package/dist/assets/index-Bp4jAYZG.js +428 -0
  5. package/dist/assets/index-CBAfprvx.css +1 -0
  6. package/dist/index.d.ts +64 -17
  7. package/dist/index.html +2 -2
  8. package/dist/index.js +18040 -16293
  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/contexts/TimelineEditContext.tsx +1 -1
  38. package/src/hooks/deleteSelectedKeyframes.ts +24 -8
  39. package/src/hooks/gsapDragCommit.ts +2 -2
  40. package/src/hooks/gsapDragPositionCommit.ts +84 -5
  41. package/src/hooks/gsapKeyframeCacheHelpers.test.ts +176 -1
  42. package/src/hooks/gsapKeyframeCacheHelpers.ts +93 -52
  43. package/src/hooks/gsapRuntimeBridge.test.ts +100 -0
  44. package/src/hooks/gsapScriptCommitHelpers.ts +3 -2
  45. package/src/hooks/gsapScriptCommitTypes.ts +2 -0
  46. package/src/hooks/gsapShared.test.ts +109 -1
  47. package/src/hooks/gsapShared.ts +142 -17
  48. package/src/hooks/gsapTweenSynth.test.ts +30 -1
  49. package/src/hooks/gsapTweenSynth.ts +61 -14
  50. package/src/hooks/keyframeCacheAstLoad.ts +181 -0
  51. package/src/hooks/timelineEditingHelpers.test.ts +80 -0
  52. package/src/hooks/timelineMoveAdapter.ts +9 -2
  53. package/src/hooks/useDomEditWiring.ts +2 -2
  54. package/src/hooks/useDomSelection.ts +4 -0
  55. package/src/hooks/useEnableKeyframes.test.ts +122 -0
  56. package/src/hooks/useEnableKeyframes.ts +116 -56
  57. package/src/hooks/useGestureCommit.ts +2 -2
  58. package/src/hooks/useGsapAnimationFetchFallback.ts +1 -1
  59. package/src/hooks/useGsapKeyframeOps.test.tsx +92 -13
  60. package/src/hooks/useGsapKeyframeOps.ts +30 -13
  61. package/src/hooks/useGsapScriptCommits.test.tsx +21 -0
  62. package/src/hooks/useGsapScriptCommits.ts +2 -0
  63. package/src/hooks/useGsapSelectionHandlers.test.tsx +69 -3
  64. package/src/hooks/useGsapSelectionHandlers.ts +90 -65
  65. package/src/hooks/useGsapTweenCache.test.ts +16 -0
  66. package/src/hooks/useGsapTweenCache.ts +64 -178
  67. package/src/hooks/usePopulateKeyframeCacheForFile.test.tsx +68 -0
  68. package/src/hooks/useStudioContextValue.test.ts +93 -0
  69. package/src/hooks/useStudioContextValue.ts +17 -3
  70. package/src/hooks/useStudioTestHooks.ts +63 -0
  71. package/src/hooks/useTimelineEditing.test.tsx +187 -22
  72. package/src/hooks/useTimelineEditing.ts +43 -32
  73. package/src/hooks/useTimelineEditingTypes.ts +2 -0
  74. package/src/hooks/useTimelineGroupEditing.ts +106 -82
  75. package/src/player/components/KeyframeDiamondContextMenu.test.tsx +69 -0
  76. package/src/player/components/KeyframeDiamondContextMenu.tsx +19 -8
  77. package/src/player/components/LayerDisclosureRow.tsx +76 -0
  78. package/src/player/components/Timeline.test.ts +420 -90
  79. package/src/player/components/Timeline.tsx +80 -78
  80. package/src/player/components/TimelineCanvas.tsx +33 -21
  81. package/src/player/components/TimelineClip.tsx +3 -1
  82. package/src/player/components/TimelineClipDiamonds.test.tsx +540 -16
  83. package/src/player/components/TimelineClipDiamonds.tsx +320 -117
  84. package/src/player/components/TimelineDiamondConnectors.tsx +145 -0
  85. package/src/player/components/TimelineLanes.tsx +153 -79
  86. package/src/player/components/TimelineOverlays.tsx +4 -18
  87. package/src/player/components/TimelinePropertyLanes.test.tsx +450 -0
  88. package/src/player/components/TimelinePropertyLanes.tsx +191 -0
  89. package/src/player/components/TimelineRazorInteraction.tsx +60 -0
  90. package/src/player/components/TimelineRuler.tsx +11 -21
  91. package/src/player/components/TimelineTrackHeader.test.tsx +346 -0
  92. package/src/player/components/TimelineTrackHeader.tsx +363 -0
  93. package/src/player/components/TrackClipCount.tsx +17 -0
  94. package/src/player/components/timelineCallbacks.ts +21 -6
  95. package/src/player/components/timelineClipDragPreview.test.ts +50 -3
  96. package/src/player/components/timelineClipDragPreview.ts +33 -15
  97. package/src/player/components/timelineCollision.ts +4 -13
  98. package/src/player/components/timelineDiamondTypes.ts +120 -0
  99. package/src/player/components/timelineDragDrop.ts +8 -3
  100. package/src/player/components/timelineEditing.test.ts +19 -27
  101. package/src/player/components/timelineEditing.ts +7 -8
  102. package/src/player/components/timelineKeyframeIdentity.test.ts +49 -0
  103. package/src/player/components/timelineKeyframeIdentity.ts +66 -0
  104. package/src/player/components/timelineLayout.test.ts +162 -9
  105. package/src/player/components/timelineLayout.ts +198 -38
  106. package/src/player/components/timelineMarquee.test.ts +76 -21
  107. package/src/player/components/timelineMarquee.ts +17 -14
  108. package/src/player/components/trackHeaderLaneState.ts +121 -0
  109. package/src/player/components/trackHeaderLaneValues.ts +139 -0
  110. package/src/player/components/useAutoExpandKeyframedClips.test.tsx +75 -0
  111. package/src/player/components/useAutoExpandKeyframedClips.ts +50 -0
  112. package/src/player/components/useTimelineClipDrag.ts +4 -1
  113. package/src/player/components/useTimelineGeometry.ts +4 -1
  114. package/src/player/components/useTimelineKeyframeHandlers.test.tsx +108 -0
  115. package/src/player/components/useTimelineKeyframeHandlers.ts +50 -15
  116. package/src/player/components/useTimelinePlayhead.ts +21 -12
  117. package/src/player/components/useTimelineRangeSelection.ts +35 -14
  118. package/src/player/components/useTimelineTrackLayout.test.ts +53 -0
  119. package/src/player/components/useTimelineTrackLayout.ts +162 -0
  120. package/src/player/hooks/useExpandedTimelineElements.test.ts +43 -0
  121. package/src/player/hooks/useExpandedTimelineElements.ts +21 -12
  122. package/src/player/lib/timelineElementHelpers.ts +26 -11
  123. package/src/player/store/keyframeSlice.ts +122 -0
  124. package/src/player/store/playerStore.test.ts +25 -0
  125. package/src/player/store/playerStore.ts +7 -43
  126. package/src/telemetry/events.test.ts +9 -3
  127. package/src/telemetry/events.ts +16 -7
  128. package/src/utils/globalTimeCompiler.test.ts +10 -0
  129. package/src/utils/globalTimeCompiler.ts +3 -3
  130. package/dist/assets/index-B37rWXo4.css +0 -1
  131. package/dist/assets/index-DzDajONI.js +0 -428
  132. package/src/player/components/TimelineDragGhost.tsx +0 -59
  133. package/src/utils/keyframeSelection.test.ts +0 -45
  134. package/src/utils/keyframeSelection.ts +0 -29
@@ -0,0 +1,68 @@
1
+ import { ADD_METHODS, ADD_METHOD_LABELS, METHOD_TOOLTIPS } from "./gsapAnimationConstants";
2
+
3
+ const STYLES = {
4
+ classic: {
5
+ method:
6
+ "rounded-lg border border-neutral-700 bg-neutral-900 px-2.5 py-1.5 text-[11px] font-medium text-neutral-300 transition-colors hover:border-neutral-600 hover:text-white",
7
+ cancel: "px-1.5 text-[11px] text-neutral-500 hover:text-neutral-300",
8
+ trigger: "text-[11px] font-medium text-neutral-400 transition-colors hover:text-neutral-200",
9
+ },
10
+ flat: {
11
+ method:
12
+ "rounded-lg border border-panel-border-input bg-panel-input px-2.5 py-1.5 text-[11px] font-medium text-panel-text-2 transition-colors hover:border-panel-text-4 hover:text-panel-text-0",
13
+ cancel: "px-1.5 text-[11px] text-panel-text-3 hover:text-panel-text-1",
14
+ trigger: "text-[11px] font-medium text-panel-text-3 transition-colors hover:text-panel-text-1",
15
+ },
16
+ };
17
+
18
+ export function GsapAddAnimationControl({
19
+ open,
20
+ setOpen,
21
+ onAddAnimation,
22
+ track,
23
+ variant,
24
+ }: {
25
+ open: boolean;
26
+ setOpen: (open: boolean) => void;
27
+ onAddAnimation: (method: "to" | "from" | "set" | "fromTo") => void;
28
+ track: (control: string, name: string) => void;
29
+ variant: keyof typeof STYLES;
30
+ }) {
31
+ const styles = STYLES[variant];
32
+
33
+ return (
34
+ <div className="relative pt-1">
35
+ {open ? (
36
+ <div className="flex gap-1.5">
37
+ {ADD_METHODS.map((method) => (
38
+ <button
39
+ key={method}
40
+ type="button"
41
+ title={METHOD_TOOLTIPS[method]}
42
+ onClick={() => {
43
+ track("button", `Add ${method} animation`);
44
+ onAddAnimation(method);
45
+ setOpen(false);
46
+ }}
47
+ className={styles.method}
48
+ >
49
+ {ADD_METHOD_LABELS[method] ?? method}
50
+ </button>
51
+ ))}
52
+ <button type="button" onClick={() => setOpen(false)} className={styles.cancel}>
53
+ Cancel
54
+ </button>
55
+ </div>
56
+ ) : (
57
+ <button
58
+ type="button"
59
+ onClick={() => setOpen(true)}
60
+ className={styles.trigger}
61
+ title="Add a new animation effect to this element"
62
+ >
63
+ + Add effect
64
+ </button>
65
+ )}
66
+ </div>
67
+ );
68
+ }
@@ -2,13 +2,15 @@ import { memo, useState } from "react";
2
2
  import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
3
3
  import { Film } from "../../icons/SystemIcons";
4
4
  import { Section } from "./propertyPanelPrimitives";
5
- import { ADD_METHODS, ADD_METHOD_LABELS, METHOD_TOOLTIPS } from "./gsapAnimationConstants";
6
5
  import { AnimationCard } from "./AnimationCard";
7
6
  import {
8
- trackAnimationMetaUpdate,
9
7
  type GsapAnimationEditCallbacks,
8
+ withTrackedGsapAnimationCallbacks,
9
+ clearFocusedEaseSegment,
10
10
  } from "./gsapAnimationCallbacks";
11
11
  import { useTrackDesignInput } from "../../contexts/DesignPanelInputContext";
12
+ import { usePlayerStore } from "../../player";
13
+ import { GsapAddAnimationControl } from "./GsapAddAnimationControl";
12
14
 
13
15
  interface GsapAnimationSectionProps extends GsapAnimationEditCallbacks {
14
16
  animations: GsapAnimation[];
@@ -21,41 +23,13 @@ export const GsapAnimationSection = memo(function GsapAnimationSection({
21
23
  animations,
22
24
  multipleTimelines,
23
25
  unsupportedTimelinePattern,
24
- onUpdateProperty,
25
- onUpdateMeta,
26
- onDeleteAnimation,
27
- onAddProperty,
28
- onRemoveProperty,
29
- onUpdateFromProperty,
30
- onAddFromProperty,
31
- onRemoveFromProperty,
32
26
  onAddAnimation,
33
- onLivePreview,
34
- onLivePreviewEnd,
35
- onSetArcPath,
36
- onUpdateArcSegment,
37
- onUpdateKeyframeEase,
38
- onSetAllKeyframeEases,
39
- onUnroll,
27
+ ...callbacks
40
28
  }: GsapAnimationSectionProps) {
41
29
  const track = useTrackDesignInput();
42
30
  const [addMenuOpen, setAddMenuOpen] = useState(false);
43
- const trackProperty = (property: string) => {
44
- const control =
45
- property === "visibility"
46
- ? "toggle"
47
- : property === "filter" || property === "clipPath"
48
- ? "text"
49
- : "metric";
50
- track(control, property);
51
- };
52
- const updateMeta = (
53
- animationId: string,
54
- updates: { duration?: number; ease?: string; position?: number },
55
- ) => {
56
- trackAnimationMetaUpdate(track, updates);
57
- onUpdateMeta(animationId, updates);
58
- };
31
+ const trackedCallbacks = withTrackedGsapAnimationCallbacks(callbacks, track);
32
+ const focusedEaseSegment = usePlayerStore((s) => s.focusedEaseSegment);
59
33
 
60
34
  return (
61
35
  <Section title="Animation" icon={<Film size={15} />}>
@@ -76,137 +50,24 @@ export const GsapAnimationSection = memo(function GsapAnimationSection({
76
50
  <div className="space-y-2">
77
51
  {animations.map((anim, index) => (
78
52
  <AnimationCard
53
+ {...trackedCallbacks}
79
54
  key={anim.id}
80
55
  animation={anim}
81
56
  defaultExpanded={index === 0}
82
- onUpdateProperty={(animationId, property, value) => {
83
- trackProperty(property);
84
- onUpdateProperty(animationId, property, value);
85
- }}
86
- onUpdateMeta={updateMeta}
87
- onDeleteAnimation={(animationId) => {
88
- track("button", "Remove animation");
89
- onDeleteAnimation(animationId);
90
- }}
91
- onAddProperty={(animationId, property) => {
92
- track("select", "Add effect property");
93
- onAddProperty(animationId, property);
94
- }}
95
- onRemoveProperty={(animationId, property) => {
96
- track("button", `Remove ${property}`);
97
- onRemoveProperty(animationId, property);
98
- }}
99
- onUpdateFromProperty={
100
- onUpdateFromProperty
101
- ? (animationId, property, value) => {
102
- trackProperty(property);
103
- onUpdateFromProperty(animationId, property, value);
104
- }
105
- : undefined
106
- }
107
- onAddFromProperty={
108
- onAddFromProperty
109
- ? (animationId, property) => {
110
- track("select", "Add from property");
111
- onAddFromProperty(animationId, property);
112
- }
113
- : undefined
114
- }
115
- onRemoveFromProperty={
116
- onRemoveFromProperty
117
- ? (animationId, property) => {
118
- track("button", `Remove from ${property}`);
119
- onRemoveFromProperty(animationId, property);
120
- }
121
- : undefined
122
- }
123
- onLivePreview={onLivePreview}
124
- onLivePreviewEnd={onLivePreviewEnd}
125
- onSetArcPath={
126
- onSetArcPath
127
- ? (animationId, config) => {
128
- track(
129
- "toggle",
130
- config.autoRotate !== undefined ? "Auto rotate" : "Arc motion",
131
- );
132
- onSetArcPath(animationId, config);
133
- }
134
- : undefined
135
- }
136
- onUpdateArcSegment={
137
- onUpdateArcSegment
138
- ? (animationId, segmentIndex, update) => {
139
- if (update.curviness === undefined) {
140
- track("button", `Reset arc segment ${segmentIndex + 1}`);
141
- }
142
- onUpdateArcSegment(animationId, segmentIndex, update);
143
- }
144
- : undefined
145
- }
146
- onUpdateKeyframeEase={
147
- onUpdateKeyframeEase
148
- ? (animationId, percentage, ease) => {
149
- track("select", "Keyframe ease");
150
- onUpdateKeyframeEase(animationId, percentage, ease);
151
- }
152
- : undefined
153
- }
154
- onSetAllKeyframeEases={
155
- onSetAllKeyframeEases
156
- ? (animationId, ease) => {
157
- track("select", "All keyframe eases");
158
- onSetAllKeyframeEases(animationId, ease);
159
- }
160
- : undefined
161
- }
162
- onUnroll={
163
- onUnroll
164
- ? (animationId) => {
165
- track("button", "Unroll animation");
166
- onUnroll(animationId);
167
- }
168
- : undefined
57
+ focusedSegment={
58
+ focusedEaseSegment?.animationId === anim.id ? focusedEaseSegment : null
169
59
  }
60
+ onFocusSegmentConsumed={clearFocusedEaseSegment}
170
61
  />
171
62
  ))}
172
63
 
173
- <div className="relative pt-1">
174
- {addMenuOpen ? (
175
- <div className="flex gap-1.5">
176
- {ADD_METHODS.map((method) => (
177
- <button
178
- key={method}
179
- type="button"
180
- title={METHOD_TOOLTIPS[method]}
181
- onClick={() => {
182
- track("button", `Add ${method} animation`);
183
- onAddAnimation(method);
184
- setAddMenuOpen(false);
185
- }}
186
- className="rounded-lg border border-neutral-700 bg-neutral-900 px-2.5 py-1.5 text-[11px] font-medium text-neutral-300 transition-colors hover:border-neutral-600 hover:text-white"
187
- >
188
- {ADD_METHOD_LABELS[method] ?? method}
189
- </button>
190
- ))}
191
- <button
192
- type="button"
193
- onClick={() => setAddMenuOpen(false)}
194
- className="px-1.5 text-[11px] text-neutral-500 hover:text-neutral-300"
195
- >
196
- Cancel
197
- </button>
198
- </div>
199
- ) : (
200
- <button
201
- type="button"
202
- onClick={() => setAddMenuOpen(true)}
203
- className="text-[11px] font-medium text-neutral-400 transition-colors hover:text-neutral-200"
204
- title="Add a new animation effect to this element"
205
- >
206
- + Add effect
207
- </button>
208
- )}
209
- </div>
64
+ <GsapAddAnimationControl
65
+ open={addMenuOpen}
66
+ setOpen={setAddMenuOpen}
67
+ onAddAnimation={onAddAnimation}
68
+ track={track}
69
+ variant="classic"
70
+ />
210
71
  </div>
211
72
  )}
212
73
  </Section>
@@ -93,7 +93,11 @@ export function KeyframeEaseList({
93
93
  ? "Custom"
94
94
  : (EASE_LABELS[segEase] ?? segEase);
95
95
  return (
96
- <div key={`${i}-${kf.percentage}`} className="rounded-md bg-neutral-900/50">
96
+ <div
97
+ key={`${i}-${kf.percentage}`}
98
+ data-ease-segment-pct={kf.percentage}
99
+ className="rounded-md bg-neutral-900/50"
100
+ >
97
101
  <button
98
102
  type="button"
99
103
  onClick={() => onToggle(isExpanded ? null : kf.percentage)}
@@ -11,17 +11,50 @@ interface KeyframeNavigationProps {
11
11
  tweenPercentage?: number;
12
12
  properties: Record<string, number | string>;
13
13
  ease?: string;
14
+ /** The tween that authored this keyframe, when the cache knows it. */
15
+ animationId?: string;
14
16
  }> | null;
15
17
  /** Current playhead percentage within the element's lifetime (0-100) */
16
18
  currentPercentage: number;
17
19
  onSeek: (percentage: number) => void;
18
20
  onAddKeyframe: (percentage: number) => void;
19
- onRemoveKeyframe: (percentage: number) => void;
21
+ /** `animationId` is the clicked keyframe's OWN tween; see handleDiamondClick. */
22
+ onRemoveKeyframe: (percentage: number, animationId?: string) => void;
20
23
  onConvertToKeyframes: () => void;
21
24
  }
22
25
 
23
26
  const TOLERANCE = 0.5;
24
27
 
28
+ interface NavigableKeyframe {
29
+ percentage: number;
30
+ tweenPercentage?: number;
31
+ properties: Record<string, number | string>;
32
+ }
33
+
34
+ export function getKeyframeNavigationState<Keyframe extends NavigableKeyframe>(
35
+ keyframes: readonly Keyframe[],
36
+ currentPercentage: number,
37
+ property?: string,
38
+ ) {
39
+ const propertyKeyframes = property
40
+ ? keyframes.filter((keyframe) => property in keyframe.properties)
41
+ : keyframes;
42
+ return {
43
+ propertyKeyframes,
44
+ prevKeyframe:
45
+ propertyKeyframes
46
+ .filter((keyframe) => keyframe.percentage < currentPercentage - TOLERANCE)
47
+ .at(-1) ?? null,
48
+ nextKeyframe:
49
+ propertyKeyframes.find((keyframe) => keyframe.percentage > currentPercentage + TOLERANCE) ??
50
+ null,
51
+ currentKeyframe:
52
+ propertyKeyframes.find(
53
+ (keyframe) => Math.abs(keyframe.percentage - currentPercentage) <= TOLERANCE,
54
+ ) ?? null,
55
+ };
56
+ }
57
+
25
58
  /**
26
59
  * Convert a clip-relative percentage (element lifetime, used for display/seek) to
27
60
  * the TWEEN-relative percentage the GSAP writer/runtime key on. The clip→tween
@@ -92,18 +125,12 @@ export const KeyframeNavigation = memo(function KeyframeNavigation({
92
125
  onRemoveKeyframe,
93
126
  onConvertToKeyframes,
94
127
  }: KeyframeNavigationProps) {
95
- // Find keyframes that contain this property
96
- const propertyKeyframes = keyframes?.filter((kf) => property in kf.properties) ?? [];
97
-
98
- const prevKf =
99
- propertyKeyframes.filter((kf) => kf.percentage < currentPercentage - TOLERANCE).at(-1) ?? null;
100
-
101
- const nextKf =
102
- propertyKeyframes.find((kf) => kf.percentage > currentPercentage + TOLERANCE) ?? null;
103
-
104
- const atCurrent =
105
- propertyKeyframes.find((kf) => Math.abs(kf.percentage - currentPercentage) <= TOLERANCE) ??
106
- null;
128
+ const {
129
+ propertyKeyframes,
130
+ prevKeyframe: prevKf,
131
+ nextKeyframe: nextKf,
132
+ currentKeyframe: atCurrent,
133
+ } = getKeyframeNavigationState(keyframes ?? [], currentPercentage, property);
107
134
 
108
135
  // Diamond state
109
136
  let diamondState: DiamondState;
@@ -128,7 +155,12 @@ export const KeyframeNavigation = memo(function KeyframeNavigation({
128
155
  if (diamondState === "ghost") {
129
156
  onConvertToKeyframes();
130
157
  } else if (diamondState === "active" && atCurrent) {
131
- onRemoveKeyframe(atCurrent.tweenPercentage ?? atCurrent.percentage);
158
+ // Report the keyframe's OWN tween. A merged gutter row shows the keyframes
159
+ // of every tween in the property group, so the caller's "the group's
160
+ // animation" guess names the wrong tween whenever the clicked keyframe
161
+ // belongs to a sibling — and the writer then finds no keyframe at that
162
+ // percentage and silently does nothing.
163
+ onRemoveKeyframe(atCurrent.tweenPercentage ?? atCurrent.percentage, atCurrent.animationId);
132
164
  } else {
133
165
  onAddKeyframe(clipToTweenPercentage(propertyKeyframes, currentPercentage));
134
166
  }
@@ -0,0 +1,59 @@
1
+ // @vitest-environment happy-dom
2
+
3
+ import { act } from "react";
4
+ import { beforeAll, describe, expect, it, vi } from "vitest";
5
+ import { installReactActEnvironment, mountReactHarness } from "../../hooks/domSelectionTestHarness";
6
+ import { KeyframeNavigation } from "./KeyframeNavigation";
7
+
8
+ beforeAll(installReactActEnvironment);
9
+
10
+ /**
11
+ * Regression: a merged gutter row shows the keyframes of EVERY tween in the
12
+ * property group. The panel guesses "the group's animation" for the write, so a
13
+ * click on a keyframe authored by a sibling tween named the wrong animation and
14
+ * the writer silently found nothing to remove. The diamond now reports the
15
+ * clicked keyframe's own animationId.
16
+ */
17
+ const MERGED_ROW = [
18
+ { percentage: 0, tweenPercentage: 0, properties: { x: 0 }, animationId: "delta-to-500-position" },
19
+ {
20
+ percentage: 50,
21
+ tweenPercentage: 25,
22
+ properties: { x: 120 },
23
+ animationId: "delta-to-4000-position",
24
+ },
25
+ ];
26
+
27
+ function clickDiamond(currentPercentage: number, onRemoveKeyframe: (...args: never[]) => void) {
28
+ const root = mountReactHarness(
29
+ <KeyframeNavigation
30
+ property="x"
31
+ keyframes={MERGED_ROW}
32
+ currentPercentage={currentPercentage}
33
+ onSeek={vi.fn()}
34
+ onAddKeyframe={vi.fn()}
35
+ onRemoveKeyframe={onRemoveKeyframe as never}
36
+ onConvertToKeyframes={vi.fn()}
37
+ />,
38
+ );
39
+ const diamond = document.querySelector<HTMLElement>('[title="Remove x keyframe"]');
40
+ expect(diamond).not.toBeNull();
41
+ act(() => {
42
+ diamond?.click();
43
+ });
44
+ act(() => root.unmount());
45
+ }
46
+
47
+ describe("KeyframeNavigation diamond", () => {
48
+ it("reports the clicked keyframe's own animation on a merged row", () => {
49
+ const onRemoveKeyframe = vi.fn();
50
+ clickDiamond(50, onRemoveKeyframe);
51
+ expect(onRemoveKeyframe).toHaveBeenCalledWith(25, "delta-to-4000-position");
52
+ });
53
+
54
+ it("still reports the first tween's keyframe as its own", () => {
55
+ const onRemoveKeyframe = vi.fn();
56
+ clickDiamond(0, onRemoveKeyframe);
57
+ expect(onRemoveKeyframe).toHaveBeenCalledWith(0, "delta-to-500-position");
58
+ });
59
+ });
@@ -45,6 +45,19 @@ type DragState = {
45
45
  ref: MotionNodeRef;
46
46
  };
47
47
 
48
+ /**
49
+ * Tween-% for a stop inserted at fraction `t` along the segment between two
50
+ * nodes. null when either end is not a keyframe (arc waypoints carry no %).
51
+ */
52
+ function interpolatedKeyframePct(
53
+ a: MotionNodeRef | undefined,
54
+ b: MotionNodeRef | undefined,
55
+ t: number,
56
+ ): number | null {
57
+ if (a?.type !== "keyframe" || b?.type !== "keyframe") return null;
58
+ return Math.round((a.pct + (b.pct - a.pct) * t) * 1000) / 1000;
59
+ }
60
+
48
61
  const NODE_PX = 6; // node radius in screen pixels (kept constant across zoom)
49
62
  // Click-vs-drag cutoff in SCREEN pixels. Below this the pointer-up is a click
50
63
  // (select the keyframe); at or above it the gesture commits a move. Screen-space
@@ -88,6 +101,13 @@ export const MotionPathOverlay = memo(function MotionPathOverlay({
88
101
  // The keyframe % selected by clicking its node — highlighted, and the next drag
89
102
  // modifies it rather than adding a keyframe.
90
103
  const activeKeyframePct = usePlayerStore((s) => s.activeKeyframePct);
104
+ const timelineElement = usePlayerStore((state) => {
105
+ if (!selection) return undefined;
106
+ const sourceScopedId = `${selection.sourceFile || "index.html"}#${selection.id}`;
107
+ return state.elements.find(
108
+ (element) => (element.key ?? element.id) === sourceScopedId || element.id === selection.id,
109
+ );
110
+ });
91
111
  // Set-destination mode is armed from the preview toolbar (replaces the old
92
112
  // double-click-on-canvas UX). See createMode effects below.
93
113
  const armed = usePlayerStore((s) => s.motionPathArmed);
@@ -395,13 +415,10 @@ export const MotionPathOverlay = memo(function MotionPathOverlay({
395
415
  void commitAddWaypoint(animId, np.segIndex + 1, x, y, commitMutation);
396
416
  } else {
397
417
  // Linear keyframe path: interpolate the new stop's tween-% from the two
398
- // keyframes bounding the clicked segment (np.t = fraction along it), then
399
- // insert it. Lands ON the current line, so the dot doesn't jump — drag it
400
- // after to bend the path.
401
- const a = abs[np.segIndex]?.ref;
402
- const b = abs[np.segIndex + 1]?.ref;
403
- if (a?.type !== "keyframe" || b?.type !== "keyframe") return;
404
- const pct = Math.round((a.pct + (b.pct - a.pct) * np.t) * 1000) / 1000;
418
+ // keyframes bounding the clicked segment, then insert it. Lands ON the
419
+ // current line, so the dot doesn't jump — drag it after to bend the path.
420
+ const pct = interpolatedKeyframePct(abs[np.segIndex]?.ref, abs[np.segIndex + 1]?.ref, np.t);
421
+ if (pct === null) return;
405
422
  e.stopPropagation();
406
423
  void commitAddKeyframe(animId, pct, x, y, commitMutation);
407
424
  }
@@ -418,12 +435,13 @@ export const MotionPathOverlay = memo(function MotionPathOverlay({
418
435
  // Right-click a keyframe node → the timeline's keyframe context menu (delete
419
436
  // this keyframe / delete all), so motion-path keyframes are removable in place.
420
437
  const onNodeContextMenu = (e: React.MouseEvent, ref: MotionNodeRef) => {
421
- if (ref.type !== "keyframe" || !animId || !elementId) return;
438
+ if (ref.type !== "keyframe" || !animId || !elementId || !timelineElement) return;
422
439
  e.preventDefault();
423
440
  e.stopPropagation();
424
441
  setKfMenu({
425
442
  x: e.clientX,
426
443
  y: e.clientY,
444
+ element: timelineElement,
427
445
  elementId,
428
446
  percentage: ref.pct,
429
447
  tweenPercentage: ref.pct,
@@ -512,9 +530,13 @@ export const MotionPathOverlay = memo(function MotionPathOverlay({
512
530
  <KeyframeDiamondContextMenu
513
531
  state={kfMenu}
514
532
  onClose={() => setKfMenu(null)}
515
- onDelete={(_elId, pct) => animId && handleGsapRemoveKeyframe(animId, pct)}
533
+ onDelete={(_elId, keyframe) =>
534
+ animId && handleGsapRemoveKeyframe(animId, keyframe.percentage)
535
+ }
516
536
  onDeleteAll={() => animId && handleGsapRemoveAllKeyframes(animId)}
517
- onMoveToPlayhead={(_elId, pct) => animId && handleGsapMoveKeyframeToPlayhead(animId, pct)}
537
+ onMoveToPlayhead={(_element, keyframe) =>
538
+ animId && handleGsapMoveKeyframeToPlayhead(animId, keyframe.percentage)
539
+ }
518
540
  />
519
541
  )}
520
542
  </>
@@ -408,7 +408,9 @@ export const PropertyPanel = memo(function PropertyPanel(props: PropertyPanelPro
408
408
  onCommitAnimatedProperty &&
409
409
  void onCommitAnimatedProperty(element, "x", displayX)
410
410
  }
411
- onRemoveKeyframe={(pct) => onRemoveKeyframe?.(animIdForProp("x"), pct)}
411
+ onRemoveKeyframe={(pct, animationId) =>
412
+ onRemoveKeyframe?.(animationId ?? animIdForProp("x"), pct)
413
+ }
412
414
  onConvertToKeyframes={() => onConvertToKeyframes?.(animIdForProp("x"))}
413
415
  />
414
416
  )}
@@ -433,7 +435,9 @@ export const PropertyPanel = memo(function PropertyPanel(props: PropertyPanelPro
433
435
  onCommitAnimatedProperty &&
434
436
  void onCommitAnimatedProperty(element, "y", displayY)
435
437
  }
436
- onRemoveKeyframe={(pct) => onRemoveKeyframe?.(animIdForProp("y"), pct)}
438
+ onRemoveKeyframe={(pct, animationId) =>
439
+ onRemoveKeyframe?.(animationId ?? animIdForProp("y"), pct)
440
+ }
437
441
  onConvertToKeyframes={() => onConvertToKeyframes?.(animIdForProp("y"))}
438
442
  />
439
443
  )}
@@ -458,7 +462,9 @@ export const PropertyPanel = memo(function PropertyPanel(props: PropertyPanelPro
458
462
  onCommitAnimatedProperty &&
459
463
  void onCommitAnimatedProperty(element, "width", displayW)
460
464
  }
461
- onRemoveKeyframe={(pct) => onRemoveKeyframe?.(animIdForProp("width"), pct)}
465
+ onRemoveKeyframe={(pct, animationId) =>
466
+ onRemoveKeyframe?.(animationId ?? animIdForProp("width"), pct)
467
+ }
462
468
  onConvertToKeyframes={() => onConvertToKeyframes?.(animIdForProp("width"))}
463
469
  />
464
470
  )}
@@ -483,7 +489,9 @@ export const PropertyPanel = memo(function PropertyPanel(props: PropertyPanelPro
483
489
  onCommitAnimatedProperty &&
484
490
  void onCommitAnimatedProperty(element, "height", displayH)
485
491
  }
486
- onRemoveKeyframe={(pct) => onRemoveKeyframe?.(animIdForProp("height"), pct)}
492
+ onRemoveKeyframe={(pct, animationId) =>
493
+ onRemoveKeyframe?.(animationId ?? animIdForProp("height"), pct)
494
+ }
487
495
  onConvertToKeyframes={() => onConvertToKeyframes?.(animIdForProp("height"))}
488
496
  />
489
497
  )}
@@ -507,7 +515,9 @@ export const PropertyPanel = memo(function PropertyPanel(props: PropertyPanelPro
507
515
  onCommitAnimatedProperty &&
508
516
  void onCommitAnimatedProperty(element, "rotation", displayR)
509
517
  }
510
- onRemoveKeyframe={(pct) => onRemoveKeyframe?.(animIdForProp("rotation"), pct)}
518
+ onRemoveKeyframe={(pct, animationId) =>
519
+ onRemoveKeyframe?.(animationId ?? animIdForProp("rotation"), pct)
520
+ }
511
521
  onConvertToKeyframes={() => onConvertToKeyframes?.(animIdForProp("rotation"))}
512
522
  />
513
523
  )}