@hyperframes/studio 0.7.7 → 0.7.9
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/{index-B2YXvFxf.js → index-B7fv-WA3.js} +1 -1
- package/dist/assets/{index-BoASKOeE.js → index-C48wGs63.js} +1 -1
- package/dist/assets/{index-BeRh2hMe.js → index-e4xyxg7-.js} +187 -187
- package/dist/assets/index-svYFaNuq.css +1 -0
- package/dist/index.d.ts +4 -1
- package/dist/index.html +2 -2
- package/dist/index.js +2715 -2061
- package/dist/index.js.map +1 -1
- package/package.json +5 -5
- package/src/components/StudioRightPanel.tsx +5 -1
- package/src/components/editor/DomEditOverlay.tsx +4 -12
- package/src/components/editor/MarqueeOverlay.tsx +40 -0
- package/src/components/editor/OffCanvasIndicators.tsx +2 -7
- package/src/components/editor/PropertyPanel.tsx +12 -0
- package/src/components/editor/Transform3DCube.tsx +313 -0
- package/src/components/editor/gsapAnimationConstants.ts +5 -0
- package/src/components/editor/manualOffsetDrag.test.ts +99 -0
- package/src/components/editor/manualOffsetDrag.ts +38 -7
- package/src/components/editor/marqueeCommit.ts +63 -20
- package/src/components/editor/propertyPanel3dTransform.tsx +311 -92
- package/src/components/editor/propertyPanelHelpers.ts +43 -21
- package/src/components/editor/transform3dProjection.test.ts +99 -0
- package/src/components/editor/transform3dProjection.ts +172 -0
- package/src/components/sidebar/AssetsTab.tsx +23 -213
- package/src/components/sidebar/AudioRow.tsx +202 -0
- package/src/contexts/DomEditContext.tsx +4 -0
- package/src/hooks/gsapDragCommit.test.ts +9 -5
- package/src/hooks/gsapDragCommit.ts +28 -9
- package/src/hooks/gsapRuntimeKeyframes.ts +50 -5
- package/src/hooks/gsapRuntimePatch.ts +162 -35
- package/src/hooks/useAnimatedPropertyCommit.ts +256 -78
- package/src/hooks/useDomEditCommits.ts +0 -2
- package/src/hooks/useDomEditSession.ts +4 -2
- package/src/hooks/useDomGeometryCommits.ts +3 -31
- package/src/hooks/useEnableKeyframes.test.ts +40 -0
- package/src/hooks/useEnableKeyframes.ts +9 -2
- package/src/hooks/useGsapAwareEditing.ts +31 -1
- package/src/hooks/useGsapKeyframeOps.ts +4 -1
- package/src/hooks/useGsapSelectionHandlers.ts +12 -9
- package/src/hooks/useGsapTweenCache.ts +6 -4
- package/src/utils/marqueeGeometry.test.ts +15 -98
- package/src/utils/marqueeGeometry.ts +1 -158
- package/dist/assets/index-BSkUuN8g.css +0 -1
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Unified helper for committing any GSAP property value from the design panel.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
4
|
+
* Routing depends on whether the element is animated (has keyframes on any tween):
|
|
5
|
+
* - Animated → write the value into a keyframe at the current playhead (convert a
|
|
6
|
+
* flat tween first if needed). An existing static `set` auto-converts to keyframes.
|
|
7
|
+
* - Static (no keyframes anywhere) → persist as a `tl.set`, NEVER keyframes — same
|
|
8
|
+
* as manual drag / resize / rotate. Updates an existing set or creates one.
|
|
8
9
|
*/
|
|
9
10
|
import { useCallback } from "react";
|
|
10
11
|
import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
|
|
@@ -12,6 +13,7 @@ import { classifyPropertyGroup } from "@hyperframes/core/gsap-parser";
|
|
|
12
13
|
import type { DomEditSelection } from "../components/editor/domEditingTypes";
|
|
13
14
|
import { usePlayerStore } from "../player/store/playerStore";
|
|
14
15
|
import { readAllAnimatedProperties, readGsapProperty } from "./gsapRuntimeBridge";
|
|
16
|
+
import type { SetPatchProps } from "./gsapRuntimePatch";
|
|
15
17
|
import { selectorFromSelection, computeElementPercentage } from "./gsapShared";
|
|
16
18
|
|
|
17
19
|
interface CommitAnimatedPropertyDeps {
|
|
@@ -47,6 +49,7 @@ function pickBestAnimation(
|
|
|
47
49
|
const currentTime = usePlayerStore.getState().currentTime;
|
|
48
50
|
const targetGroup = property ? classifyPropertyGroup(property) : undefined;
|
|
49
51
|
|
|
52
|
+
// fallow-ignore-next-line complexity
|
|
50
53
|
const scored = animations.map((a) => {
|
|
51
54
|
let score = 0;
|
|
52
55
|
if (targetGroup && a.propertyGroup === targetGroup) score += 20;
|
|
@@ -62,96 +65,271 @@ function pickBestAnimation(
|
|
|
62
65
|
return scored[0]?.anim;
|
|
63
66
|
}
|
|
64
67
|
|
|
68
|
+
/**
|
|
69
|
+
* Auto-keyframe a just-updated static `set`: if the element is already animated
|
|
70
|
+
* (its clip carries keyframes on another tween), convert the set to keyframes so
|
|
71
|
+
* subsequent edits at other playheads interpolate — matching the drag / resize /
|
|
72
|
+
* rotate UX. Purely static elements (no other keyframes) are left as a set.
|
|
73
|
+
*/
|
|
74
|
+
async function maybeAutoKeyframeSet(
|
|
75
|
+
selection: DomEditSelection,
|
|
76
|
+
setAnim: GsapAnimation,
|
|
77
|
+
animations: GsapAnimation[],
|
|
78
|
+
commit: NonNullable<CommitAnimatedPropertyDeps["gsapCommitMutation"]>,
|
|
79
|
+
): Promise<void> {
|
|
80
|
+
const animatedTween = animations.find((a) => a.keyframes && a.id !== setAnim.id);
|
|
81
|
+
if (!animatedTween) return;
|
|
82
|
+
await commit(
|
|
83
|
+
selection,
|
|
84
|
+
{
|
|
85
|
+
type: "convert-to-keyframes",
|
|
86
|
+
animationId: setAnim.id,
|
|
87
|
+
duration: animatedTween.duration ?? 1,
|
|
88
|
+
},
|
|
89
|
+
{ label: "Keyframe 3D transform", softReload: true },
|
|
90
|
+
);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
type Commit = NonNullable<CommitAnimatedPropertyDeps["gsapCommitMutation"]>;
|
|
94
|
+
|
|
95
|
+
/** Merge ALL props into the static `set` in ONE commit (value-only, instant), then
|
|
96
|
+
* auto-keyframe. One mutation — a per-property loop would shift the set's
|
|
97
|
+
* group-derived id mid-way (e.g. reset adding `scale` to a rotation set), 404-ing
|
|
98
|
+
* the next update. */
|
|
99
|
+
async function commitSetProps(
|
|
100
|
+
selection: DomEditSelection,
|
|
101
|
+
setAnim: GsapAnimation,
|
|
102
|
+
propEntries: [string, number | string][],
|
|
103
|
+
selector: string | null,
|
|
104
|
+
animations: GsapAnimation[],
|
|
105
|
+
commit: Commit,
|
|
106
|
+
): Promise<void> {
|
|
107
|
+
const properties = Object.fromEntries(propEntries);
|
|
108
|
+
const numericProps: SetPatchProps = {};
|
|
109
|
+
for (const [k, v] of propEntries) {
|
|
110
|
+
if (typeof v === "number") numericProps[k as keyof SetPatchProps] = v;
|
|
111
|
+
}
|
|
112
|
+
const instantPatch =
|
|
113
|
+
selector && Object.keys(numericProps).length > 0
|
|
114
|
+
? {
|
|
115
|
+
selector,
|
|
116
|
+
change: {
|
|
117
|
+
kind: (setAnim.global ? "global-set" : "set") as "set" | "global-set",
|
|
118
|
+
props: numericProps,
|
|
119
|
+
},
|
|
120
|
+
}
|
|
121
|
+
: undefined;
|
|
122
|
+
await commit(
|
|
123
|
+
selection,
|
|
124
|
+
{ type: "update-properties", animationId: setAnim.id, properties },
|
|
125
|
+
{ label: "Set 3D transform", softReload: true, ...(instantPatch ? { instantPatch } : {}) },
|
|
126
|
+
);
|
|
127
|
+
await maybeAutoKeyframeSet(selection, setAnim, animations, commit);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Static element (no keyframes on ANY of its tweens): persist the 3D props as a
|
|
132
|
+
* `tl.set` — NEVER keyframes. Mirrors manual drag / resize / rotate, which `tl.set`
|
|
133
|
+
* a static element instead of animating it. Updates an existing `set` in place, or
|
|
134
|
+
* creates a dedicated `set` at position 0 when the element has none.
|
|
135
|
+
*/
|
|
136
|
+
async function commitStaticSet(
|
|
137
|
+
selection: DomEditSelection,
|
|
138
|
+
propEntries: [string, number | string][],
|
|
139
|
+
selector: string | null,
|
|
140
|
+
animations: GsapAnimation[],
|
|
141
|
+
commit: Commit,
|
|
142
|
+
): Promise<void> {
|
|
143
|
+
if (!selector) return;
|
|
144
|
+
// Update an existing `set` in ONE batched commit — NEVER a flat `to`/`from`. A
|
|
145
|
+
// set's id is GROUP-derived, so a per-prop loop shifts it the instant a new-group
|
|
146
|
+
// prop lands (e.g. `scale` onto a rotation set), 404-ing the next prop; commitSetProps
|
|
147
|
+
// sends them together. A static element with no set gets a dedicated `set` carrying
|
|
148
|
+
// ALL props in ONE `add`.
|
|
149
|
+
const existingSet = animations.find((a) => a.method === "set" && a.targetSelector === selector);
|
|
150
|
+
if (existingSet) {
|
|
151
|
+
await commitSetProps(selection, existingSet, propEntries, selector, animations, commit);
|
|
152
|
+
return;
|
|
153
|
+
}
|
|
154
|
+
// Base `gsap.set` (off-timeline) — a static hold with no 0% keyframe marker, so
|
|
155
|
+
// adjusting a 3D transform on a non-keyframed element doesn't drop a keyframe on
|
|
156
|
+
// the timeline (matches the manual-drag UX). The global-set instant patch applies
|
|
157
|
+
// it straight to the element so the first edit shows with no soft-reload flash.
|
|
158
|
+
const numericProps: SetPatchProps = {};
|
|
159
|
+
for (const [k, v] of propEntries) {
|
|
160
|
+
if (typeof v === "number") numericProps[k as keyof SetPatchProps] = v;
|
|
161
|
+
}
|
|
162
|
+
await commit(
|
|
163
|
+
selection,
|
|
164
|
+
{
|
|
165
|
+
type: "add",
|
|
166
|
+
targetSelector: selector,
|
|
167
|
+
method: "set",
|
|
168
|
+
position: 0,
|
|
169
|
+
properties: Object.fromEntries(propEntries),
|
|
170
|
+
global: true,
|
|
171
|
+
},
|
|
172
|
+
{
|
|
173
|
+
label: "Set 3D transform",
|
|
174
|
+
softReload: true,
|
|
175
|
+
...(Object.keys(numericProps).length > 0
|
|
176
|
+
? {
|
|
177
|
+
instantPatch: {
|
|
178
|
+
selector,
|
|
179
|
+
change: { kind: "global-set" as const, props: numericProps },
|
|
180
|
+
},
|
|
181
|
+
}
|
|
182
|
+
: {}),
|
|
183
|
+
},
|
|
184
|
+
);
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/** Convert-if-flat, then write ALL props into ONE keyframe at the playhead. */
|
|
188
|
+
// fallow-ignore-next-line complexity
|
|
189
|
+
async function commitKeyframeProps(
|
|
190
|
+
selection: DomEditSelection,
|
|
191
|
+
anim: GsapAnimation,
|
|
192
|
+
props: Record<string, number | string>,
|
|
193
|
+
propEntries: [string, number | string][],
|
|
194
|
+
primaryProp: string,
|
|
195
|
+
selector: string | null,
|
|
196
|
+
iframe: HTMLIFrameElement | null,
|
|
197
|
+
commit: Commit,
|
|
198
|
+
): Promise<void> {
|
|
199
|
+
if (!anim.keyframes) {
|
|
200
|
+
await commit(
|
|
201
|
+
selection,
|
|
202
|
+
{ type: "convert-to-keyframes", animationId: anim.id },
|
|
203
|
+
{ label: "Convert to keyframes", skipReload: true },
|
|
204
|
+
);
|
|
205
|
+
}
|
|
206
|
+
const pct = computeElementPercentage(usePlayerStore.getState().currentTime, selection, anim);
|
|
207
|
+
const runtimeProps = selector ? readAllAnimatedProperties(iframe, selector, anim) : {};
|
|
208
|
+
const properties: Record<string, number | string> = { ...runtimeProps, ...props };
|
|
209
|
+
|
|
210
|
+
const backfillDefaults: Record<string, number | string> = { ...runtimeProps };
|
|
211
|
+
for (const [property, value] of propEntries) {
|
|
212
|
+
if (!(property in runtimeProps) && selector) {
|
|
213
|
+
const cssVal = readGsapProperty(iframe, selector, property);
|
|
214
|
+
if (cssVal != null) backfillDefaults[property] = cssVal;
|
|
215
|
+
}
|
|
216
|
+
backfillDefaults[property] = value;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
const existingKf = anim.keyframes?.keyframes.some((kf) => Math.abs(kf.percentage - pct) < 0.05);
|
|
220
|
+
// Rebuild the live keyframe tween in place so the edit shows instantly (no flash);
|
|
221
|
+
// rebuildKeyframeTween declines → soft reload if the tween can't be safely rebuilt.
|
|
222
|
+
const numericProps: Record<string, number> = {};
|
|
223
|
+
for (const [k, v] of Object.entries(properties)) {
|
|
224
|
+
if (typeof v === "number") numericProps[k] = v;
|
|
225
|
+
}
|
|
226
|
+
const instantPatch =
|
|
227
|
+
selector && Object.keys(numericProps).length > 0
|
|
228
|
+
? { selector, change: { kind: "keyframe-rebuild" as const, pct, props: numericProps } }
|
|
229
|
+
: undefined;
|
|
230
|
+
await commit(
|
|
231
|
+
selection,
|
|
232
|
+
existingKf
|
|
233
|
+
? { type: "update-keyframe", animationId: anim.id, percentage: pct, properties }
|
|
234
|
+
: {
|
|
235
|
+
type: "add-keyframe",
|
|
236
|
+
animationId: anim.id,
|
|
237
|
+
percentage: pct,
|
|
238
|
+
properties,
|
|
239
|
+
backfillDefaults,
|
|
240
|
+
},
|
|
241
|
+
{
|
|
242
|
+
label: `Edit ${primaryProp} (keyframe ${pct}%)`,
|
|
243
|
+
softReload: true,
|
|
244
|
+
...(instantPatch ? { instantPatch } : {}),
|
|
245
|
+
},
|
|
246
|
+
);
|
|
247
|
+
}
|
|
248
|
+
|
|
65
249
|
export function useAnimatedPropertyCommit(deps: CommitAnimatedPropertyDeps) {
|
|
66
|
-
const {
|
|
67
|
-
selectedGsapAnimations,
|
|
68
|
-
gsapCommitMutation,
|
|
69
|
-
addGsapAnimation,
|
|
70
|
-
previewIframeRef,
|
|
71
|
-
bumpGsapCache,
|
|
72
|
-
} = deps;
|
|
250
|
+
const { selectedGsapAnimations, gsapCommitMutation, previewIframeRef, bumpGsapCache } = deps;
|
|
73
251
|
|
|
74
|
-
const
|
|
75
|
-
async (
|
|
76
|
-
selection: DomEditSelection,
|
|
77
|
-
property: string,
|
|
78
|
-
value: number | string,
|
|
79
|
-
): Promise<void> => {
|
|
252
|
+
const commitAnimatedProperties = useCallback(
|
|
253
|
+
async (selection: DomEditSelection, props: Record<string, number | string>): Promise<void> => {
|
|
80
254
|
if (!gsapCommitMutation) return;
|
|
255
|
+
const propEntries = Object.entries(props);
|
|
256
|
+
if (propEntries.length === 0) return;
|
|
257
|
+
const primaryProp = propEntries[0]![0];
|
|
81
258
|
|
|
82
259
|
const iframe = previewIframeRef.current;
|
|
83
260
|
const selector = selectorFromSelection(selection);
|
|
84
261
|
|
|
85
|
-
|
|
262
|
+
const anim: GsapAnimation | undefined = pickBestAnimation(
|
|
86
263
|
selectedGsapAnimations,
|
|
87
264
|
selector,
|
|
88
|
-
|
|
265
|
+
primaryProp,
|
|
89
266
|
);
|
|
267
|
+
// Whether the element is animated at all. A 3D edit only creates/edits
|
|
268
|
+
// keyframes when it IS — a static element (no keyframes on any of its tweens)
|
|
269
|
+
// gets a `tl.set`, never new keyframes (matches manual drag / resize / rotate).
|
|
270
|
+
const elementHasKeyframes = selectedGsapAnimations.some((a) => !!a.keyframes);
|
|
90
271
|
|
|
91
|
-
//
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
//
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
272
|
+
// The picked anim comes from the (possibly stale) panel cache: if keyframes
|
|
273
|
+
// were just removed or the script changed underneath us, its id is gone
|
|
274
|
+
// server-side and the commit 404s. The raw commit already toasts; we catch
|
|
275
|
+
// so the rejection doesn't escape as an uncaught promise, and bump the cache
|
|
276
|
+
// so selectedGsapAnimations re-syncs and the user's next edit self-heals.
|
|
277
|
+
try {
|
|
278
|
+
// Existing static hold — merge the props into the `set`, then auto-keyframe
|
|
279
|
+
// ONLY if the element is already animated (maybeAutoKeyframeSet no-ops if not).
|
|
280
|
+
if (anim?.method === "set") {
|
|
281
|
+
await commitSetProps(
|
|
282
|
+
selection,
|
|
283
|
+
anim,
|
|
284
|
+
propEntries,
|
|
285
|
+
selector,
|
|
286
|
+
selectedGsapAnimations,
|
|
287
|
+
gsapCommitMutation,
|
|
288
|
+
);
|
|
289
|
+
return;
|
|
290
|
+
}
|
|
103
291
|
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
292
|
+
// Static element — persist as a `tl.set`, never keyframes (incl. the
|
|
293
|
+
// no-animation case, which now creates a set instead of a keyframed tween).
|
|
294
|
+
if (!elementHasKeyframes) {
|
|
295
|
+
await commitStaticSet(
|
|
296
|
+
selection,
|
|
297
|
+
propEntries,
|
|
298
|
+
selector,
|
|
299
|
+
selectedGsapAnimations,
|
|
300
|
+
gsapCommitMutation,
|
|
301
|
+
);
|
|
302
|
+
return;
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
// Animated element — write ALL props into ONE keyframe so a multi-axis cube
|
|
306
|
+
// edit doesn't race into adjacent duplicates.
|
|
307
|
+
if (!anim) {
|
|
308
|
+
bumpGsapCache();
|
|
309
|
+
return;
|
|
310
|
+
}
|
|
311
|
+
await commitKeyframeProps(
|
|
107
312
|
selection,
|
|
108
|
-
|
|
109
|
-
|
|
313
|
+
anim,
|
|
314
|
+
props,
|
|
315
|
+
propEntries,
|
|
316
|
+
primaryProp,
|
|
317
|
+
selector,
|
|
318
|
+
iframe,
|
|
319
|
+
gsapCommitMutation,
|
|
110
320
|
);
|
|
321
|
+
} catch {
|
|
322
|
+
bumpGsapCache();
|
|
111
323
|
}
|
|
112
|
-
|
|
113
|
-
const pct = computeElementPercentage(usePlayerStore.getState().currentTime, selection, anim);
|
|
114
|
-
|
|
115
|
-
// Read all currently animated properties from runtime for backfill
|
|
116
|
-
const runtimeProps = selector ? readAllAnimatedProperties(iframe, selector, anim) : {};
|
|
117
|
-
|
|
118
|
-
// Build the properties object: all runtime props + the new value
|
|
119
|
-
const properties: Record<string, number | string> = { ...runtimeProps };
|
|
120
|
-
properties[property] = value;
|
|
121
|
-
|
|
122
|
-
// Compute backfill defaults for properties not in existing keyframes
|
|
123
|
-
const backfillDefaults: Record<string, number | string> = { ...runtimeProps };
|
|
124
|
-
if (!(property in runtimeProps) && selector) {
|
|
125
|
-
const cssVal = readGsapProperty(iframe, selector, property);
|
|
126
|
-
if (cssVal != null) backfillDefaults[property] = cssVal;
|
|
127
|
-
}
|
|
128
|
-
backfillDefaults[property] = typeof value === "number" ? value : value;
|
|
129
|
-
|
|
130
|
-
const existingKf = anim.keyframes?.keyframes.some(
|
|
131
|
-
(kf) => Math.abs(kf.percentage - pct) < 0.05,
|
|
132
|
-
);
|
|
133
|
-
|
|
134
|
-
await gsapCommitMutation(
|
|
135
|
-
selection,
|
|
136
|
-
existingKf
|
|
137
|
-
? {
|
|
138
|
-
type: "update-keyframe",
|
|
139
|
-
animationId: anim.id,
|
|
140
|
-
percentage: pct,
|
|
141
|
-
properties,
|
|
142
|
-
}
|
|
143
|
-
: {
|
|
144
|
-
type: "add-keyframe",
|
|
145
|
-
animationId: anim.id,
|
|
146
|
-
percentage: pct,
|
|
147
|
-
properties,
|
|
148
|
-
backfillDefaults,
|
|
149
|
-
},
|
|
150
|
-
{ label: `Edit ${property} (keyframe ${pct}%)`, softReload: true },
|
|
151
|
-
);
|
|
152
324
|
},
|
|
153
|
-
[selectedGsapAnimations, gsapCommitMutation,
|
|
325
|
+
[selectedGsapAnimations, gsapCommitMutation, previewIframeRef, bumpGsapCache],
|
|
326
|
+
);
|
|
327
|
+
|
|
328
|
+
const commitAnimatedProperty = useCallback(
|
|
329
|
+
(selection: DomEditSelection, property: string, value: number | string) =>
|
|
330
|
+
commitAnimatedProperties(selection, { [property]: value }),
|
|
331
|
+
[commitAnimatedProperties],
|
|
154
332
|
);
|
|
155
333
|
|
|
156
|
-
return commitAnimatedProperty;
|
|
334
|
+
return { commitAnimatedProperty, commitAnimatedProperties };
|
|
157
335
|
}
|
|
@@ -301,7 +301,6 @@ export function useDomEditCommits({
|
|
|
301
301
|
|
|
302
302
|
const {
|
|
303
303
|
handleDomPathOffsetCommit,
|
|
304
|
-
handleDomGroupPathOffsetCommit,
|
|
305
304
|
handleDomBoxSizeCommit,
|
|
306
305
|
handleDomRotationCommit,
|
|
307
306
|
handleDomManualEditsReset,
|
|
@@ -340,7 +339,6 @@ export function useDomEditCommits({
|
|
|
340
339
|
handleDomAddTextField,
|
|
341
340
|
handleDomRemoveTextField,
|
|
342
341
|
handleDomPathOffsetCommit,
|
|
343
|
-
handleDomGroupPathOffsetCommit,
|
|
344
342
|
handleDomBoxSizeCommit,
|
|
345
343
|
handleDomRotationCommit,
|
|
346
344
|
handleDomManualEditsReset,
|
|
@@ -215,7 +215,6 @@ export function useDomEditSession({
|
|
|
215
215
|
handleDomTextFieldStyleCommit,
|
|
216
216
|
handleDomAddTextField,
|
|
217
217
|
handleDomRemoveTextField,
|
|
218
|
-
handleDomGroupPathOffsetCommit,
|
|
219
218
|
handleDomBoxSizeCommit,
|
|
220
219
|
handleDomManualEditsReset,
|
|
221
220
|
handleDomEditElementDelete,
|
|
@@ -368,9 +367,11 @@ export function useDomEditSession({
|
|
|
368
367
|
|
|
369
368
|
const {
|
|
370
369
|
handleGsapAwarePathOffsetCommit,
|
|
370
|
+
handleGsapAwareGroupPathOffsetCommit,
|
|
371
371
|
handleGsapAwareBoxSizeCommit,
|
|
372
372
|
handleGsapAwareRotationCommit,
|
|
373
373
|
commitAnimatedProperty,
|
|
374
|
+
commitAnimatedProperties,
|
|
374
375
|
handleSetArcPath,
|
|
375
376
|
handleUpdateArcSegment,
|
|
376
377
|
handleUnroll,
|
|
@@ -452,7 +453,7 @@ export function useDomEditSession({
|
|
|
452
453
|
handleDomAttributeLiveCommit,
|
|
453
454
|
handleDomHtmlAttributeCommit,
|
|
454
455
|
handleDomPathOffsetCommit: handleGsapAwarePathOffsetCommit,
|
|
455
|
-
handleDomGroupPathOffsetCommit,
|
|
456
|
+
handleDomGroupPathOffsetCommit: handleGsapAwareGroupPathOffsetCommit,
|
|
456
457
|
handleDomZIndexReorderCommit,
|
|
457
458
|
handleDomBoxSizeCommit: handleGsapAwareBoxSizeCommit,
|
|
458
459
|
handleDomRotationCommit: handleGsapAwareRotationCommit,
|
|
@@ -498,6 +499,7 @@ export function useDomEditSession({
|
|
|
498
499
|
handleUpdateKeyframeEase,
|
|
499
500
|
handleSetAllKeyframeEases,
|
|
500
501
|
commitAnimatedProperty,
|
|
502
|
+
commitAnimatedProperties,
|
|
501
503
|
handleSetArcPath,
|
|
502
504
|
handleUpdateArcSegment,
|
|
503
505
|
handleUnroll,
|
|
@@ -16,11 +16,10 @@ import {
|
|
|
16
16
|
buildClearBoxSizePatches,
|
|
17
17
|
buildClearRotationPatches,
|
|
18
18
|
} from "../components/editor/manualEditsDomPatches";
|
|
19
|
-
import type { DomEditGroupPathOffsetCommit } from "../components/editor/DomEditOverlay";
|
|
20
19
|
import type { PatchOperation } from "../utils/sourcePatcher";
|
|
21
20
|
import { isElementGsapTargeted } from "./gsapTargetCache";
|
|
22
21
|
|
|
23
|
-
|
|
22
|
+
const GSAP_CSS_FALLBACK_BLOCKED_MESSAGE =
|
|
24
23
|
"This element is GSAP-animated — dragging via CSS would corrupt keyframes";
|
|
25
24
|
|
|
26
25
|
// ── Hook ──
|
|
@@ -46,7 +45,8 @@ export function useDomGeometryCommits({
|
|
|
46
45
|
// elements fall through to commitPositionPatchToHtml → persistDomEditOperations →
|
|
47
46
|
// onTrySdkPersist and are already SDK-cut-over as setStyle/setAttribute (§3.3 done).
|
|
48
47
|
// Upgrade path for GSAP: add a moveElementGsap SDK op in a separate SDK PR.
|
|
49
|
-
|
|
48
|
+
const gsapTargeted = isElementGsapTargeted(previewIframeRef.current, selection.element);
|
|
49
|
+
if (gsapTargeted) {
|
|
50
50
|
const error = new Error(GSAP_CSS_FALLBACK_BLOCKED_MESSAGE);
|
|
51
51
|
showToast(error.message, "error");
|
|
52
52
|
return Promise.reject(error);
|
|
@@ -60,33 +60,6 @@ export function useDomGeometryCommits({
|
|
|
60
60
|
[commitPositionPatchToHtml, previewIframeRef, showToast],
|
|
61
61
|
);
|
|
62
62
|
|
|
63
|
-
const handleDomGroupPathOffsetCommit = useCallback(
|
|
64
|
-
(updates: DomEditGroupPathOffsetCommit[]) => {
|
|
65
|
-
if (updates.length === 0) return Promise.resolve();
|
|
66
|
-
const blockedUpdate = updates.find(({ selection }) =>
|
|
67
|
-
isElementGsapTargeted(previewIframeRef.current, selection.element),
|
|
68
|
-
);
|
|
69
|
-
if (blockedUpdate) {
|
|
70
|
-
const error = new Error(GSAP_CSS_FALLBACK_BLOCKED_MESSAGE);
|
|
71
|
-
showToast(error.message, "error");
|
|
72
|
-
return Promise.reject(error);
|
|
73
|
-
}
|
|
74
|
-
const coalesceKey = updates
|
|
75
|
-
.map((u) => getDomEditTargetKey(u.selection))
|
|
76
|
-
.sort()
|
|
77
|
-
.join(":");
|
|
78
|
-
const saves = updates.map(({ selection, next }) => {
|
|
79
|
-
applyStudioPathOffset(selection.element, next);
|
|
80
|
-
return commitPositionPatchToHtml(selection, buildPathOffsetPatches(selection.element), {
|
|
81
|
-
label: `Move ${updates.length} layers`,
|
|
82
|
-
coalesceKey: `group-path-offset:${coalesceKey}`,
|
|
83
|
-
});
|
|
84
|
-
});
|
|
85
|
-
return Promise.all(saves).then(() => undefined);
|
|
86
|
-
},
|
|
87
|
-
[commitPositionPatchToHtml, previewIframeRef, showToast],
|
|
88
|
-
);
|
|
89
|
-
|
|
90
63
|
const handleDomBoxSizeCommit = useCallback(
|
|
91
64
|
(selection: DomEditSelection, next: { width: number; height: number }) => {
|
|
92
65
|
if (isElementGsapTargeted(previewIframeRef.current, selection.element)) {
|
|
@@ -142,7 +115,6 @@ export function useDomGeometryCommits({
|
|
|
142
115
|
|
|
143
116
|
return {
|
|
144
117
|
handleDomPathOffsetCommit,
|
|
145
|
-
handleDomGroupPathOffsetCommit,
|
|
146
118
|
handleDomBoxSizeCommit,
|
|
147
119
|
handleDomRotationCommit,
|
|
148
120
|
handleDomManualEditsReset,
|
|
@@ -1,10 +1,13 @@
|
|
|
1
1
|
import { describe, expect, it } from "vitest";
|
|
2
2
|
import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
|
|
3
|
+
import type { DomEditSelection } from "../components/editor/domEditingTypes";
|
|
3
4
|
import {
|
|
4
5
|
animatedProps,
|
|
5
6
|
buildExtendedKeyframes,
|
|
6
7
|
isPlayheadWithinTween,
|
|
8
|
+
promoteSetToKeyframes,
|
|
7
9
|
resolveNewTweenRange,
|
|
10
|
+
type EnableKeyframesSession,
|
|
8
11
|
} from "./useEnableKeyframes";
|
|
9
12
|
|
|
10
13
|
function anim(overrides: Partial<GsapAnimation>): GsapAnimation {
|
|
@@ -128,3 +131,40 @@ describe("buildExtendedKeyframes", () => {
|
|
|
128
131
|
expect(out.keyframes[1]!.percentage).toBeCloseTo(22.7, 1);
|
|
129
132
|
});
|
|
130
133
|
});
|
|
134
|
+
|
|
135
|
+
describe("promoteSetToKeyframes — auto endpoint", () => {
|
|
136
|
+
it("marks the 0% (held start) as `auto`, leaving the 100% (playhead) fixed", async () => {
|
|
137
|
+
let committed: Record<string, unknown> | undefined;
|
|
138
|
+
const session = {
|
|
139
|
+
commitMutation: async (mutation: Record<string, unknown>) => {
|
|
140
|
+
committed = mutation;
|
|
141
|
+
},
|
|
142
|
+
} as unknown as EnableKeyframesSession;
|
|
143
|
+
const sel = {
|
|
144
|
+
id: "card",
|
|
145
|
+
selector: "#card",
|
|
146
|
+
sourceFile: "index.html",
|
|
147
|
+
element: { isConnected: true } as unknown as HTMLElement,
|
|
148
|
+
} as unknown as DomEditSelection;
|
|
149
|
+
// readElementPosition reads gsap.getProperty off the iframe window.
|
|
150
|
+
const iframe = {
|
|
151
|
+
contentWindow: { gsap: { getProperty: () => -74 } },
|
|
152
|
+
} as unknown as HTMLIFrameElement;
|
|
153
|
+
const setAnim = anim({
|
|
154
|
+
id: "#card-set-0-position",
|
|
155
|
+
targetSelector: "#card",
|
|
156
|
+
method: "set",
|
|
157
|
+
global: true,
|
|
158
|
+
resolvedStart: 0,
|
|
159
|
+
properties: { x: -74, y: -469 },
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
await promoteSetToKeyframes(session, sel, setAnim, 1, iframe);
|
|
163
|
+
|
|
164
|
+
const kfs = committed?.keyframes as Array<{ percentage: number; auto?: boolean }>;
|
|
165
|
+
expect(committed?.type).toBe("replace-with-keyframes");
|
|
166
|
+
expect(kfs[0]).toMatchObject({ percentage: 0, auto: true });
|
|
167
|
+
expect(kfs[1].percentage).toBe(100);
|
|
168
|
+
expect(kfs[1].auto).toBeUndefined();
|
|
169
|
+
});
|
|
170
|
+
});
|
|
@@ -107,6 +107,7 @@ export function buildExtendedKeyframes(
|
|
|
107
107
|
return { position: roundTo3(newStart), duration: newDuration, keyframes };
|
|
108
108
|
}
|
|
109
109
|
|
|
110
|
+
// fallow-ignore-next-line complexity
|
|
110
111
|
function readElementPosition(
|
|
111
112
|
iframe: HTMLIFrameElement | null,
|
|
112
113
|
sel: DomEditSelection,
|
|
@@ -238,8 +239,12 @@ async function applyKeyframeAtPlayhead(
|
|
|
238
239
|
* two-stop tween from the set's time to the playhead — the held value at 0%, the
|
|
239
240
|
* live value at 100% — giving the user something to animate. No-op if the playhead
|
|
240
241
|
* is at or before the set.
|
|
242
|
+
*
|
|
243
|
+
* The 0% endpoint is the held start, which the user didn't choose — mark it `auto`
|
|
244
|
+
* so it tracks the nearest keyframe until edited directly. The 100% is the real
|
|
245
|
+
* keyframe being placed at the playhead, so it stays fixed.
|
|
241
246
|
*/
|
|
242
|
-
async function promoteSetToKeyframes(
|
|
247
|
+
export async function promoteSetToKeyframes(
|
|
243
248
|
session: EnableKeyframesSession,
|
|
244
249
|
sel: DomEditSelection,
|
|
245
250
|
setAnim: GsapAnimation,
|
|
@@ -267,6 +272,7 @@ async function promoteSetToKeyframes(
|
|
|
267
272
|
{
|
|
268
273
|
percentage: 0,
|
|
269
274
|
properties: Object.keys(startPosition).length > 0 ? startPosition : endPosition,
|
|
275
|
+
auto: true,
|
|
270
276
|
},
|
|
271
277
|
{ percentage: 100, properties: endPosition },
|
|
272
278
|
],
|
|
@@ -283,6 +289,7 @@ async function promoteSetToKeyframes(
|
|
|
283
289
|
* the path, inserted at the matching segment so the curve is preserved. Outside the
|
|
284
290
|
* range, extend the duration so the motion reaches the playhead.
|
|
285
291
|
*/
|
|
292
|
+
// fallow-ignore-next-line complexity
|
|
286
293
|
async function applyArcWaypointAtPlayhead(
|
|
287
294
|
session: EnableKeyframesSession,
|
|
288
295
|
sel: DomEditSelection,
|
|
@@ -332,10 +339,10 @@ async function applyArcWaypointAtPlayhead(
|
|
|
332
339
|
);
|
|
333
340
|
}
|
|
334
341
|
|
|
335
|
-
// fallow-ignore-next-line complexity
|
|
336
342
|
export function useEnableKeyframes(
|
|
337
343
|
sessionRef: React.RefObject<EnableKeyframesSession | undefined>,
|
|
338
344
|
) {
|
|
345
|
+
// fallow-ignore-next-line complexity
|
|
339
346
|
return useCallback(async () => {
|
|
340
347
|
const session = sessionRef.current;
|
|
341
348
|
if (!session) return;
|
|
@@ -21,6 +21,7 @@ import {
|
|
|
21
21
|
useSafeGsapCommitMutation,
|
|
22
22
|
} from "./useSafeGsapCommitMutation";
|
|
23
23
|
import type { CommitMutation } from "./gsapScriptCommitTypes";
|
|
24
|
+
import type { DomEditGroupPathOffsetCommit } from "../components/editor/DomEditOverlay";
|
|
24
25
|
|
|
25
26
|
export interface UseGsapAwareEditingParams {
|
|
26
27
|
domEditSelection: DomEditSelection | null;
|
|
@@ -122,6 +123,33 @@ export function useGsapAwareEditing({
|
|
|
122
123
|
],
|
|
123
124
|
);
|
|
124
125
|
|
|
126
|
+
// Multi-select (group) drag: route EACH element through the SAME GSAP intercept as
|
|
127
|
+
// a single drag, so every position is written as GSAP code (tl.set / keyframes /
|
|
128
|
+
// gsap.set) — NEVER the deprecated `--hf-studio-offset` CSS var, and GSAP-animated
|
|
129
|
+
// elements are no longer blocked in a group. No CSS fallback: with no GSAP
|
|
130
|
+
// composition there's nothing to write (a no-op, exactly like the single-drag path).
|
|
131
|
+
const handleGsapAwareGroupPathOffsetCommit = useCallback(
|
|
132
|
+
async (updates: DomEditGroupPathOffsetCommit[]) => {
|
|
133
|
+
if (!gsapCommitMutation) return;
|
|
134
|
+
for (const { selection, next } of updates) {
|
|
135
|
+
try {
|
|
136
|
+
await tryGsapDragIntercept(
|
|
137
|
+
selection,
|
|
138
|
+
next,
|
|
139
|
+
[],
|
|
140
|
+
previewIframeRef.current,
|
|
141
|
+
gsapCommitMutation,
|
|
142
|
+
makeFetchFallback(selection),
|
|
143
|
+
);
|
|
144
|
+
} catch (error) {
|
|
145
|
+
trackGsapInteractionFailure(error, selection, "drag", "Move animated layer (group)");
|
|
146
|
+
throw error;
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
},
|
|
150
|
+
[gsapCommitMutation, previewIframeRef, makeFetchFallback, trackGsapInteractionFailure],
|
|
151
|
+
);
|
|
152
|
+
|
|
125
153
|
const handleGsapAwareBoxSizeCommit = useCallback(
|
|
126
154
|
async (selection: DomEditSelection, next: { width: number; height: number }) => {
|
|
127
155
|
if (gsapCommitMutation) {
|
|
@@ -184,7 +212,7 @@ export function useGsapAwareEditing({
|
|
|
184
212
|
|
|
185
213
|
// ── Animated property commit ──
|
|
186
214
|
|
|
187
|
-
const commitAnimatedProperty = useAnimatedPropertyCommit({
|
|
215
|
+
const { commitAnimatedProperty, commitAnimatedProperties } = useAnimatedPropertyCommit({
|
|
188
216
|
selectedGsapAnimations,
|
|
189
217
|
gsapCommitMutation,
|
|
190
218
|
addGsapAnimation: (sel, method, time) => addGsapAnimation(sel, method, time),
|
|
@@ -246,9 +274,11 @@ export function useGsapAwareEditing({
|
|
|
246
274
|
|
|
247
275
|
return {
|
|
248
276
|
handleGsapAwarePathOffsetCommit,
|
|
277
|
+
handleGsapAwareGroupPathOffsetCommit,
|
|
249
278
|
handleGsapAwareBoxSizeCommit,
|
|
250
279
|
handleGsapAwareRotationCommit,
|
|
251
280
|
commitAnimatedProperty,
|
|
281
|
+
commitAnimatedProperties,
|
|
252
282
|
handleSetArcPath,
|
|
253
283
|
handleUpdateArcSegment,
|
|
254
284
|
handleUnroll,
|
|
@@ -206,6 +206,7 @@ export function useGsapKeyframeOps({
|
|
|
206
206
|
selection: DomEditSelection,
|
|
207
207
|
animationId: string,
|
|
208
208
|
resolvedFromValues?: Record<string, number | string>,
|
|
209
|
+
duration?: number,
|
|
209
210
|
) => {
|
|
210
211
|
if (sdkSession && sdkDeps) {
|
|
211
212
|
const targetPath = selection.sourceFile || activeCompPath || "index.html";
|
|
@@ -221,7 +222,9 @@ export function useGsapKeyframeOps({
|
|
|
221
222
|
}
|
|
222
223
|
return commitMutation(
|
|
223
224
|
selection,
|
|
224
|
-
|
|
225
|
+
// `duration` only applies when the target is a static `set` (which has
|
|
226
|
+
// none) — it spans the converted keyframes across the element's clip.
|
|
227
|
+
{ type: "convert-to-keyframes", animationId, resolvedFromValues, duration },
|
|
225
228
|
{ label: "Convert to keyframes" },
|
|
226
229
|
);
|
|
227
230
|
},
|