@hyperframes/studio 0.7.51 → 0.7.53
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-ZwnuKogK.js → index-Bo8sRL2U.js} +1 -1
- package/dist/assets/index-CJpl5RTK.js +423 -0
- package/dist/assets/{index-3b1A9DtP.js → index-DHrXh-VF.js} +1 -1
- package/dist/{chunk-BA66NM4L.js → chunk-SOTCF4DF.js} +4 -2
- package/dist/chunk-SOTCF4DF.js.map +1 -0
- package/dist/{domEditingLayers-H7LDFIJ7.js → domEditingLayers-2ECJK24D.js} +2 -2
- package/dist/index.html +1 -1
- package/dist/index.js +669 -338
- package/dist/index.js.map +1 -1
- package/package.json +7 -7
- package/src/components/editor/DomEditCropHandles.test.tsx +86 -0
- package/src/components/editor/DomEditCropHandles.tsx +96 -58
- package/src/components/editor/DomEditOverlay.test.ts +41 -0
- package/src/components/editor/DomEditOverlay.tsx +17 -1
- package/src/components/editor/domEditOverlayCrop.test.ts +137 -0
- package/src/components/editor/domEditOverlayCrop.ts +103 -7
- package/src/components/editor/domEditOverlayGestures.ts +35 -6
- package/src/components/editor/domEditOverlayStartGesture.ts +35 -2
- package/src/components/editor/domEditingDom.ts +1 -2
- package/src/components/editor/useDomEditOverlayGestures.ts +48 -7
- package/src/hooks/gsapResizeIntercept.test.ts +129 -0
- package/src/hooks/gsapResizeIntercept.ts +385 -0
- package/src/hooks/gsapRuntimeBridge.ts +3 -226
- package/src/hooks/gsapRuntimePatch.test.ts +63 -0
- package/src/hooks/gsapRuntimePatch.ts +35 -1
- package/src/hooks/gsapRuntimeReaders.test.ts +111 -0
- package/src/hooks/gsapRuntimeReaders.ts +39 -14
- package/src/hooks/useAnimatedPropertyCommit.test.tsx +66 -5
- package/src/hooks/useAnimatedPropertyCommit.ts +85 -17
- package/src/hooks/useGsapAwareEditing.ts +2 -5
- package/src/hooks/useGsapScriptCommits.test.tsx +86 -66
- package/src/hooks/useGsapScriptCommits.ts +27 -4
- package/src/utils/authoredOpacity.ts +35 -0
- package/src/utils/elementGsap.ts +31 -0
- package/src/utils/gsapSoftReload.test.ts +86 -2
- package/src/utils/gsapSoftReload.ts +69 -7
- package/dist/assets/index-BiJK2Lfo.js +0 -423
- package/dist/chunk-BA66NM4L.js.map +0 -1
- /package/dist/{domEditingLayers-H7LDFIJ7.js.map → domEditingLayers-2ECJK24D.js.map} +0 -0
|
@@ -133,6 +133,69 @@ describe("patchRuntimeTweenInPlace — set tweens", () => {
|
|
|
133
133
|
});
|
|
134
134
|
});
|
|
135
135
|
|
|
136
|
+
describe("patchRuntimeTweenInPlace — authored-opacity capture guard", () => {
|
|
137
|
+
function makeStampedEl(id: string, stamped: string | null, inlineOpacity: string) {
|
|
138
|
+
const style = new Map<string, string>([["opacity", inlineOpacity]]);
|
|
139
|
+
return {
|
|
140
|
+
el: {
|
|
141
|
+
id,
|
|
142
|
+
style: {
|
|
143
|
+
setProperty: (k: string, v: string) => void style.set(k, v),
|
|
144
|
+
removeProperty: (k: string) => void style.delete(k),
|
|
145
|
+
},
|
|
146
|
+
getAttribute: (name: string) => (name === "data-hf-authored-opacity" ? stamped : null),
|
|
147
|
+
},
|
|
148
|
+
style,
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
it("restores the stamped authored opacity before an opacity-touching patch", () => {
|
|
153
|
+
// Runtime transient (grading hide / mid-flight tween) baked into inline style.
|
|
154
|
+
const { el, style } = makeStampedEl("box", "0.75", "0");
|
|
155
|
+
const setTween = makeTween(
|
|
156
|
+
{ vars: { opacity: 0.2, duration: 0 }, targetIds: ["box"], duration: 0 },
|
|
157
|
+
el,
|
|
158
|
+
);
|
|
159
|
+
const { iframe } = fakeIframe(el, [setTween]);
|
|
160
|
+
|
|
161
|
+
const ok = patchRuntimeTweenInPlace(iframe, "#box", {
|
|
162
|
+
kind: "set",
|
|
163
|
+
props: { opacity: 0.5 },
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
expect(ok).toBe(true);
|
|
167
|
+
// The re-init must capture the authored 0.75, not the transient 0.
|
|
168
|
+
expect(style.get("opacity")).toBe("0.75");
|
|
169
|
+
expect(setTween.vars.opacity).toBe(0.5);
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
it("removes inline opacity when the stamp recorded no authored value", () => {
|
|
173
|
+
const { el, style } = makeStampedEl("box", "", "0");
|
|
174
|
+
const setTween = makeTween(
|
|
175
|
+
{ vars: { opacity: 0.2, duration: 0 }, targetIds: ["box"], duration: 0 },
|
|
176
|
+
el,
|
|
177
|
+
);
|
|
178
|
+
const { iframe } = fakeIframe(el, [setTween]);
|
|
179
|
+
|
|
180
|
+
patchRuntimeTweenInPlace(iframe, "#box", { kind: "set", props: { opacity: 0.5 } });
|
|
181
|
+
|
|
182
|
+
expect(style.has("opacity")).toBe(false);
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
it("leaves inline opacity alone for a position-only patch", () => {
|
|
186
|
+
const { el, style } = makeStampedEl("box", "0.75", "0");
|
|
187
|
+
const setTween = makeTween(
|
|
188
|
+
{ vars: { x: 0, y: 0, duration: 0 }, targetIds: ["box"], duration: 0 },
|
|
189
|
+
el,
|
|
190
|
+
);
|
|
191
|
+
const { iframe } = fakeIframe(el, [setTween]);
|
|
192
|
+
|
|
193
|
+
patchRuntimeTweenInPlace(iframe, "#box", { kind: "set", props: { x: 10, y: 20 } });
|
|
194
|
+
|
|
195
|
+
expect(style.get("opacity")).toBe("0");
|
|
196
|
+
});
|
|
197
|
+
});
|
|
198
|
+
|
|
136
199
|
describe("patchRuntimeTweenInPlace — channel-aware set resolution", () => {
|
|
137
200
|
it("patches the {x,y} set, not a co-located rotation-only set", () => {
|
|
138
201
|
const el = { id: "dual" };
|
|
@@ -13,6 +13,7 @@
|
|
|
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 { applyAuthoredInlineOpacity, readStampedAuthoredOpacity } from "../utils/authoredOpacity";
|
|
16
17
|
import {
|
|
17
18
|
resolveRuntimeTween,
|
|
18
19
|
type RuntimeTween,
|
|
@@ -235,6 +236,34 @@ function seekToCurrent(iframe: HTMLIFrameElement, timeline: RuntimeTimeline): vo
|
|
|
235
236
|
player?.seek?.(Number.isFinite(currentTime) ? currentTime : 0);
|
|
236
237
|
}
|
|
237
238
|
|
|
239
|
+
/** Does this change touch the opacity channel (whose re-init reads inline style)? */
|
|
240
|
+
function changeTouchesOpacity(change: RuntimeTweenChange): boolean {
|
|
241
|
+
if (change.kind === "set" || change.kind === "global-set")
|
|
242
|
+
return change.props.opacity !== undefined;
|
|
243
|
+
if (change.kind === "keyframes") return change.keyframes.some((step) => "opacity" in step);
|
|
244
|
+
return "opacity" in change.props;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/**
|
|
248
|
+
* A tween re-initialization (invalidate, or kill+recreate for keyframe-rebuild)
|
|
249
|
+
* captures opacity from the element's CURRENT inline style — for a color-graded
|
|
250
|
+
* source (hidden with `opacity: 0 !important`) or a mid-flight tween that's a
|
|
251
|
+
* runtime transient, not the authored value, and the capture makes it permanent.
|
|
252
|
+
* Restore the runtime's parse-time authored capture (data-hf-authored-opacity)
|
|
253
|
+
* first; the re-seek after the patch re-renders the animated value anyway.
|
|
254
|
+
* Duck-typed (no instanceof): the targets live in the preview iframe's realm.
|
|
255
|
+
*/
|
|
256
|
+
function restoreAuthoredOpacityForCapture(tween: RuntimeTween): void {
|
|
257
|
+
const targets = typeof tween.targets === "function" ? tween.targets() : [];
|
|
258
|
+
for (const target of targets ?? []) {
|
|
259
|
+
const el = target as HTMLElement | null;
|
|
260
|
+
if (!el?.style || typeof el.getAttribute !== "function") continue;
|
|
261
|
+
const authored = readStampedAuthoredOpacity(el);
|
|
262
|
+
if (authored === null) continue;
|
|
263
|
+
applyAuthoredInlineOpacity(el.style, authored);
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
|
|
238
267
|
/** Apply `change` to the resolved tween. `true` if applied, `false` to soft-reload.
|
|
239
268
|
* `global-set` is handled before this (no tween) and never reaches here. */
|
|
240
269
|
function applyChange(tween: RuntimeTween, change: RuntimeTweenChange): boolean {
|
|
@@ -270,13 +299,18 @@ export function patchRuntimeTweenInPlace(
|
|
|
270
299
|
if (!resolved) return false;
|
|
271
300
|
const { tween, timeline } = resolved;
|
|
272
301
|
|
|
302
|
+
if (changeTouchesOpacity(change)) restoreAuthoredOpacityForCapture(tween);
|
|
273
303
|
if (!applyChange(tween, change)) return false;
|
|
274
304
|
|
|
275
305
|
// A rebuild already recreated the tween; set/keyframes mutate vars in place, so
|
|
276
306
|
// invalidate to make GSAP re-read them on the next render. Either way, re-seek.
|
|
307
|
+
// Invalidate ONLY the edited tween — never the whole timeline. A timeline-wide
|
|
308
|
+
// invalidate re-initializes every from() tween against the CURRENT inline
|
|
309
|
+
// styles, and the color-grading engine hides its source elements with
|
|
310
|
+
// `opacity: 0 !important` — so every graded element's from(opacity) re-captures
|
|
311
|
+
// 0 as its end value and animates 0→0 forever (all graded elements vanish).
|
|
277
312
|
if (change.kind !== "keyframe-rebuild") {
|
|
278
313
|
tween.invalidate?.();
|
|
279
|
-
timeline.invalidate?.();
|
|
280
314
|
}
|
|
281
315
|
seekToCurrent(iframe, timeline);
|
|
282
316
|
return true;
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
import { afterEach, describe, expect, it } from "vitest";
|
|
3
|
+
import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
|
|
4
|
+
import {
|
|
5
|
+
COLOR_GRADING_SOURCE_HIDDEN_ATTR,
|
|
6
|
+
HF_COLOR_GRADING_CANVAS_ID_PREFIX,
|
|
7
|
+
} from "@hyperframes/core/color-grading";
|
|
8
|
+
import { readAllAnimatedProperties, readGsapProperty } from "./gsapRuntimeReaders";
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Regression: converting a property-group tween to keyframes resolves "current
|
|
12
|
+
* values" via readAllAnimatedProperties. Two ways that used to bake garbage
|
|
13
|
+
* into the composition file:
|
|
14
|
+
*
|
|
15
|
+
* 1. The group filter only pruned the tween's OWN properties — the baseline
|
|
16
|
+
* pass still captured every property ANY tween on the element touches, so
|
|
17
|
+
* a rotation commit carried `opacity` from the intro from() tween.
|
|
18
|
+
* 2. `gsap.getProperty(el, "opacity")` on a color-grading source reads the
|
|
19
|
+
* runtime hide (inline `opacity: 0 !important`), not the animated value —
|
|
20
|
+
* so the captured opacity was the transient 0, which then animated 0 → 0
|
|
21
|
+
* on the next full load and the element disappeared.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
function fakeIframe(
|
|
25
|
+
el: Element,
|
|
26
|
+
opts: { gsapValues: Record<string, number>; otherTweenVars?: Record<string, number> },
|
|
27
|
+
): HTMLIFrameElement {
|
|
28
|
+
const children = opts.otherTweenVars
|
|
29
|
+
? [{ targets: () => [el], vars: { duration: 0.8, ...opts.otherTweenVars } }]
|
|
30
|
+
: [];
|
|
31
|
+
return {
|
|
32
|
+
contentWindow: {
|
|
33
|
+
__timelines: { main: { getChildren: () => children } },
|
|
34
|
+
gsap: { getProperty: (_el: Element, prop: string) => opts.gsapValues[prop] ?? 0 },
|
|
35
|
+
},
|
|
36
|
+
contentDocument: document,
|
|
37
|
+
} as unknown as HTMLIFrameElement;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function rotationSetAnim(): GsapAnimation {
|
|
41
|
+
return {
|
|
42
|
+
id: "#clip-set-0-rotation",
|
|
43
|
+
targetSelector: "#clip",
|
|
44
|
+
method: "set",
|
|
45
|
+
properties: { rotation: 0 },
|
|
46
|
+
} as unknown as GsapAnimation;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
afterEach(() => {
|
|
50
|
+
document.body.innerHTML = "";
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
describe("readAllAnimatedProperties group filter", () => {
|
|
54
|
+
it("keeps other tweens' out-of-group properties out of a grouped resolve", () => {
|
|
55
|
+
const el = document.createElement("div");
|
|
56
|
+
el.id = "clip";
|
|
57
|
+
document.body.appendChild(el);
|
|
58
|
+
const iframe = fakeIframe(el, {
|
|
59
|
+
gsapValues: { rotation: -28.1, opacity: 0, rotationX: 52, rotationY: -47 },
|
|
60
|
+
otherTweenVars: { opacity: 0, rotationX: 52, rotationY: -47 },
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
const result = readAllAnimatedProperties(iframe, "#clip", rotationSetAnim(), "rotation");
|
|
64
|
+
|
|
65
|
+
expect(result).toEqual({ rotation: -28.1 });
|
|
66
|
+
});
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
describe("color-grading opacity truth", () => {
|
|
70
|
+
function gradedElement(): HTMLElement {
|
|
71
|
+
const el = document.createElement("img");
|
|
72
|
+
el.id = "clip";
|
|
73
|
+
el.setAttribute(COLOR_GRADING_SOURCE_HIDDEN_ATTR, "");
|
|
74
|
+
const canvas = document.createElement("canvas");
|
|
75
|
+
canvas.id = `${HF_COLOR_GRADING_CANVAS_ID_PREFIX}clip`;
|
|
76
|
+
canvas.style.opacity = "0.98";
|
|
77
|
+
document.body.append(el, canvas);
|
|
78
|
+
return el;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
it("resolves opacity from the grading canvas, not the runtime hide", () => {
|
|
82
|
+
const el = gradedElement();
|
|
83
|
+
const iframe = fakeIframe(el, { gsapValues: { opacity: 0 } });
|
|
84
|
+
const anim = {
|
|
85
|
+
id: "#clip-from-200-visual",
|
|
86
|
+
targetSelector: "#clip",
|
|
87
|
+
method: "from",
|
|
88
|
+
properties: { opacity: 0.5 },
|
|
89
|
+
} as unknown as GsapAnimation;
|
|
90
|
+
|
|
91
|
+
const result = readAllAnimatedProperties(iframe, "#clip", anim);
|
|
92
|
+
|
|
93
|
+
expect(result.opacity).toBe(0.98);
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it("readGsapProperty takes the same detour", () => {
|
|
97
|
+
const el = gradedElement();
|
|
98
|
+
const iframe = fakeIframe(el, { gsapValues: { opacity: 0 } });
|
|
99
|
+
|
|
100
|
+
expect(readGsapProperty(iframe, "#clip", "opacity")).toBe(0.98);
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it("reads GSAP directly when the source is not grading-hidden", () => {
|
|
104
|
+
const el = document.createElement("div");
|
|
105
|
+
el.id = "clip";
|
|
106
|
+
document.body.appendChild(el);
|
|
107
|
+
const iframe = fakeIframe(el, { gsapValues: { opacity: 0.3 } });
|
|
108
|
+
|
|
109
|
+
expect(readGsapProperty(iframe, "#clip", "opacity")).toBe(0.3);
|
|
110
|
+
});
|
|
111
|
+
});
|
|
@@ -3,9 +3,33 @@
|
|
|
3
3
|
*/
|
|
4
4
|
import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
|
|
5
5
|
import { classifyPropertyGroup, type PropertyGroupName } from "@hyperframes/core/gsap-parser";
|
|
6
|
-
import {
|
|
6
|
+
import {
|
|
7
|
+
COLOR_GRADING_SOURCE_HIDDEN_ATTR,
|
|
8
|
+
HF_COLOR_GRADING_CANVAS_ID_PREFIX,
|
|
9
|
+
} from "@hyperframes/core/color-grading";
|
|
10
|
+
import { getIframeGsap, queryIframeElement, type IframeGsap } from "./gsapShared";
|
|
7
11
|
import { roundTo3 } from "../utils/rounding";
|
|
8
12
|
|
|
13
|
+
/**
|
|
14
|
+
* The element's live value for `prop` as GSAP drives it. Opacity on a
|
|
15
|
+
* color-grading-hidden source needs a detour: the runtime hides the source
|
|
16
|
+
* with inline `opacity: 0 !important`, so computed opacity is the hide, not
|
|
17
|
+
* the animated value. The grading canvas mirrors the source's effective
|
|
18
|
+
* opacity every frame, so it is the truth for that one property — reading the
|
|
19
|
+
* raw 0 here is what bakes `opacity: 0` into committed keyframes.
|
|
20
|
+
*/
|
|
21
|
+
function readLiveGsapValue(gsap: IframeGsap, el: Element, prop: string): number {
|
|
22
|
+
if (prop === "opacity" && el.getAttribute(COLOR_GRADING_SOURCE_HIDDEN_ATTR) != null && el.id) {
|
|
23
|
+
const canvas = el.ownerDocument.getElementById(HF_COLOR_GRADING_CANVAS_ID_PREFIX + el.id);
|
|
24
|
+
const win = el.ownerDocument.defaultView;
|
|
25
|
+
if (canvas && win) {
|
|
26
|
+
const val = Number(win.getComputedStyle(canvas).opacity);
|
|
27
|
+
if (Number.isFinite(val)) return val;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
return Number(gsap.getProperty(el, prop));
|
|
31
|
+
}
|
|
32
|
+
|
|
9
33
|
export function readGsapProperty(
|
|
10
34
|
iframe: HTMLIFrameElement | null,
|
|
11
35
|
selector: string | null,
|
|
@@ -17,7 +41,7 @@ export function readGsapProperty(
|
|
|
17
41
|
const el = queryIframeElement(iframe, selector);
|
|
18
42
|
if (!el) return null;
|
|
19
43
|
try {
|
|
20
|
-
const val =
|
|
44
|
+
const val = readLiveGsapValue(gsap, el, prop);
|
|
21
45
|
if (!Number.isFinite(val)) return null;
|
|
22
46
|
return POSITION_PROPS.has(prop) ? Math.round(val) : roundTo3(val);
|
|
23
47
|
} catch {
|
|
@@ -77,15 +101,15 @@ export function readAllAnimatedProperties(
|
|
|
77
101
|
for (const p of Object.keys(anim.properties)) propKeys.add(p);
|
|
78
102
|
}
|
|
79
103
|
|
|
80
|
-
// When a group filter is specified, only
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
104
|
+
// When a group filter is specified, only properties belonging to that group
|
|
105
|
+
// may enter the result — including the baseline passes below. The whole
|
|
106
|
+
// point of property-group tweens is that a rotation commit never carries
|
|
107
|
+
// opacity/rotationX/etc. captured from unrelated tweens on the element.
|
|
108
|
+
const inGroup = (p: string) => !group || classifyPropertyGroup(p) === group;
|
|
109
|
+
const groupedPropKeys = new Set([...propKeys].filter(inGroup));
|
|
86
110
|
|
|
87
|
-
for (const prop of
|
|
88
|
-
const val =
|
|
111
|
+
for (const prop of groupedPropKeys) {
|
|
112
|
+
const val = readLiveGsapValue(gsap, el, prop);
|
|
89
113
|
if (Number.isFinite(val)) {
|
|
90
114
|
result[prop] = POSITION_PROPS.has(prop) ? Math.round(val) : roundTo3(val);
|
|
91
115
|
}
|
|
@@ -110,13 +134,13 @@ export function readAllAnimatedProperties(
|
|
|
110
134
|
const vars = child.vars;
|
|
111
135
|
if (!vars) continue;
|
|
112
136
|
for (const k of Object.keys(vars)) {
|
|
113
|
-
if (!GSAP_CONFIG_KEYS.has(k)) otherTweenProps.add(k);
|
|
137
|
+
if (!GSAP_CONFIG_KEYS.has(k) && inGroup(k)) otherTweenProps.add(k);
|
|
114
138
|
}
|
|
115
139
|
}
|
|
116
140
|
}
|
|
117
141
|
}
|
|
118
142
|
} catch {}
|
|
119
|
-
for (const p of
|
|
143
|
+
for (const p of groupedPropKeys) otherTweenProps.delete(p);
|
|
120
144
|
|
|
121
145
|
// Tier 1: Transform + visual properties with universal CSS defaults.
|
|
122
146
|
// Safe to compare against hardcoded values — these are always 0 or 1
|
|
@@ -148,11 +172,11 @@ export function readAllAnimatedProperties(
|
|
|
148
172
|
// Collect all properties that ANY tween on this element explicitly targets.
|
|
149
173
|
// Only capture baseline values for these — GSAP reports non-default values
|
|
150
174
|
// (scaleZ=0, brightness=0) for untouched properties, polluting keyframes.
|
|
151
|
-
const allTweenedProps = new Set([...
|
|
175
|
+
const allTweenedProps = new Set([...groupedPropKeys, ...otherTweenProps]);
|
|
152
176
|
for (const [prop, defaultVal] of Object.entries(UNIVERSAL_BASELINE)) {
|
|
153
177
|
if (prop in result) continue;
|
|
154
178
|
if (!allTweenedProps.has(prop)) continue;
|
|
155
|
-
const val =
|
|
179
|
+
const val = readLiveGsapValue(gsap, el, prop);
|
|
156
180
|
if (Number.isFinite(val) && Math.round(val * 1000) !== Math.round(defaultVal * 1000)) {
|
|
157
181
|
result[prop] = roundTo3(val);
|
|
158
182
|
}
|
|
@@ -184,6 +208,7 @@ export function readAllAnimatedProperties(
|
|
|
184
208
|
} catch {}
|
|
185
209
|
for (const prop of COMPUTED_BASELINE) {
|
|
186
210
|
if (prop in result) continue;
|
|
211
|
+
if (!inGroup(prop)) continue;
|
|
187
212
|
if (otherTweenProps.has(prop)) continue;
|
|
188
213
|
const gsapVal = Number(gsap.getProperty(el, prop));
|
|
189
214
|
if (!Number.isFinite(gsapVal)) continue;
|
|
@@ -39,15 +39,16 @@ type Commit = (
|
|
|
39
39
|
) => Promise<void>;
|
|
40
40
|
|
|
41
41
|
/** Renders the hook and hands its commit function to the caller via a ref callback. */
|
|
42
|
-
function
|
|
43
|
-
|
|
42
|
+
function renderHookWith(
|
|
43
|
+
animations: GsapAnimation[],
|
|
44
|
+
onMutation: (mutation: Record<string, unknown>, label: string) => void,
|
|
44
45
|
onReady: (commit: Commit) => void,
|
|
45
46
|
) {
|
|
46
47
|
function Harness() {
|
|
47
48
|
const { commitAnimatedProperties } = useAnimatedPropertyCommit({
|
|
48
|
-
selectedGsapAnimations:
|
|
49
|
-
gsapCommitMutation: async (_sel, mutation) => {
|
|
50
|
-
|
|
49
|
+
selectedGsapAnimations: animations,
|
|
50
|
+
gsapCommitMutation: async (_sel, mutation, options) => {
|
|
51
|
+
onMutation(mutation, options.label);
|
|
51
52
|
},
|
|
52
53
|
addGsapAnimation: vi.fn(),
|
|
53
54
|
convertToKeyframes: vi.fn(),
|
|
@@ -66,6 +67,13 @@ function renderCommitHook(
|
|
|
66
67
|
return root;
|
|
67
68
|
}
|
|
68
69
|
|
|
70
|
+
function renderCommitHook(
|
|
71
|
+
mutations: Array<Record<string, unknown>>,
|
|
72
|
+
onReady: (commit: Commit) => void,
|
|
73
|
+
) {
|
|
74
|
+
return renderHookWith([keyframedAnim], (mutation) => mutations.push(mutation), onReady);
|
|
75
|
+
}
|
|
76
|
+
|
|
69
77
|
// Regression (#1808): a "3D transform" / design-panel property edit on an
|
|
70
78
|
// element that already has a keyframed tween is the ACTUAL path a manual
|
|
71
79
|
// canvas nudge exercises (not the raw drag intercept) — with auto-keyframe
|
|
@@ -103,3 +111,56 @@ describe("useAnimatedPropertyCommit — autoKeyframeEnabled toggle (#1808)", ()
|
|
|
103
111
|
act(() => root.unmount());
|
|
104
112
|
});
|
|
105
113
|
});
|
|
114
|
+
|
|
115
|
+
// Regression: commitStaticSet picked the FIRST `set` for the selector with no
|
|
116
|
+
// group check — a panel W edit on a static element merged `width` into the
|
|
117
|
+
// POSITION set (`tl.set("#el",{x,y,width})`), a mixed-group set the split
|
|
118
|
+
// machinery exists to prevent, labeled "Set 3D transform" in undo history.
|
|
119
|
+
describe("commitStaticSet group routing", () => {
|
|
120
|
+
const positionSet = {
|
|
121
|
+
id: "#box-set-0-position",
|
|
122
|
+
targetSelector: "#box",
|
|
123
|
+
propertyGroup: "position",
|
|
124
|
+
method: "set",
|
|
125
|
+
properties: { x: 10, y: 20 },
|
|
126
|
+
} as unknown as GsapAnimation;
|
|
127
|
+
|
|
128
|
+
function renderStaticHook(
|
|
129
|
+
committed: Array<{ mutation: Record<string, unknown>; label: string }>,
|
|
130
|
+
onReady: (commit: Commit) => void,
|
|
131
|
+
) {
|
|
132
|
+
return renderHookWith(
|
|
133
|
+
[positionSet],
|
|
134
|
+
(mutation, label) => committed.push({ mutation, label }),
|
|
135
|
+
onReady,
|
|
136
|
+
);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
it("width edit creates a size set instead of contaminating the position set", async () => {
|
|
140
|
+
const committed: Array<{ mutation: Record<string, unknown>; label: string }> = [];
|
|
141
|
+
let commit!: Commit;
|
|
142
|
+
renderStaticHook(committed, (c) => (commit = c));
|
|
143
|
+
await act(async () => {
|
|
144
|
+
await commit(selection, { width: 500 });
|
|
145
|
+
});
|
|
146
|
+
const updates = committed.filter((c) => c.mutation.type === "update-properties");
|
|
147
|
+
expect(updates).toHaveLength(0);
|
|
148
|
+
const adds = committed.filter((c) => c.mutation.type === "add");
|
|
149
|
+
expect(adds).toHaveLength(1);
|
|
150
|
+
expect(adds[0]!.mutation.properties).toEqual({ width: 500 });
|
|
151
|
+
expect(adds[0]!.label).toBe("Resize layer");
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
it("x edit updates the position set with a Move label", async () => {
|
|
155
|
+
const committed: Array<{ mutation: Record<string, unknown>; label: string }> = [];
|
|
156
|
+
let commit!: Commit;
|
|
157
|
+
renderStaticHook(committed, (c) => (commit = c));
|
|
158
|
+
await act(async () => {
|
|
159
|
+
await commit(selection, { x: 400 });
|
|
160
|
+
});
|
|
161
|
+
const update = committed.find((c) => c.mutation.type === "update-properties");
|
|
162
|
+
expect(update).toBeDefined();
|
|
163
|
+
expect(update!.mutation.animationId).toBe("#box-set-0-position");
|
|
164
|
+
expect(update!.label).toBe("Move layer");
|
|
165
|
+
});
|
|
166
|
+
});
|
|
@@ -103,6 +103,22 @@ async function maybeAutoKeyframeSet(
|
|
|
103
103
|
|
|
104
104
|
type Commit = NonNullable<CommitAnimatedPropertyDeps["gsapCommitMutation"]>;
|
|
105
105
|
|
|
106
|
+
/** Undo-history label for a static-set commit, from the group it writes. */
|
|
107
|
+
const STATIC_SET_LABELS: Partial<Record<ReturnType<typeof classifyPropertyGroup>, string>> = {
|
|
108
|
+
position: "Move layer",
|
|
109
|
+
scale: "Resize layer",
|
|
110
|
+
size: "Resize layer",
|
|
111
|
+
rotation: "Rotate layer",
|
|
112
|
+
visual: "Set opacity",
|
|
113
|
+
other: "Set 3D transform",
|
|
114
|
+
};
|
|
115
|
+
|
|
116
|
+
function staticSetLabel(propEntries: [string, number | string][]): string {
|
|
117
|
+
const groups = new Set(propEntries.map(([k]) => classifyPropertyGroup(k)));
|
|
118
|
+
const only = groups.size === 1 ? [...groups][0] : undefined;
|
|
119
|
+
return (only && STATIC_SET_LABELS[only]) || "Set properties";
|
|
120
|
+
}
|
|
121
|
+
|
|
106
122
|
/** Merge ALL props into the static `set` in ONE commit (value-only, instant), then
|
|
107
123
|
* auto-keyframe. One mutation — a per-property loop would shift the set's
|
|
108
124
|
* group-derived id mid-way (e.g. reset adding `scale` to a rotation set), 404-ing
|
|
@@ -133,7 +149,11 @@ async function commitSetProps(
|
|
|
133
149
|
await commit(
|
|
134
150
|
selection,
|
|
135
151
|
{ type: "update-properties", animationId: setAnim.id, properties },
|
|
136
|
-
{
|
|
152
|
+
{
|
|
153
|
+
label: staticSetLabel(propEntries),
|
|
154
|
+
softReload: true,
|
|
155
|
+
...(instantPatch ? { instantPatch } : {}),
|
|
156
|
+
},
|
|
137
157
|
);
|
|
138
158
|
await maybeAutoKeyframeSet(selection, setAnim, animations, commit);
|
|
139
159
|
}
|
|
@@ -152,22 +172,70 @@ async function commitStaticSet(
|
|
|
152
172
|
commit: Commit,
|
|
153
173
|
): Promise<void> {
|
|
154
174
|
if (!selector) return;
|
|
155
|
-
//
|
|
156
|
-
//
|
|
157
|
-
//
|
|
158
|
-
//
|
|
159
|
-
//
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
175
|
+
// One commit per PROPERTY GROUP, each into a set that owns that group — never a
|
|
176
|
+
// flat `to`/`from`, and never a foreign-group set (a width edit used to merge
|
|
177
|
+
// into the element's position set, producing a mixed set the split machinery
|
|
178
|
+
// exists to prevent). Within a group everything batches into ONE commit: a
|
|
179
|
+
// set's id is group-derived, so a per-prop loop would shift the id mid-way and
|
|
180
|
+
// 404 the next update.
|
|
181
|
+
const byGroup = new Map<string, [string, number | string][]>();
|
|
182
|
+
for (const entry of propEntries) {
|
|
183
|
+
const group = classifyPropertyGroup(entry[0]);
|
|
184
|
+
const batch = byGroup.get(group) ?? [];
|
|
185
|
+
batch.push(entry);
|
|
186
|
+
byGroup.set(group, batch);
|
|
187
|
+
}
|
|
188
|
+
const sets = animations.filter((a) => a.method === "set" && a.targetSelector === selector);
|
|
189
|
+
// Resolve every group's target BEFORE committing anything, and coalesce
|
|
190
|
+
// groups that land on the SAME set into one commit: the `sets` snapshot is
|
|
191
|
+
// captured once, so if two groups resolved to one legacy mixed set, a first
|
|
192
|
+
// commit could re-shape it server-side and leave the second chasing a stale
|
|
193
|
+
// id (404 on legacy pre-split files).
|
|
194
|
+
const byTargetSet = new Map<GsapAnimation, [string, number | string][]>();
|
|
195
|
+
const newSetBatches: [string, number | string][][] = [];
|
|
196
|
+
for (const [group, batch] of byGroup) {
|
|
197
|
+
const existingSet = findGroupOwningSet(sets, group);
|
|
198
|
+
if (existingSet) {
|
|
199
|
+
byTargetSet.set(existingSet, [...(byTargetSet.get(existingSet) ?? []), ...batch]);
|
|
200
|
+
} else {
|
|
201
|
+
newSetBatches.push(batch);
|
|
202
|
+
}
|
|
164
203
|
}
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
//
|
|
204
|
+
for (const [targetSet, batch] of byTargetSet) {
|
|
205
|
+
await commitSetProps(selection, targetSet, batch, selector, animations, commit);
|
|
206
|
+
}
|
|
207
|
+
// Fresh adds don't reshape existing sets, so their ids can't go stale.
|
|
208
|
+
for (const batch of newSetBatches) {
|
|
209
|
+
await addGlobalStaticSet(selection, batch, selector, commit);
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* The set that owns a property group: one already dedicated to the group wins;
|
|
215
|
+
* else a mixed set that already carries a property of the group (merging
|
|
216
|
+
* same-group values there beats spawning a second writer for the channel).
|
|
217
|
+
*/
|
|
218
|
+
function findGroupOwningSet(sets: GsapAnimation[], group: string): GsapAnimation | undefined {
|
|
219
|
+
return (
|
|
220
|
+
sets.find((a) => a.propertyGroup === group) ??
|
|
221
|
+
sets.find((a) => Object.keys(a.properties).some((k) => classifyPropertyGroup(k) === group))
|
|
222
|
+
);
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/**
|
|
226
|
+
* Base `gsap.set` (off-timeline) — a static hold with no 0% keyframe marker, so
|
|
227
|
+
* adjusting a 3D transform on a non-keyframed element doesn't drop a keyframe on
|
|
228
|
+
* the timeline (matches the manual-drag UX). The global-set instant patch applies
|
|
229
|
+
* it straight to the element so the first edit shows with no soft-reload flash.
|
|
230
|
+
*/
|
|
231
|
+
async function addGlobalStaticSet(
|
|
232
|
+
selection: DomEditSelection,
|
|
233
|
+
batch: [string, number | string][],
|
|
234
|
+
selector: string,
|
|
235
|
+
commit: Commit,
|
|
236
|
+
): Promise<void> {
|
|
169
237
|
const numericProps: SetPatchProps = {};
|
|
170
|
-
for (const [k, v] of
|
|
238
|
+
for (const [k, v] of batch) {
|
|
171
239
|
if (typeof v === "number") numericProps[k as keyof SetPatchProps] = v;
|
|
172
240
|
}
|
|
173
241
|
await commit(
|
|
@@ -177,11 +245,11 @@ async function commitStaticSet(
|
|
|
177
245
|
targetSelector: selector,
|
|
178
246
|
method: "set",
|
|
179
247
|
position: 0,
|
|
180
|
-
properties: Object.fromEntries(
|
|
248
|
+
properties: Object.fromEntries(batch),
|
|
181
249
|
global: true,
|
|
182
250
|
},
|
|
183
251
|
{
|
|
184
|
-
label:
|
|
252
|
+
label: staticSetLabel(batch),
|
|
185
253
|
softReload: true,
|
|
186
254
|
...(Object.keys(numericProps).length > 0
|
|
187
255
|
? {
|
|
@@ -10,11 +10,8 @@
|
|
|
10
10
|
import { useCallback } from "react";
|
|
11
11
|
import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
|
|
12
12
|
import type { DomEditSelection } from "../components/editor/domEditingTypes";
|
|
13
|
-
import {
|
|
14
|
-
|
|
15
|
-
tryGsapResizeIntercept,
|
|
16
|
-
tryGsapRotationIntercept,
|
|
17
|
-
} from "./gsapRuntimeBridge";
|
|
13
|
+
import { tryGsapDragIntercept, tryGsapRotationIntercept } from "./gsapRuntimeBridge";
|
|
14
|
+
import { tryGsapResizeIntercept } from "./gsapResizeIntercept";
|
|
18
15
|
import { useAnimatedPropertyCommit } from "./useAnimatedPropertyCommit";
|
|
19
16
|
import {
|
|
20
17
|
useGsapSaveFailureTelemetry,
|