@hyperframes/studio 0.7.79 → 0.7.80

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 (50) hide show
  1. package/dist/assets/{hyperframes-player-mFah2TZE.js → hyperframes-player-Csai0_vh.js} +1 -1
  2. package/dist/assets/{index-Bqj3h_1a.js → index-B5aYTg-4.js} +1 -1
  3. package/dist/assets/{index-DlZMDyYs.js → index-BhdJN49y.js} +200 -200
  4. package/dist/assets/{index-hmnoiSEV.js → index-By-YiRxq.js} +1 -1
  5. package/dist/assets/index-D379YOKT.css +1 -0
  6. package/dist/index.d.ts +45 -3
  7. package/dist/index.html +2 -2
  8. package/dist/index.js +652 -465
  9. package/dist/index.js.map +1 -1
  10. package/package.json +7 -7
  11. package/src/components/StudioRightPanel.tsx +2 -1
  12. package/src/components/editor/AnimationCard.test.tsx +225 -18
  13. package/src/components/editor/AnimationCard.tsx +24 -3
  14. package/src/components/editor/EaseCurveSection.test.tsx +147 -2
  15. package/src/components/editor/EaseCurveSection.tsx +77 -15
  16. package/src/components/editor/GsapAnimationSection.test.tsx +102 -0
  17. package/src/components/editor/GsapAnimationSection.tsx +6 -1
  18. package/src/components/editor/KeyframeEaseList.tsx +4 -0
  19. package/src/components/editor/MotionPathOverlay.tsx +54 -19
  20. package/src/components/editor/PropertyPanel.tsx +4 -0
  21. package/src/components/editor/PropertyPanelFlat.tsx +6 -91
  22. package/src/components/editor/gsapAnimationCallbacks.test.ts +8 -1
  23. package/src/components/editor/gsapAnimationCallbacks.ts +8 -0
  24. package/src/components/editor/holdEaseSeek.test.ts +37 -0
  25. package/src/components/editor/propertyPanelFlatMotionSection.test.tsx +69 -0
  26. package/src/components/editor/propertyPanelFlatMotionSection.tsx +2 -1
  27. package/src/components/editor/propertyPanelFlatProps.ts +91 -0
  28. package/src/components/editor/propertyPanelTypes.ts +2 -0
  29. package/src/contexts/DomEditContext.tsx +4 -0
  30. package/src/hooks/gsapKeyframeCacheHelpers.test.ts +132 -43
  31. package/src/hooks/gsapKeyframeCacheHelpers.ts +150 -50
  32. package/src/hooks/gsapTweenSynth.test.ts +70 -15
  33. package/src/hooks/gsapTweenSynth.ts +50 -23
  34. package/src/hooks/keyframeCacheAstLoad.ts +4 -12
  35. package/src/hooks/useDomEditSession.test.tsx +152 -3
  36. package/src/hooks/useDomEditSession.ts +4 -53
  37. package/src/hooks/useGsapTweenCache.ts +40 -34
  38. package/src/hooks/useKeyframeEaseCommits.ts +78 -0
  39. package/src/player/components/KeyframeDiamondContextMenu.test.tsx +21 -0
  40. package/src/player/components/KeyframeDiamondContextMenu.tsx +19 -12
  41. package/src/player/components/TimelineClipDiamonds.test.tsx +18 -6
  42. package/src/player/components/TimelineDiamondConnectors.tsx +118 -85
  43. package/src/player/components/timelineDiamondTypes.ts +4 -3
  44. package/src/player/components/timelineKeyframeIdentity.ts +3 -0
  45. package/src/player/components/useTimelineKeyframeHandlers.test.tsx +52 -32
  46. package/src/player/components/useTimelineKeyframeHandlers.ts +1 -0
  47. package/src/player/hooks/useExpandedTimelineElements.test.ts +29 -0
  48. package/src/player/hooks/useExpandedTimelineElements.ts +24 -0
  49. package/src/player/store/keyframeSlice.ts +15 -5
  50. package/dist/assets/index-gGVKuFg5.css +0 -1
