@hyperframes/studio 0.7.7 → 0.7.8
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--lOAjFJl.js} +1 -1
- package/dist/assets/{index-BeRh2hMe.js → index-BSyZKYmH.js} +194 -194
- package/dist/assets/{index-BoASKOeE.js → index-Dgeszckd.js} +1 -1
- 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 +2699 -2052
- 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/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/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
|
@@ -13,13 +13,22 @@
|
|
|
13
13
|
* "Which tween" is resolved by the same all-timelines scan `readRuntimeKeyframes`
|
|
14
14
|
* uses (`resolveRuntimeTween`), so read and write agree on the target.
|
|
15
15
|
*/
|
|
16
|
-
import {
|
|
16
|
+
import {
|
|
17
|
+
resolveRuntimeTween,
|
|
18
|
+
type RuntimeTween,
|
|
19
|
+
type RuntimeTimeline,
|
|
20
|
+
} from "./gsapRuntimeKeyframes";
|
|
17
21
|
|
|
18
22
|
/** Value-only channels a `tl.set(...)` patch may touch. */
|
|
19
23
|
export interface SetPatchProps {
|
|
20
24
|
x?: number;
|
|
21
25
|
y?: number;
|
|
22
26
|
rotation?: number;
|
|
27
|
+
rotationX?: number;
|
|
28
|
+
rotationY?: number;
|
|
29
|
+
rotationZ?: number;
|
|
30
|
+
z?: number;
|
|
31
|
+
transformPerspective?: number;
|
|
23
32
|
scaleX?: number;
|
|
24
33
|
scaleY?: number;
|
|
25
34
|
scale?: number;
|
|
@@ -31,12 +40,28 @@ export type KeyframeStep = Record<string, number>;
|
|
|
31
40
|
|
|
32
41
|
export type RuntimeTweenChange =
|
|
33
42
|
| { kind: "set"; props: SetPatchProps }
|
|
34
|
-
| { kind: "keyframes"; keyframes: KeyframeStep[] }
|
|
43
|
+
| { kind: "keyframes"; keyframes: KeyframeStep[] }
|
|
44
|
+
// Edit ONE step (at `pct`) of an object-form keyframe tween — the form the studio
|
|
45
|
+
// writes (`keyframes: { "0%": {...} }`). GSAP pre-compiles those into sub-tweens
|
|
46
|
+
// and won't re-read `vars.keyframes` on `invalidate()`, so this REBUILDS the tween
|
|
47
|
+
// (kill + recreate at the same position) instead of mutating it. Lets a design-panel
|
|
48
|
+
// keyframe edit show instantly rather than soft-reloading the iframe (a flash).
|
|
49
|
+
| { kind: "keyframe-rebuild"; pct: number; props: KeyframeStep }
|
|
50
|
+
// Apply a base `gsap.set` value to the element directly (`gsap.set(el, props)`).
|
|
51
|
+
// A base set lives OFF the timeline, so there's no runtime tween to patch — but
|
|
52
|
+
// the element is static on these channels, so setting them immediately reflects
|
|
53
|
+
// the edit with no soft reload (no flash) and leaves no keyframe marker.
|
|
54
|
+
| { kind: "global-set"; props: SetPatchProps };
|
|
35
55
|
|
|
36
56
|
const SET_CHANNELS: Array<keyof SetPatchProps> = [
|
|
37
57
|
"x",
|
|
38
58
|
"y",
|
|
39
59
|
"rotation",
|
|
60
|
+
"rotationX",
|
|
61
|
+
"rotationY",
|
|
62
|
+
"rotationZ",
|
|
63
|
+
"z",
|
|
64
|
+
"transformPerspective",
|
|
40
65
|
"scaleX",
|
|
41
66
|
"scaleY",
|
|
42
67
|
"scale",
|
|
@@ -45,8 +70,42 @@ const SET_CHANNELS: Array<keyof SetPatchProps> = [
|
|
|
45
70
|
|
|
46
71
|
type IframeWindow = Window & {
|
|
47
72
|
__player?: { getTime?: () => number; seek?: (t: number) => void };
|
|
73
|
+
gsap?: { set?: (target: Element, vars: Record<string, number>) => void };
|
|
48
74
|
};
|
|
49
75
|
|
|
76
|
+
/**
|
|
77
|
+
* Apply a base `gsap.set` value to the element in the live runtime. Returns `true`
|
|
78
|
+
* if applied. Used for off-timeline static holds (position / 3D transform) — there's
|
|
79
|
+
* no tween to patch, so we set the channels directly. Safe because the element is
|
|
80
|
+
* static on these channels (the caller only uses this for non-animated values).
|
|
81
|
+
*/
|
|
82
|
+
/** The props as finite numbers, or null if any value is non-finite / none present. */
|
|
83
|
+
function finiteNumericProps(props: SetPatchProps): Record<string, number> | null {
|
|
84
|
+
const numeric: Record<string, number> = {};
|
|
85
|
+
for (const [k, v] of Object.entries(props)) {
|
|
86
|
+
if (typeof v !== "number" || !Number.isFinite(v)) return null;
|
|
87
|
+
numeric[k] = v;
|
|
88
|
+
}
|
|
89
|
+
return Object.keys(numeric).length > 0 ? numeric : null;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function applyGlobalSet(
|
|
93
|
+
iframe: HTMLIFrameElement,
|
|
94
|
+
selector: string,
|
|
95
|
+
props: SetPatchProps,
|
|
96
|
+
): boolean {
|
|
97
|
+
try {
|
|
98
|
+
const gsapLib = (iframe.contentWindow as IframeWindow | null)?.gsap;
|
|
99
|
+
const el = iframe.contentDocument?.querySelector(selector) ?? null;
|
|
100
|
+
const numeric = finiteNumericProps(props);
|
|
101
|
+
if (!gsapLib?.set || !el || !numeric) return false;
|
|
102
|
+
gsapLib.set(el, numeric);
|
|
103
|
+
return true;
|
|
104
|
+
} catch {
|
|
105
|
+
return false;
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
50
109
|
function playerOf(iframe: HTMLIFrameElement): IframeWindow["__player"] | null {
|
|
51
110
|
try {
|
|
52
111
|
return (iframe.contentWindow as IframeWindow | null)?.__player ?? null;
|
|
@@ -100,10 +159,95 @@ function patchKeyframes(tween: RuntimeTween, keyframes: KeyframeStep[]): boolean
|
|
|
100
159
|
return true;
|
|
101
160
|
}
|
|
102
161
|
|
|
162
|
+
/** The object-form keyframes map with `props` merged into the step at `pct`. */
|
|
163
|
+
function mergeKeyframeStep(
|
|
164
|
+
map: Record<string, Record<string, number>>,
|
|
165
|
+
pct: number,
|
|
166
|
+
props: KeyframeStep,
|
|
167
|
+
): Record<string, Record<string, number>> {
|
|
168
|
+
const next: Record<string, Record<string, number>> = {};
|
|
169
|
+
for (const [k, step] of Object.entries(map)) next[k] = { ...step };
|
|
170
|
+
// Match the existing percentage key numerically ("50%" ≡ pct 50), else add one.
|
|
171
|
+
let key: string | null = null;
|
|
172
|
+
for (const k of Object.keys(next)) {
|
|
173
|
+
const n = parseFloat(k);
|
|
174
|
+
if (Number.isFinite(n) && Math.abs(n - pct) < 0.05) {
|
|
175
|
+
key = k;
|
|
176
|
+
break;
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
if (key === null) key = `${pct}%`;
|
|
180
|
+
next[key] = { ...(next[key] ?? {}), ...props };
|
|
181
|
+
return next;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* Rebuild an object-form keyframe tween with `props` merged into the step at `pct`,
|
|
186
|
+
* in place: kill the old tween and recreate it on the SAME parent timeline at the
|
|
187
|
+
* SAME position, with all other vars (duration, ease, repeat, …) preserved. This
|
|
188
|
+
* is the only way to reflect an object-form keyframe edit live — GSAP compiles
|
|
189
|
+
* those keyframes into sub-tweens at creation and ignores later `vars.keyframes`
|
|
190
|
+
* mutations. Declines (→ caller soft-reloads) for array-form, motionPath arcs,
|
|
191
|
+
* non-finite/dynamic values, or a tween whose parent/targets can't be resolved.
|
|
192
|
+
*/
|
|
193
|
+
// fallow-ignore-next-line complexity
|
|
194
|
+
function rebuildKeyframeTween(tween: RuntimeTween, pct: number, props: KeyframeStep): boolean {
|
|
195
|
+
const vars = tween.vars;
|
|
196
|
+
if (!vars || "motionPath" in vars) return false;
|
|
197
|
+
const kf = vars.keyframes;
|
|
198
|
+
if (!kf || typeof kf !== "object" || Array.isArray(kf)) return false;
|
|
199
|
+
for (const v of Object.values(props)) {
|
|
200
|
+
if (typeof v !== "number" || !Number.isFinite(v)) return false;
|
|
201
|
+
}
|
|
202
|
+
const parent = tween.parent;
|
|
203
|
+
const targets = tween.targets?.();
|
|
204
|
+
if (!parent?.to || !targets || targets.length === 0) return false;
|
|
205
|
+
if (typeof tween.startTime !== "function" || typeof tween.kill !== "function") return false;
|
|
206
|
+
|
|
207
|
+
const next = mergeKeyframeStep(kf as Record<string, Record<string, number>>, pct, props);
|
|
208
|
+
const newVars = { ...vars, keyframes: next };
|
|
209
|
+
const position = tween.startTime();
|
|
210
|
+
tween.kill();
|
|
211
|
+
parent.to(targets, newVars, position);
|
|
212
|
+
return true;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/** The channels a change writes, for the resolver to disambiguate co-located tweens. */
|
|
216
|
+
function changeChannels(change: RuntimeTweenChange): string[] | undefined {
|
|
217
|
+
if (change.kind === "set") {
|
|
218
|
+
return Object.keys(change.props).filter(
|
|
219
|
+
(k) => change.props[k as keyof SetPatchProps] !== undefined,
|
|
220
|
+
);
|
|
221
|
+
}
|
|
222
|
+
if (change.kind === "keyframe-rebuild") return Object.keys(change.props);
|
|
223
|
+
return undefined;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/** Re-render the timeline at the current playhead after an in-place edit. */
|
|
227
|
+
function seekToCurrent(iframe: HTMLIFrameElement, timeline: RuntimeTimeline): void {
|
|
228
|
+
const player = playerOf(iframe);
|
|
229
|
+
const currentTime =
|
|
230
|
+
typeof player?.getTime === "function"
|
|
231
|
+
? player.getTime()
|
|
232
|
+
: typeof timeline.time === "function"
|
|
233
|
+
? timeline.time()
|
|
234
|
+
: 0;
|
|
235
|
+
player?.seek?.(Number.isFinite(currentTime) ? currentTime : 0);
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/** Apply `change` to the resolved tween. `true` if applied, `false` to soft-reload.
|
|
239
|
+
* `global-set` is handled before this (no tween) and never reaches here. */
|
|
240
|
+
function applyChange(tween: RuntimeTween, change: RuntimeTweenChange): boolean {
|
|
241
|
+
if (change.kind === "set") return patchSet(tween, change.props);
|
|
242
|
+
if (change.kind === "keyframes") return patchKeyframes(tween, change.keyframes);
|
|
243
|
+
if (change.kind === "keyframe-rebuild")
|
|
244
|
+
return rebuildKeyframeTween(tween, change.pct, change.props);
|
|
245
|
+
return false;
|
|
246
|
+
}
|
|
247
|
+
|
|
103
248
|
/**
|
|
104
|
-
*
|
|
105
|
-
*
|
|
106
|
-
* (caller falls back to a soft reload).
|
|
249
|
+
* Edit one tween in `window.__timelines` in place + re-seek to the current playhead.
|
|
250
|
+
* Returns `true` on a confident patch, `false` otherwise (caller soft-reloads).
|
|
107
251
|
*/
|
|
108
252
|
export function patchRuntimeTweenInPlace(
|
|
109
253
|
iframe: HTMLIFrameElement | null,
|
|
@@ -112,46 +256,29 @@ export function patchRuntimeTweenInPlace(
|
|
|
112
256
|
compositionId?: string,
|
|
113
257
|
): boolean {
|
|
114
258
|
if (!iframe) return false;
|
|
259
|
+
// A base `gsap.set` has no timeline tween to resolve — apply the value straight
|
|
260
|
+
// to the element so the edit shows instantly (no soft reload, no flash).
|
|
261
|
+
if (change.kind === "global-set") return applyGlobalSet(iframe, selector, change.props);
|
|
115
262
|
try {
|
|
116
|
-
// For a `set` patch, hand the resolver the channels actually being written so
|
|
117
|
-
// it picks the set whose vars carry them — an element can have separate
|
|
118
|
-
// {x,y} and {rotation} sets, and a position patch must not corrupt the
|
|
119
|
-
// rotation set (channel-blind resolution would return the first match).
|
|
120
|
-
const channels =
|
|
121
|
-
change.kind === "set"
|
|
122
|
-
? Object.keys(change.props).filter(
|
|
123
|
-
(k) => change.props[k as keyof SetPatchProps] !== undefined,
|
|
124
|
-
)
|
|
125
|
-
: undefined;
|
|
126
263
|
const resolved = resolveRuntimeTween(
|
|
127
264
|
iframe,
|
|
128
265
|
selector,
|
|
129
266
|
change.kind === "set" ? "set" : "keyframe",
|
|
130
267
|
compositionId,
|
|
131
|
-
|
|
268
|
+
changeChannels(change),
|
|
132
269
|
);
|
|
133
270
|
if (!resolved) return false;
|
|
134
271
|
const { tween, timeline } = resolved;
|
|
135
272
|
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
if (
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
timeline.invalidate?.();
|
|
146
|
-
|
|
147
|
-
const player = playerOf(iframe);
|
|
148
|
-
const currentTime =
|
|
149
|
-
typeof player?.getTime === "function"
|
|
150
|
-
? player.getTime()
|
|
151
|
-
: typeof timeline.time === "function"
|
|
152
|
-
? timeline.time()
|
|
153
|
-
: 0;
|
|
154
|
-
player?.seek?.(Number.isFinite(currentTime) ? currentTime : 0);
|
|
273
|
+
if (!applyChange(tween, change)) return false;
|
|
274
|
+
|
|
275
|
+
// A rebuild already recreated the tween; set/keyframes mutate vars in place, so
|
|
276
|
+
// invalidate to make GSAP re-read them on the next render. Either way, re-seek.
|
|
277
|
+
if (change.kind !== "keyframe-rebuild") {
|
|
278
|
+
tween.invalidate?.();
|
|
279
|
+
timeline.invalidate?.();
|
|
280
|
+
}
|
|
281
|
+
seekToCurrent(iframe, timeline);
|
|
155
282
|
return true;
|
|
156
283
|
} catch {
|
|
157
284
|
return false;
|
|
@@ -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,
|