@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.
- package/dist/assets/{hyperframes-player-BrfzYqX3.js → hyperframes-player-CEggaaxR.js} +1 -1
- package/dist/assets/{index-BvGKYVrH.js → index-B1INQNmj.js} +1 -1
- package/dist/assets/{index-CjGZkNxS.js → index-B1Tjjdse.js} +1 -1
- package/dist/assets/index-Bp4jAYZG.js +428 -0
- package/dist/assets/index-CBAfprvx.css +1 -0
- package/dist/index.d.ts +64 -17
- package/dist/index.html +2 -2
- package/dist/index.js +18040 -16293
- package/dist/index.js.map +1 -1
- package/package.json +7 -7
- package/src/App.tsx +16 -17
- package/src/components/EditorShell.tsx +3 -0
- package/src/components/TimelineToolbar.test.tsx +48 -3
- package/src/components/TimelineToolbar.tsx +89 -36
- package/src/components/editor/AnimationCard.test.tsx +18 -75
- package/src/components/editor/AnimationCard.tsx +26 -2
- package/src/components/editor/GsapAddAnimationControl.tsx +68 -0
- package/src/components/editor/GsapAnimationSection.tsx +18 -157
- package/src/components/editor/KeyframeEaseList.tsx +5 -1
- package/src/components/editor/KeyframeNavigation.tsx +46 -14
- package/src/components/editor/KeyframeNavigationDiamond.test.tsx +59 -0
- package/src/components/editor/MotionPathOverlay.tsx +32 -10
- package/src/components/editor/PropertyPanel.tsx +15 -5
- package/src/components/editor/PropertyPanelFlat.tsx +48 -23
- package/src/components/editor/gsapAnimationCallbacks.test.ts +122 -0
- package/src/components/editor/gsapAnimationCallbacks.ts +108 -4
- package/src/components/editor/keyframeRetime.test.ts +25 -22
- package/src/components/editor/keyframeRetime.ts +1 -2
- package/src/components/editor/propertyPanelFlatLayoutSection.tsx +2 -2
- package/src/components/editor/propertyPanelFlatMotionSection.tsx +25 -139
- package/src/components/editor/propertyPanelFlatTextSection.test.tsx +32 -16
- package/src/components/editor/propertyPanelFlatTextSection.tsx +21 -4
- package/src/components/nle/PreviewOverlays.tsx +3 -1
- package/src/components/nle/useTimelineEditCallbacks.test.ts +30 -0
- package/src/components/nle/useTimelineEditCallbacks.test.tsx +693 -0
- package/src/components/nle/useTimelineEditCallbacks.ts +197 -57
- package/src/contexts/TimelineEditContext.tsx +1 -1
- package/src/hooks/deleteSelectedKeyframes.ts +24 -8
- package/src/hooks/gsapDragCommit.ts +2 -2
- package/src/hooks/gsapDragPositionCommit.ts +84 -5
- package/src/hooks/gsapKeyframeCacheHelpers.test.ts +176 -1
- package/src/hooks/gsapKeyframeCacheHelpers.ts +93 -52
- package/src/hooks/gsapRuntimeBridge.test.ts +100 -0
- package/src/hooks/gsapScriptCommitHelpers.ts +3 -2
- package/src/hooks/gsapScriptCommitTypes.ts +2 -0
- package/src/hooks/gsapShared.test.ts +109 -1
- package/src/hooks/gsapShared.ts +142 -17
- package/src/hooks/gsapTweenSynth.test.ts +30 -1
- package/src/hooks/gsapTweenSynth.ts +61 -14
- package/src/hooks/keyframeCacheAstLoad.ts +181 -0
- package/src/hooks/timelineEditingHelpers.test.ts +80 -0
- package/src/hooks/timelineMoveAdapter.ts +9 -2
- package/src/hooks/useDomEditWiring.ts +2 -2
- package/src/hooks/useDomSelection.ts +4 -0
- package/src/hooks/useEnableKeyframes.test.ts +122 -0
- package/src/hooks/useEnableKeyframes.ts +116 -56
- package/src/hooks/useGestureCommit.ts +2 -2
- package/src/hooks/useGsapAnimationFetchFallback.ts +1 -1
- package/src/hooks/useGsapKeyframeOps.test.tsx +92 -13
- package/src/hooks/useGsapKeyframeOps.ts +30 -13
- package/src/hooks/useGsapScriptCommits.test.tsx +21 -0
- package/src/hooks/useGsapScriptCommits.ts +2 -0
- package/src/hooks/useGsapSelectionHandlers.test.tsx +69 -3
- package/src/hooks/useGsapSelectionHandlers.ts +90 -65
- package/src/hooks/useGsapTweenCache.test.ts +16 -0
- package/src/hooks/useGsapTweenCache.ts +64 -178
- package/src/hooks/usePopulateKeyframeCacheForFile.test.tsx +68 -0
- package/src/hooks/useStudioContextValue.test.ts +93 -0
- package/src/hooks/useStudioContextValue.ts +17 -3
- package/src/hooks/useStudioTestHooks.ts +63 -0
- package/src/hooks/useTimelineEditing.test.tsx +187 -22
- package/src/hooks/useTimelineEditing.ts +43 -32
- package/src/hooks/useTimelineEditingTypes.ts +2 -0
- package/src/hooks/useTimelineGroupEditing.ts +106 -82
- package/src/player/components/KeyframeDiamondContextMenu.test.tsx +69 -0
- package/src/player/components/KeyframeDiamondContextMenu.tsx +19 -8
- package/src/player/components/LayerDisclosureRow.tsx +76 -0
- package/src/player/components/Timeline.test.ts +420 -90
- package/src/player/components/Timeline.tsx +80 -78
- package/src/player/components/TimelineCanvas.tsx +33 -21
- package/src/player/components/TimelineClip.tsx +3 -1
- package/src/player/components/TimelineClipDiamonds.test.tsx +540 -16
- package/src/player/components/TimelineClipDiamonds.tsx +320 -117
- package/src/player/components/TimelineDiamondConnectors.tsx +145 -0
- package/src/player/components/TimelineLanes.tsx +153 -79
- package/src/player/components/TimelineOverlays.tsx +4 -18
- package/src/player/components/TimelinePropertyLanes.test.tsx +450 -0
- package/src/player/components/TimelinePropertyLanes.tsx +191 -0
- package/src/player/components/TimelineRazorInteraction.tsx +60 -0
- package/src/player/components/TimelineRuler.tsx +11 -21
- package/src/player/components/TimelineTrackHeader.test.tsx +346 -0
- package/src/player/components/TimelineTrackHeader.tsx +363 -0
- package/src/player/components/TrackClipCount.tsx +17 -0
- package/src/player/components/timelineCallbacks.ts +21 -6
- package/src/player/components/timelineClipDragPreview.test.ts +50 -3
- package/src/player/components/timelineClipDragPreview.ts +33 -15
- package/src/player/components/timelineCollision.ts +4 -13
- package/src/player/components/timelineDiamondTypes.ts +120 -0
- package/src/player/components/timelineDragDrop.ts +8 -3
- package/src/player/components/timelineEditing.test.ts +19 -27
- package/src/player/components/timelineEditing.ts +7 -8
- package/src/player/components/timelineKeyframeIdentity.test.ts +49 -0
- package/src/player/components/timelineKeyframeIdentity.ts +66 -0
- package/src/player/components/timelineLayout.test.ts +162 -9
- package/src/player/components/timelineLayout.ts +198 -38
- package/src/player/components/timelineMarquee.test.ts +76 -21
- package/src/player/components/timelineMarquee.ts +17 -14
- package/src/player/components/trackHeaderLaneState.ts +121 -0
- package/src/player/components/trackHeaderLaneValues.ts +139 -0
- package/src/player/components/useAutoExpandKeyframedClips.test.tsx +75 -0
- package/src/player/components/useAutoExpandKeyframedClips.ts +50 -0
- package/src/player/components/useTimelineClipDrag.ts +4 -1
- package/src/player/components/useTimelineGeometry.ts +4 -1
- package/src/player/components/useTimelineKeyframeHandlers.test.tsx +108 -0
- package/src/player/components/useTimelineKeyframeHandlers.ts +50 -15
- package/src/player/components/useTimelinePlayhead.ts +21 -12
- package/src/player/components/useTimelineRangeSelection.ts +35 -14
- package/src/player/components/useTimelineTrackLayout.test.ts +53 -0
- package/src/player/components/useTimelineTrackLayout.ts +162 -0
- package/src/player/hooks/useExpandedTimelineElements.test.ts +43 -0
- package/src/player/hooks/useExpandedTimelineElements.ts +21 -12
- package/src/player/lib/timelineElementHelpers.ts +26 -11
- package/src/player/store/keyframeSlice.ts +122 -0
- package/src/player/store/playerStore.test.ts +25 -0
- package/src/player/store/playerStore.ts +7 -43
- package/src/telemetry/events.test.ts +9 -3
- package/src/telemetry/events.ts +16 -7
- package/src/utils/globalTimeCompiler.test.ts +10 -0
- package/src/utils/globalTimeCompiler.ts +3 -3
- package/dist/assets/index-B37rWXo4.css +0 -1
- package/dist/assets/index-DzDajONI.js +0 -428
- package/src/player/components/TimelineDragGhost.tsx +0 -59
- package/src/utils/keyframeSelection.test.ts +0 -45
- 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
|
-
|
|
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
|
|
44
|
-
|
|
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
|
-
|
|
83
|
-
|
|
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
|
-
<
|
|
174
|
-
{addMenuOpen
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
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
|
-
|
|
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
|
|
399
|
-
//
|
|
400
|
-
|
|
401
|
-
|
|
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,
|
|
533
|
+
onDelete={(_elId, keyframe) =>
|
|
534
|
+
animId && handleGsapRemoveKeyframe(animId, keyframe.percentage)
|
|
535
|
+
}
|
|
516
536
|
onDeleteAll={() => animId && handleGsapRemoveAllKeyframes(animId)}
|
|
517
|
-
onMoveToPlayhead={(
|
|
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) =>
|
|
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) =>
|
|
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) =>
|
|
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) =>
|
|
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) =>
|
|
518
|
+
onRemoveKeyframe={(pct, animationId) =>
|
|
519
|
+
onRemoveKeyframe?.(animationId ?? animIdForProp("rotation"), pct)
|
|
520
|
+
}
|
|
511
521
|
onConvertToKeyframes={() => onConvertToKeyframes?.(animIdForProp("rotation"))}
|
|
512
522
|
/>
|
|
513
523
|
)}
|