@@ -37,13 +37,6 @@ export function TimelineDiamondConnectors({
37
37
  keyframeTarget: (keyframe: TimelineDiamondKeyframe) => TimelineKeyframeTarget;
38
38
  onSelectSegment?: (target: TimelineKeyframeTarget) => void;
39
39
  }) {
40
- // The ease button sits dead centre of its segment, which on a two-keyframe clip
41
- // is the centre of the clip bar — the natural place to grab a clip and drag it.
42
- // Swallowing pointerdown there made that grab a no-op. Instead the press falls
43
- // through to the clip (so the drag starts normally) and the button keeps only
44
- // the click, which we drop if the pointer actually travelled.
45
- const pressXRef = useRef<number | null>(null);
46
-
47
40
  return (
48
41
  <>
49
42
  {markers.map((marker, i) => {
@@ -55,19 +48,6 @@ export function TimelineDiamondConnectors({
55
48
  if (x2 - x1 < 1) return null;
56
49
  const connectorLeft = x1 + previous.visualSize / 2;
57
50
  const connectorWidth = x2 - x1 - previous.visualSize / 2 - marker.visualSize / 2;
58
- // The ease button targets one segment, so it needs the keyframe's own
59
- // animationId/tweenPercentage. On a merged inline row the button is
60
- // hidden where the segment is ambiguous (two source animations collide
61
- // at this % with different eases; see easeAmbiguous) or the keyframe has
62
- // no source animation id (runtime-scanned) so there is no tween to target.
63
- const target = keyframeTarget(kf);
64
- const ease = kf.ease ?? globalEase;
65
- // connectorWidth is the clear span between the two diamonds' edges, so a
66
- // 24x24 target centred in it overhangs a diamond as soon as the span is
67
- // narrower than 24. The segment wrapper sits at z-index 3, above the
68
- // diamonds, so that overhang would win the hit test and steal their
69
- // clicks at fit zoom. Grow the target only where the room exists.
70
- const roomForFullTarget = connectorWidth >= 24;
71
51
  return (
72
52
  <Fragment key={`line-${i}-${previous.keyframe.percentage}-${kf.percentage}`}>
73
53
  <div
@@ -84,71 +64,22 @@ export function TimelineDiamondConnectors({
84
64
  borderRadius: 1,
85
65
  }}
86
66
  />
87
- {onSelectSegment && !kf.easeAmbiguous && kf.animationId !== undefined && (
88
- <div
89
- className="group absolute"
90
- data-keyframe-ease-segment=""
91
- style={{
92
- left: x1,
93
- top: centerY,
94
- width: x2 - x1,
95
- height: 18,
96
- transform: "translateY(-50%)",
97
- // Own a stacking context above the diamond buttons. At fit
98
- // zoom the 16px ease control can overlap its neighbouring
99
- // diamond; without a z-index here the later diamond wins the
100
- // hit test even though the child button has z-index 3.
101
- zIndex: 3,
102
- // Only the centered control is interactive. The transparent
103
- // segment wrapper must not swallow connector/clip gestures.
104
- pointerEvents: "none",
105
- }}
106
- >
107
- <button
108
- type="button"
109
- data-keyframe-ease-button=""
110
- aria-label={`Edit ${ease} easing`}
111
- title={`Edit ${ease} easing`}
112
- // A visible 24x24 badge would collide with the diamonds either
113
- // side, so the WCAG 2.2 (2.5.8) target is met with a centered
114
- // transparent ::before overlay; the box stays 16x16. Where the
115
- // segment is too narrow for that overlay the button keeps its
116
- // 16x16 hit area, which is WCAG's target-spacing exception:
117
- // the neighbouring diamonds are themselves the reason it
118
- // cannot grow, and stealing their clicks is the worse failure.
119
- className={`absolute flex items-center justify-center rounded opacity-0 transition-opacity group-hover:opacity-100 focus-visible:opacity-100 ${roomForFullTarget ? "before:absolute before:left-1/2 before:top-1/2 before:h-6 before:w-6 before:-translate-x-1/2 before:-translate-y-1/2 before:content-['']" : ""}`}
120
- style={{
121
- left: "50%",
122
- top: "50%",
123
- width: 16,
124
- height: 16,
125
- transform: "translate(-50%, -50%)",
126
- zIndex: 3,
127
- pointerEvents: "auto",
128
- padding: 0,
129
- border: "1px solid rgba(255, 255, 255, 0.14)",
130
- background: "#171717",
131
- cursor: "pointer",
132
- }}
133
- onPointerDown={(e) => {
134
- pressXRef.current = e.clientX;
135
- }}
136
- onClick={(e) => {
137
- e.stopPropagation();
138
- const pressX = pressXRef.current;
139
- pressXRef.current = null;
140
- if (
141
- pressX !== null &&
142
- Math.abs(e.clientX - pressX) >= KEYFRAME_DRAG_THRESHOLD_PX
143
- ) {
144
- return;
145
- }
146
- onSelectSegment(target);
147
- }}
148
- >
149
- <MiniCurveSvg ease={ease} active size={12} />
150
- </button>
151
- </div>
67
+ {onSelectSegment && showsEaseControl(kf) && (
68
+ <SegmentEaseControl
69
+ left={x1}
70
+ width={x2 - x1}
71
+ centerY={centerY}
72
+ ease={kf.ease ?? globalEase}
73
+ target={keyframeTarget(kf)}
74
+ // connectorWidth is the clear span between the two diamonds'
75
+ // edges, so a 24x24 target centred in it overhangs a diamond as
76
+ // soon as the span is narrower than 24. The segment wrapper sits
77
+ // at z-index 3, above the diamonds, so that overhang would win
78
+ // the hit test and steal their clicks at fit zoom. Grow the
79
+ // target only where the room exists.
80
+ roomForFullTarget={connectorWidth >= 24}
81
+ onSelectSegment={onSelectSegment}
82
+ />
152
83
  )}
153
84
  </Fragment>
154
85
  );
@@ -156,3 +87,105 @@ export function TimelineDiamondConnectors({
156
87
  </>
157
88
  );
158
89
  }
90
+
91
+ /**
92
+ * The ease control targets one segment, so it needs the keyframe's own
93
+ * animationId. A merged keyframe now shows one too: the editor edits every
94
+ * colliding tween together, so one button standing for several curves is
95
+ * honest. Only a keyframe with no source animation id (runtime-scanned) is left
96
+ * out, because there is no tween to target.
97
+ */
98
+ function showsEaseControl(kf: TimelineDiamondKeyframe): boolean {
99
+ return kf.animationId !== undefined;
100
+ }
101
+
102
+ /**
103
+ * The ease button centred on one connector segment, plus the transparent
104
+ * wrapper that positions it. Split out of the connector map so that map stays a
105
+ * geometry loop and this keeps the press guard, hit-target sizing and click
106
+ * filtering together.
107
+ */
108
+ function SegmentEaseControl({
109
+ left,
110
+ width,
111
+ centerY,
112
+ ease,
113
+ target,
114
+ roomForFullTarget,
115
+ onSelectSegment,
116
+ }: {
117
+ left: number;
118
+ width: number;
119
+ centerY: number;
120
+ ease: string;
121
+ target: TimelineKeyframeTarget;
122
+ roomForFullTarget: boolean;
123
+ onSelectSegment: (target: TimelineKeyframeTarget) => void;
124
+ }) {
125
+ // The ease button sits dead centre of its segment, which on a two-keyframe clip
126
+ // is the centre of the clip bar, the natural place to grab a clip and drag it.
127
+ // Swallowing pointerdown there made that grab a no-op. Instead the press falls
128
+ // through to the clip (so the drag starts normally) and the button keeps only
129
+ // the click, which we drop if the pointer actually travelled.
130
+ const pressXRef = useRef<number | null>(null);
131
+ return (
132
+ <div
133
+ className="group absolute"
134
+ data-keyframe-ease-segment=""
135
+ style={{
136
+ left,
137
+ top: centerY,
138
+ width,
139
+ height: 18,
140
+ transform: "translateY(-50%)",
141
+ // Own a stacking context above the diamond buttons. At fit zoom the 16px
142
+ // ease control can overlap its neighbouring diamond; without a z-index
143
+ // here the later diamond wins the hit test even though the child button
144
+ // has z-index 3.
145
+ zIndex: 3,
146
+ // Only the centered control is interactive. The transparent segment
147
+ // wrapper must not swallow connector/clip gestures.
148
+ pointerEvents: "none",
149
+ }}
150
+ >
151
+ <button
152
+ type="button"
153
+ data-keyframe-ease-button=""
154
+ aria-label={`Edit ${ease} easing`}
155
+ title={`Edit ${ease} easing`}
156
+ // A visible 24x24 badge would collide with the diamonds either side, so
157
+ // the WCAG 2.2 (2.5.8) target is met with a centered transparent
158
+ // ::before overlay; the box stays 16x16. Where the segment is too narrow
159
+ // for that overlay the button keeps its 16x16 hit area, which is WCAG's
160
+ // target-spacing exception: the neighbouring diamonds are themselves the
161
+ // reason it cannot grow, and stealing their clicks is the worse failure.
162
+ className={`absolute flex items-center justify-center rounded opacity-0 transition-opacity group-hover:opacity-100 focus-visible:opacity-100 ${roomForFullTarget ? "before:absolute before:left-1/2 before:top-1/2 before:h-6 before:w-6 before:-translate-x-1/2 before:-translate-y-1/2 before:content-['']" : ""}`}
163
+ style={{
164
+ left: "50%",
165
+ top: "50%",
166
+ width: 16,
167
+ height: 16,
168
+ transform: "translate(-50%, -50%)",
169
+ zIndex: 3,
170
+ pointerEvents: "auto",
171
+ padding: 0,
172
+ border: "1px solid rgba(255, 255, 255, 0.14)",
173
+ background: "#171717",
174
+ cursor: "pointer",
175
+ }}
176
+ onPointerDown={(e) => {
177
+ pressXRef.current = e.clientX;
178
+ }}
179
+ onClick={(e) => {
180
+ e.stopPropagation();
181
+ const pressX = pressXRef.current;
182
+ pressXRef.current = null;
183
+ if (pressX !== null && Math.abs(e.clientX - pressX) >= KEYFRAME_DRAG_THRESHOLD_PX) return;
184
+ onSelectSegment(target);
185
+ }}
186
+ >
187
+ <MiniCurveSvg ease={ease} active size={12} />
188
+ </button>
189
+ </div>
190
+ );
191
+ }
@@ -4,6 +4,7 @@
4
4
  * keyframe-identity helper live here; the rendering lives there.
5
5
  */
6
6
  import type { TimelineKeyframeTarget } from "./timelineKeyframeIdentity";
7
+ import type { AnimationKeyframeTarget } from "../../hooks/gsapTweenSynth";
7
8
 
8
9
  export interface TimelineDiamondKeyframe {
9
10
  percentage: number;
@@ -13,9 +14,8 @@ export interface TimelineDiamondKeyframe {
13
14
  animationId?: string;
14
15
  properties: Record<string, number | string>;
15
16
  ease?: string;
16
- /** Set when 2+ source animations collide at this percentage (a single inline
17
- * ease button can't target one): the collapsed row hides the button here. */
18
- easeAmbiguous?: boolean;
17
+ /** Source animation/keyframe targets that collide at this clip percentage. */
18
+ collidingAnimationTargets?: AnimationKeyframeTarget[];
19
19
  }
20
20
 
21
21
  interface KeyframeCacheEntry {
@@ -116,5 +116,6 @@ export function keyframeTarget(keyframe: TimelineDiamondKeyframe): TimelineKeyfr
116
116
  tweenPercentage: keyframe.tweenPercentage,
117
117
  propertyGroup: keyframe.propertyGroup,
118
118
  animationId: keyframe.animationId,
119
+ collidingAnimationTargets: keyframe.collidingAnimationTargets,
119
120
  };
120
121
  }
@@ -1,8 +1,11 @@
1
+ import type { AnimationKeyframeTarget } from "../../hooks/gsapTweenSynth";
2
+
1
3
  export interface TimelineKeyframeTarget {
2
4
  percentage: number;
3
5
  tweenPercentage?: number;
4
6
  propertyGroup?: string;
5
7
  animationId?: string;
8
+ collidingAnimationTargets?: AnimationKeyframeTarget[];
6
9
  }
7
10
 
8
11
  /**
@@ -36,72 +36,92 @@ const FLAT_TWEEN_TARGET: TimelineKeyframeTarget = {
36
36
  animationId: "position-tween",
37
37
  };
38
38
 
39
+ const COLLIDING_TARGET: TimelineKeyframeTarget = {
40
+ ...FLAT_TWEEN_TARGET,
41
+ collidingAnimationTargets: [
42
+ { animationId: "position-tween", tweenPercentage: 100 },
43
+ { animationId: "scale-tween", tweenPercentage: 75 },
44
+ ],
45
+ };
46
+
39
47
  afterEach(() => {
40
48
  document.body.innerHTML = "";
41
49
  trackStudioSegmentEaseEdit.mockClear();
42
50
  usePlayerStore.setState({ focusedEaseSegment: null });
43
51
  });
44
52
 
45
- describe("useTimelineKeyframeHandlers", () => {
46
- it("tracks opening the segment ease editor when a timeline segment is selected", () => {
47
- let onSelectSegment: ((elementId: string, target: TimelineKeyframeTarget) => void) | undefined;
48
-
49
- function Harness() {
50
- ({ onSelectSegment } = useTimelineKeyframeHandlers({
53
+ /**
54
+ * Mount the hook on its own and hand back the handlers it returned, with the
55
+ * options every test shares already filled in. Each test overrides only the
56
+ * inputs its assertion is about.
57
+ */
58
+ function mountHandlers(options: Partial<Parameters<typeof useTimelineKeyframeHandlers>[0]> = {}) {
59
+ const handlers: Partial<ReturnType<typeof useTimelineKeyframeHandlers>> = {};
60
+
61
+ function Harness() {
62
+ Object.assign(
63
+ handlers,
64
+ useTimelineKeyframeHandlers({
51
65
  expandedElements: [ELEMENT],
52
66
  keyframeCache: new Map(),
53
67
  setSelectedElementId: vi.fn(),
54
68
  setKfContextMenu: vi.fn(),
55
69
  toggleSelectedKeyframe: vi.fn(),
56
- }));
57
- return null;
58
- }
70
+ ...options,
71
+ }),
72
+ );
73
+ return null;
74
+ }
75
+
76
+ return { root: mountReactHarness(<Harness />), handlers };
77
+ }
59
78
 
60
- const root = mountReactHarness(<Harness />);
61
- act(() => onSelectSegment?.(ELEMENT.id, TARGET));
79
+ describe("useTimelineKeyframeHandlers", () => {
80
+ it("tracks opening the segment ease editor when a timeline segment is selected", () => {
81
+ const { root, handlers } = mountHandlers();
82
+ act(() => handlers.onSelectSegment?.(ELEMENT.id, TARGET));
62
83
 
63
84
  expect(trackStudioSegmentEaseEdit).toHaveBeenCalledOnce();
64
85
  expect(trackStudioSegmentEaseEdit).toHaveBeenCalledWith({ action: "open" });
65
86
  act(() => root.unmount());
66
87
  });
67
88
 
89
+ it("focuses a merged segment with its colliding animation targets", () => {
90
+ const { root, handlers } = mountHandlers();
91
+ act(() => handlers.onSelectSegment?.(ELEMENT.id, COLLIDING_TARGET));
92
+
93
+ expect(usePlayerStore.getState().focusedEaseSegment).toEqual({
94
+ animationId: "position-tween",
95
+ collidingAnimationTargets: [
96
+ { animationId: "position-tween", tweenPercentage: 100 },
97
+ { animationId: "scale-tween", tweenPercentage: 75 },
98
+ ],
99
+ tweenPercentage: 100,
100
+ elementId: ELEMENT.id,
101
+ });
102
+ act(() => root.unmount());
103
+ });
104
+
68
105
  it("focuses a flat tween segment without seeking, while keyframe clicks still seek", () => {
69
106
  const onSeek = vi.fn();
70
107
  const onSelectElement = vi.fn();
71
108
  const setSelectedElementId = vi.fn();
72
- let onClickKeyframe:
73
- | ((el: TimelineElement, target: TimelineKeyframeTarget) => void)
74
- | undefined;
75
- let onSelectSegment: ((elementId: string, target: TimelineKeyframeTarget) => void) | undefined;
76
-
77
- function Harness() {
78
- ({ onClickKeyframe, onSelectSegment } = useTimelineKeyframeHandlers({
79
- expandedElements: [ELEMENT],
80
- keyframeCache: new Map(),
81
- onSelectElement,
82
- onSeek,
83
- setSelectedElementId,
84
- setKfContextMenu: vi.fn(),
85
- toggleSelectedKeyframe: vi.fn(),
86
- }));
87
- return null;
88
- }
89
-
90
- const root = mountReactHarness(<Harness />);
109
+ const { root, handlers } = mountHandlers({ onSelectElement, onSeek, setSelectedElementId });
91
110
 
92
111
  // Selecting a segment must NOT move the playhead.
93
- act(() => onSelectSegment?.(ELEMENT.id, FLAT_TWEEN_TARGET));
112
+ act(() => handlers.onSelectSegment?.(ELEMENT.id, FLAT_TWEEN_TARGET));
94
113
  expect(onSeek).not.toHaveBeenCalled();
95
114
  expect(usePlayerStore.getState().focusedEaseSegment).toEqual({
96
115
  animationId: "position-tween",
97
116
  tweenPercentage: 100,
98
117
  elementId: ELEMENT.id,
99
118
  });
119
+ expect(usePlayerStore.getState().focusedEaseSegment?.collidingAnimationTargets).toBeUndefined();
100
120
  expect(setSelectedElementId).toHaveBeenCalledWith(ELEMENT.id);
101
121
  expect(onSelectElement).toHaveBeenCalledWith(ELEMENT);
102
122
 
103
123
  // Clicking the keyframe itself still seeks to it (start 1 + 50% of 2 = 2).
104
- act(() => onClickKeyframe?.(ELEMENT, TARGET));
124
+ act(() => handlers.onClickKeyframe?.(ELEMENT, TARGET));
105
125
  expect(onSeek).toHaveBeenCalledExactlyOnceWith(2);
106
126
  act(() => root.unmount());
107
127
  });
@@ -65,6 +65,7 @@ export function useTimelineKeyframeHandlers({
65
65
  if (target.animationId !== undefined && target.tweenPercentage !== undefined) {
66
66
  usePlayerStore.getState().setFocusedEaseSegment({
67
67
  animationId: target.animationId,
68
+ collidingAnimationTargets: target.collidingAnimationTargets,
68
69
  tweenPercentage: target.tweenPercentage,
69
70
  elementId: elId,
70
71
  });
@@ -221,6 +221,35 @@ describe("buildExpandedElements", () => {
221
221
  expect(child.key).toBe(expectedStoreKey);
222
222
  });
223
223
 
224
+ // Regression: a child row is built from a manifest clip, which carries none of
225
+ // the host element's attributes. Reading hidden off the manifest left every
226
+ // expanded row reporting itself visible, so the eye wrote data-hidden a second
227
+ // time instead of removing it and the element could never be shown again.
228
+ it("inherits hidden and locked state from the flat store element", () => {
229
+ const elements = [
230
+ el({ id: "s1", domId: "s1", start: 0, duration: 14 }),
231
+ el({
232
+ id: "eyebrow",
233
+ key: "index.html#eyebrow",
234
+ domId: "eyebrow",
235
+ start: 0,
236
+ duration: 14,
237
+ hidden: true,
238
+ timelineLocked: true,
239
+ }),
240
+ ];
241
+ const manifest = [
242
+ clip({ id: "s1", start: 0, duration: 14 }),
243
+ clip({ id: "eyebrow", start: 0, duration: 14 }),
244
+ ];
245
+ const parentMap = new Map([["eyebrow", "s1"]]);
246
+
247
+ const out = buildExpandedElements(elements, manifest, parentMap, "s1", "s1");
248
+ const child = out.find((e) => e.domId === "eyebrow")!;
249
+ expect(child.hidden).toBe(true);
250
+ expect(child.timelineLocked).toBe(true);
251
+ });
252
+
224
253
  // Sub-comp internals (group + pills) have no data-start, so they're not in the
225
254
  // manifest. They arrive as DOM children and must still expand under their host.
226
255
  it("expands DOM-only sub-comp children (no manifest clip) under the host", () => {
@@ -134,6 +134,27 @@ interface DisplayBounds {
134
134
  track: number;
135
135
  }
136
136
 
137
+ /**
138
+ * State that lives on the live host element, not in the clip manifest:
139
+ * `data-hidden`, `data-timeline-locked`, `data-timeline-role`. A child row is
140
+ * built from a manifest clip with no hostEl to read, so
141
+ * createTimelineElementFromManifestClip cannot see any of it. The flat store
142
+ * element for the same child WAS built with one, so it is inherited from there.
143
+ *
144
+ * Without this the eye on an expanded child always reported the row visible, so
145
+ * clicking it wrote data-hidden again instead of removing it, and a hidden child
146
+ * could never be shown again (not even after a reload, since the attribute is in
147
+ * the source).
148
+ */
149
+ function hostElementState(flat: TimelineElement | undefined): Partial<TimelineElement> {
150
+ if (!flat) return {};
151
+ return {
152
+ hidden: flat.hidden,
153
+ timelineLocked: flat.timelineLocked,
154
+ timelineRole: flat.timelineRole,
155
+ };
156
+ }
157
+
137
158
  // `display` bounds come from the top-level scene clip (where the expanded row is
138
159
  // drawn). `editBasis` comes from the child's immediate sub-comp host: its absolute
139
160
  // start anchors local-time edits and its compositionSrc is the file edits write to.
@@ -143,6 +164,7 @@ function buildChildElements(
143
164
  display: DisplayBounds,
144
165
  editBasis: { start: number; sourceFile: string | undefined },
145
166
  expandedHostKey: string,
167
+ elements: readonly TimelineElement[],
146
168
  ): TimelineElement[] {
147
169
  const result: TimelineElement[] = [];
148
170
  for (const child of siblings) {
@@ -170,6 +192,7 @@ function buildChildElements(
170
192
  });
171
193
  result.push({
172
194
  ...base,
195
+ ...hostElementState(elements.find((element) => element.key === key)),
173
196
  key,
174
197
  start: clamped.start,
175
198
  duration: clamped.duration,
@@ -281,6 +304,7 @@ export function buildExpandedElements(
281
304
  },
282
305
  editBasis,
283
306
  parentKey,
307
+ elements,
284
308
  );
285
309
  if (expanded.length === 0) return filterToTopLevel(elements, parentMap);
286
310
 
@@ -1,5 +1,6 @@
1
1
  import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
2
2
  import type { StoreApi } from "zustand";
3
+ import type { AnimationKeyframeTarget } from "../../hooks/gsapTweenSynth";
3
4
 
4
5
  /** Minimal keyframe cache types — mirrors GsapKeyframesData without pulling in Node-only gsap-parser. */
5
6
  export interface KeyframeCacheEntry {
@@ -14,9 +15,8 @@ export interface KeyframeCacheEntry {
14
15
  animationId?: string;
15
16
  properties: Record<string, number | string>;
16
17
  ease?: string;
17
- /** Set when 2+ source animations collide at this percentage (a single inline
18
- * ease button can't target one): the collapsed row hides the button here. */
19
- easeAmbiguous?: boolean;
18
+ /** Source animation/keyframe targets that collide at this clip percentage. */
19
+ collidingAnimationTargets?: AnimationKeyframeTarget[];
20
20
  }>;
21
21
  ease?: string;
22
22
  easeEach?: string;
@@ -37,9 +37,19 @@ export interface KeyframeSlice {
37
37
 
38
38
  /** elementId scopes the request to one element so a shared (class-selector)
39
39
  * animation id can't open the ease editor on the wrong element. */
40
- focusedEaseSegment: { animationId: string; tweenPercentage: number; elementId: string } | null;
40
+ focusedEaseSegment: {
41
+ animationId: string;
42
+ collidingAnimationTargets?: AnimationKeyframeTarget[];
43
+ tweenPercentage: number;
44
+ elementId: string;
45
+ } | null;
41
46
  setFocusedEaseSegment: (
42
- target: { animationId: string; tweenPercentage: number; elementId: string } | null,
47
+ target: {
48
+ animationId: string;
49
+ collidingAnimationTargets?: AnimationKeyframeTarget[];
50
+ tweenPercentage: number;
51
+ elementId: string;
52
+ } | null,
43
53
  ) => void;
44
54
 
45
55
  /** Keyframe data per element id, populated from parsed GSAP animations. */