@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.
- package/dist/assets/{hyperframes-player-mFah2TZE.js → hyperframes-player-Csai0_vh.js} +1 -1
- package/dist/assets/{index-Bqj3h_1a.js → index-B5aYTg-4.js} +1 -1
- package/dist/assets/{index-DlZMDyYs.js → index-BhdJN49y.js} +200 -200
- package/dist/assets/{index-hmnoiSEV.js → index-By-YiRxq.js} +1 -1
- package/dist/assets/index-D379YOKT.css +1 -0
- package/dist/index.d.ts +45 -3
- package/dist/index.html +2 -2
- package/dist/index.js +652 -465
- package/dist/index.js.map +1 -1
- package/package.json +7 -7
- package/src/components/StudioRightPanel.tsx +2 -1
- package/src/components/editor/AnimationCard.test.tsx +225 -18
- package/src/components/editor/AnimationCard.tsx +24 -3
- package/src/components/editor/EaseCurveSection.test.tsx +147 -2
- package/src/components/editor/EaseCurveSection.tsx +77 -15
- package/src/components/editor/GsapAnimationSection.test.tsx +102 -0
- package/src/components/editor/GsapAnimationSection.tsx +6 -1
- package/src/components/editor/KeyframeEaseList.tsx +4 -0
- package/src/components/editor/MotionPathOverlay.tsx +54 -19
- package/src/components/editor/PropertyPanel.tsx +4 -0
- package/src/components/editor/PropertyPanelFlat.tsx +6 -91
- package/src/components/editor/gsapAnimationCallbacks.test.ts +8 -1
- package/src/components/editor/gsapAnimationCallbacks.ts +8 -0
- package/src/components/editor/holdEaseSeek.test.ts +37 -0
- package/src/components/editor/propertyPanelFlatMotionSection.test.tsx +69 -0
- package/src/components/editor/propertyPanelFlatMotionSection.tsx +2 -1
- package/src/components/editor/propertyPanelFlatProps.ts +91 -0
- package/src/components/editor/propertyPanelTypes.ts +2 -0
- package/src/contexts/DomEditContext.tsx +4 -0
- package/src/hooks/gsapKeyframeCacheHelpers.test.ts +132 -43
- package/src/hooks/gsapKeyframeCacheHelpers.ts +150 -50
- package/src/hooks/gsapTweenSynth.test.ts +70 -15
- package/src/hooks/gsapTweenSynth.ts +50 -23
- package/src/hooks/keyframeCacheAstLoad.ts +4 -12
- package/src/hooks/useDomEditSession.test.tsx +152 -3
- package/src/hooks/useDomEditSession.ts +4 -53
- package/src/hooks/useGsapTweenCache.ts +40 -34
- package/src/hooks/useKeyframeEaseCommits.ts +78 -0
- package/src/player/components/KeyframeDiamondContextMenu.test.tsx +21 -0
- package/src/player/components/KeyframeDiamondContextMenu.tsx +19 -12
- package/src/player/components/TimelineClipDiamonds.test.tsx +18 -6
- package/src/player/components/TimelineDiamondConnectors.tsx +118 -85
- package/src/player/components/timelineDiamondTypes.ts +4 -3
- package/src/player/components/timelineKeyframeIdentity.ts +3 -0
- package/src/player/components/useTimelineKeyframeHandlers.test.tsx +52 -32
- package/src/player/components/useTimelineKeyframeHandlers.ts +1 -0
- package/src/player/hooks/useExpandedTimelineElements.test.ts +29 -0
- package/src/player/hooks/useExpandedTimelineElements.ts +24 -0
- package/src/player/store/keyframeSlice.ts +15 -5
- 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 &&
|
|
88
|
-
<
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
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
|
-
/**
|
|
17
|
-
|
|
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
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
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
|
-
|
|
58
|
-
|
|
70
|
+
...options,
|
|
71
|
+
}),
|
|
72
|
+
);
|
|
73
|
+
return null;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
return { root: mountReactHarness(<Harness />), handlers };
|
|
77
|
+
}
|
|
59
78
|
|
|
60
|
-
|
|
61
|
-
|
|
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
|
-
|
|
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
|
-
/**
|
|
18
|
-
|
|
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: {
|
|
40
|
+
focusedEaseSegment: {
|
|
41
|
+
animationId: string;
|
|
42
|
+
collidingAnimationTargets?: AnimationKeyframeTarget[];
|
|
43
|
+
tweenPercentage: number;
|
|
44
|
+
elementId: string;
|
|
45
|
+
} | null;
|
|
41
46
|
setFocusedEaseSegment: (
|
|
42
|
-
target: {
|
|
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. */
|