@hyperframes/studio 0.7.99 → 0.7.100
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/assets/{hyperframes-player-BY5RLMBA.js → hyperframes-player-D0x4bQn-.js} +1 -1
- package/dist/assets/{index-DMSqmZM6.js → index-CVAZ9AJE.js} +200 -200
- package/dist/assets/{index-CQ07_DLG.js → index-Cy1eXOyE.js} +1 -1
- package/dist/assets/{index-DeXLAktv.js → index-F8iVj3EF.js} +1 -1
- package/dist/index.html +1 -1
- package/dist/index.js +440 -344
- package/dist/index.js.map +1 -1
- package/package.json +7 -7
- package/src/App.tsx +2 -2
- package/src/components/StudioHeader.test.ts +28 -0
- package/src/components/StudioHeader.tsx +21 -2
- package/src/components/StudioLeftSidebar.tsx +2 -2
- package/src/components/nle/NLEContext.tsx +16 -7
- package/src/components/nle/TimelineResizeDivider.tsx +4 -11
- package/src/contexts/PanelLayoutContext.tsx +6 -3
- package/src/hooks/gsapEditOutcome.ts +19 -1
- package/src/hooks/gsapResizeGeometrySweep.test.ts +289 -0
- package/src/hooks/gsapResizeIntercept.test.ts +2 -2
- package/src/hooks/gsapResizeIntercept.ts +37 -14
- package/src/hooks/gsapResizeMixedTween.test.ts +168 -0
- package/src/hooks/gsapResizeSweep.test.ts +274 -0
- package/src/hooks/useGsapAwareEditing.test.tsx +19 -2
- package/src/hooks/useGsapAwareEditing.ts +13 -5
- package/src/hooks/usePanelLayout.test.ts +117 -0
- package/src/hooks/usePanelLayout.ts +121 -39
- package/src/utils/fitPanels.test.ts +164 -0
- package/src/utils/fitPanels.ts +151 -0
|
@@ -149,7 +149,20 @@ export async function tryGsapResizeIntercept(
|
|
|
149
149
|
if (!anim || isInstantHold(anim)) {
|
|
150
150
|
const sel = selectorFromSelection(selection) ?? writeTargetSelector(selection);
|
|
151
151
|
if (!sel) return { status: "blocked", reason: "no-selector" };
|
|
152
|
-
|
|
152
|
+
// A scale hold is not a size hold.
|
|
153
|
+
//
|
|
154
|
+
// `anim` is the tween resolved for THIS resize's group, and for a
|
|
155
|
+
// scale-driven element that is the one carrying `scale`. Handing it to the
|
|
156
|
+
// size commit wrote `width` and `height` into it, leaving one tween that
|
|
157
|
+
// spans two property groups — which the parser then classifies as neither,
|
|
158
|
+
// so it loses its group suffix and its id along with it. Every later edit
|
|
159
|
+
// of that element looked for a scale tween and a size tween, found no
|
|
160
|
+
// group at all, and the element became uneditable: "animation not found".
|
|
161
|
+
// Size goes to a size hold of its own, and the scale hold is left alone.
|
|
162
|
+
const sizeSet =
|
|
163
|
+
resizeGroup === "size"
|
|
164
|
+
? (anim ?? findSizeSetAnimation(workingAnimations, sel, selection.element))
|
|
165
|
+
: findSizeSetAnimation(workingAnimations, sel, selection.element);
|
|
153
166
|
|
|
154
167
|
// If the element is animated (has a real tween, not just a static size
|
|
155
168
|
// hold), keyframe the size at the playhead so other keyframes keep theirs —
|
|
@@ -239,7 +252,14 @@ export async function tryGsapResizeIntercept(
|
|
|
239
252
|
// and the longhands win: the resize commits correctly and then does
|
|
240
253
|
// nothing, and the element snaps back to its old size on release. The
|
|
241
254
|
// tween never mixes the two forms in either direction.
|
|
242
|
-
|
|
255
|
+
//
|
|
256
|
+
// "Agree" is measured in PIXELS, not in scale. A fixed 0.01 of scale is
|
|
257
|
+
// invisible on a 40px box and two pixels of height on a 408px one, so a
|
|
258
|
+
// free drag whose axes happened to land within it silently gave back a box
|
|
259
|
+
// shorter than the one dropped. The question is only ever whether using one
|
|
260
|
+
// value for both axes would move an edge, so ask that.
|
|
261
|
+
const uniformDrift = Math.abs(newScaleX - newScaleY) * cssH;
|
|
262
|
+
nonUniformScale = uniformDrift > 0.5;
|
|
243
263
|
useScaleLonghands = nonUniformScale || tweenUsesScaleLonghands(anim);
|
|
244
264
|
resizeProps = useScaleLonghands
|
|
245
265
|
? { scaleX: newScaleX, scaleY: newScaleY }
|
|
@@ -290,10 +310,13 @@ export async function tryGsapResizeIntercept(
|
|
|
290
310
|
// ponytail: for a 3D-rotated element the rects are AABBs, so the anchor is
|
|
291
311
|
// approximate rather than corner-exact.
|
|
292
312
|
// fallow-ignore-next-line complexity
|
|
293
|
-
const finalizeScaleResizeCommit = async () => {
|
|
294
|
-
|
|
313
|
+
const finalizeScaleResizeCommit = async (): Promise<boolean> => {
|
|
314
|
+
// Only the scale route captures the element, so a null draft means this
|
|
315
|
+
// resize took the size route and never moved anything: the drop point is
|
|
316
|
+
// the drag's to settle, not ours.
|
|
317
|
+
if (!scaleDraftEl) return false;
|
|
295
318
|
clearStudioBoxSize(scaleDraftEl);
|
|
296
|
-
if (!scaleDraftDropPoint || !selector) return;
|
|
319
|
+
if (!scaleDraftDropPoint || !selector) return false;
|
|
297
320
|
// Put the committed scale on the live element before measuring.
|
|
298
321
|
//
|
|
299
322
|
// This step reads where the commit lands the box and shifts the position
|
|
@@ -331,10 +354,12 @@ export async function tryGsapResizeIntercept(
|
|
|
331
354
|
setElementGsapPosition(scaleDraftEl, base.x, base.y);
|
|
332
355
|
const post = scaleDraftEl.getBoundingClientRect();
|
|
333
356
|
const residual = { x: scaleDraftDropPoint.x - post.x, y: scaleDraftDropPoint.y - post.y };
|
|
334
|
-
if (!Number.isFinite(residual.x) || !Number.isFinite(residual.y)) return;
|
|
357
|
+
if (!Number.isFinite(residual.x) || !Number.isFinite(residual.y)) return false;
|
|
335
358
|
if (Math.abs(residual.x) < 0.5 && Math.abs(residual.y) < 0.5) {
|
|
336
359
|
logResize("scale-finalize", { skipped: "already-on-drop-point", residual, base });
|
|
337
|
-
|
|
360
|
+
// Settled, with nothing to write. Still ours: forwarding the drag offset
|
|
361
|
+
// on top would move the box off the point it is already sitting on.
|
|
362
|
+
return true;
|
|
338
363
|
}
|
|
339
364
|
// The ONE corrected position — rounded once so the live runtime and the
|
|
340
365
|
// persisted file agree exactly (commitStaticGsapPosition composes the same
|
|
@@ -385,13 +410,14 @@ export async function tryGsapResizeIntercept(
|
|
|
385
410
|
commitMutation,
|
|
386
411
|
fetchAnimations: fetchFallbackAnimations,
|
|
387
412
|
});
|
|
388
|
-
return;
|
|
413
|
+
return true;
|
|
389
414
|
}
|
|
390
415
|
const existingSet = findExistingPositionWrite(currentAnimations, selector, selection.element);
|
|
391
416
|
await commitStaticGsapPosition(selection, delta, base, selector, existingSet, {
|
|
392
417
|
commitMutation,
|
|
393
418
|
fetchAnimations: fetchFallbackAnimations,
|
|
394
419
|
});
|
|
420
|
+
return true;
|
|
395
421
|
};
|
|
396
422
|
|
|
397
423
|
// With auto-keyframe off (#1808), `anim` is already a real (non-"set")
|
|
@@ -408,8 +434,7 @@ export async function tryGsapResizeIntercept(
|
|
|
408
434
|
{ commitMutation, fetchAnimations: fetchFallbackAnimations },
|
|
409
435
|
"Resize animation",
|
|
410
436
|
);
|
|
411
|
-
await finalizeScaleResizeCommit();
|
|
412
|
-
return { status: "persisted" };
|
|
437
|
+
return { status: "persisted", ownsDragOffset: await finalizeScaleResizeCommit() };
|
|
413
438
|
}
|
|
414
439
|
|
|
415
440
|
const ct = usePlayerStore.getState().currentTime;
|
|
@@ -520,8 +545,7 @@ export async function tryGsapResizeIntercept(
|
|
|
520
545
|
softReload: true,
|
|
521
546
|
},
|
|
522
547
|
);
|
|
523
|
-
await finalizeScaleResizeCommit();
|
|
524
|
-
return { status: "persisted" };
|
|
548
|
+
return { status: "persisted", ownsDragOffset: await finalizeScaleResizeCommit() };
|
|
525
549
|
}
|
|
526
550
|
|
|
527
551
|
const SIZE_PROPS = new Set(["width", "height"]);
|
|
@@ -542,8 +566,7 @@ export async function tryGsapResizeIntercept(
|
|
|
542
566
|
},
|
|
543
567
|
{ label: `Resize (keyframe ${pct}%)`, softReload: true },
|
|
544
568
|
);
|
|
545
|
-
await finalizeScaleResizeCommit();
|
|
546
|
-
return { status: "persisted" };
|
|
569
|
+
return { status: "persisted", ownsDragOffset: await finalizeScaleResizeCommit() };
|
|
547
570
|
}
|
|
548
571
|
|
|
549
572
|
// ── Rotation intercept ────────────────────────────────────────────────────
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
// @vitest-environment happy-dom
|
|
2
|
+
/**
|
|
3
|
+
* Resizing `#card` in the shipped playground fails with "animation not found".
|
|
4
|
+
*
|
|
5
|
+
* The element carries five tweens, all at position 0 and all duration 0, and
|
|
6
|
+
* one of them mixes `scale` with `width`/`height`. That spans two property
|
|
7
|
+
* groups, so the parser gives it no group at all and the bare id `#card-to-0` —
|
|
8
|
+
* which means the resize finds neither a scale tween nor a size tween and has
|
|
9
|
+
* to split the mixed one apart before it can commit.
|
|
10
|
+
*
|
|
11
|
+
* This drives the real intercept against that exact set, with a server stand-in
|
|
12
|
+
* that answers the way the real one does: an id it cannot find is a 404. Any id
|
|
13
|
+
* the intercept sends that is not in the list it was last handed reproduces the
|
|
14
|
+
* failure.
|
|
15
|
+
*/
|
|
16
|
+
import { afterEach, expect, it, vi } from "vitest";
|
|
17
|
+
import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
|
|
18
|
+
import type { DomEditSelection } from "../components/editor/domEditingTypes";
|
|
19
|
+
import { usePlayerStore } from "../player/store/playerStore";
|
|
20
|
+
import { tryGsapResizeIntercept } from "./gsapResizeIntercept";
|
|
21
|
+
|
|
22
|
+
afterEach(() => {
|
|
23
|
+
vi.restoreAllMocks();
|
|
24
|
+
usePlayerStore.setState({ currentTime: 0, activeKeyframePct: null });
|
|
25
|
+
document.body.innerHTML = "";
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
function hold(
|
|
29
|
+
id: string,
|
|
30
|
+
group: string | undefined,
|
|
31
|
+
properties: Record<string, number>,
|
|
32
|
+
): GsapAnimation {
|
|
33
|
+
return {
|
|
34
|
+
id,
|
|
35
|
+
targetSelector: "#card",
|
|
36
|
+
propertyGroup: group,
|
|
37
|
+
method: "to",
|
|
38
|
+
properties,
|
|
39
|
+
position: 0,
|
|
40
|
+
resolvedStart: 0,
|
|
41
|
+
duration: 0,
|
|
42
|
+
extras: { immediateRender: "__raw:true" },
|
|
43
|
+
} as unknown as GsapAnimation;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** `#card` as the playground actually holds it. */
|
|
47
|
+
function cardAnimations(): GsapAnimation[] {
|
|
48
|
+
return [
|
|
49
|
+
hold("#card-to-0-other", "other", { rotationY: -540, rotationX: 720, _auto: 0 }),
|
|
50
|
+
hold("#card-to-0-rotation", "rotation", { rotation: 720 }),
|
|
51
|
+
hold("#card-to-0", undefined, { scale: 1.2, width: 326, height: 213 }),
|
|
52
|
+
hold("#card-to-0-position", "position", { x: -1180, y: 128 }),
|
|
53
|
+
hold("#card-to-0-other-2", "other", { z: 50 }),
|
|
54
|
+
];
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
it("never sends an animation id the server does not have", async () => {
|
|
58
|
+
const el = document.createElement("div");
|
|
59
|
+
el.id = "card";
|
|
60
|
+
el.setAttribute("data-hf-studio-original-box-width", "326");
|
|
61
|
+
el.setAttribute("data-hf-studio-original-box-height", "213");
|
|
62
|
+
document.body.append(el);
|
|
63
|
+
|
|
64
|
+
// The server's view of the file, and its 404.
|
|
65
|
+
let current = cardAnimations();
|
|
66
|
+
const rejected: string[] = [];
|
|
67
|
+
const sent: string[] = [];
|
|
68
|
+
const commitMutation = vi.fn(async (_selection: unknown, mutation: Record<string, unknown>) => {
|
|
69
|
+
const animationId = mutation.animationId as string | undefined;
|
|
70
|
+
if (animationId) {
|
|
71
|
+
sent.push(`${String(mutation.type)}:${animationId}`);
|
|
72
|
+
if (!current.some((a) => a.id === animationId)) {
|
|
73
|
+
rejected.push(animationId);
|
|
74
|
+
throw new Error("animation not found");
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
// Splitting the mixed tween is what the real server does with it.
|
|
78
|
+
if (mutation.type === "split-into-property-groups") {
|
|
79
|
+
current = [
|
|
80
|
+
hold("#card-to-0-other", "other", { rotationY: -540, rotationX: 720, _auto: 0 }),
|
|
81
|
+
hold("#card-to-0-rotation", "rotation", { rotation: 720 }),
|
|
82
|
+
hold("#card-to-0-position", "position", { x: -1180, y: 128 }),
|
|
83
|
+
hold("#card-to-0-other-2", "other", { z: 50 }),
|
|
84
|
+
hold("#card-to-0-scale", "scale", { scale: 1.2 }),
|
|
85
|
+
hold("#card-to-0-size", "size", { width: 326, height: 213 }),
|
|
86
|
+
];
|
|
87
|
+
}
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
const selection = { id: "card", selector: "#card", element: el } as DomEditSelection;
|
|
91
|
+
await tryGsapResizeIntercept(
|
|
92
|
+
selection,
|
|
93
|
+
{ width: 500, height: 320 },
|
|
94
|
+
cardAnimations(),
|
|
95
|
+
null,
|
|
96
|
+
commitMutation as never,
|
|
97
|
+
async () => current,
|
|
98
|
+
);
|
|
99
|
+
|
|
100
|
+
expect(rejected).toEqual([]);
|
|
101
|
+
expect(sent).not.toHaveLength(0);
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* How `#card` got into that state: a resize wrote `width`/`height` into the
|
|
106
|
+
* tween that carried `scale`.
|
|
107
|
+
*
|
|
108
|
+
* One tween spanning two property groups is classified as neither, so it loses
|
|
109
|
+
* its group suffix and its id with it — and every later edit of the element
|
|
110
|
+
* looks for a scale tween and a size tween, finds no group at all, and fails.
|
|
111
|
+
*/
|
|
112
|
+
it("does not write size into the tween that carries scale", async () => {
|
|
113
|
+
const el = document.createElement("div");
|
|
114
|
+
el.id = "card";
|
|
115
|
+
el.setAttribute("data-hf-studio-original-box-width", "630");
|
|
116
|
+
el.setAttribute("data-hf-studio-original-box-height", "408");
|
|
117
|
+
document.body.append(el);
|
|
118
|
+
|
|
119
|
+
// The element before the damage: one instant scale hold, nothing else.
|
|
120
|
+
const scaleHold = hold("#card-to-0-scale", "scale", { scale: 1.2 });
|
|
121
|
+
const commitMutation = vi.fn();
|
|
122
|
+
|
|
123
|
+
await tryGsapResizeIntercept(
|
|
124
|
+
{ id: "card", selector: "#card", element: el } as DomEditSelection,
|
|
125
|
+
{ width: 326, height: 213 },
|
|
126
|
+
[scaleHold],
|
|
127
|
+
null,
|
|
128
|
+
commitMutation as never,
|
|
129
|
+
async () => [scaleHold],
|
|
130
|
+
);
|
|
131
|
+
|
|
132
|
+
const intoScaleHold = commitMutation.mock.calls
|
|
133
|
+
.map((call) => call[1] as { animationId?: string; properties?: Record<string, number> })
|
|
134
|
+
.filter((mutation) => mutation.animationId === "#card-to-0-scale")
|
|
135
|
+
.flatMap((mutation) => Object.keys(mutation.properties ?? {}));
|
|
136
|
+
|
|
137
|
+
expect(intoScaleHold).not.toContain("width");
|
|
138
|
+
expect(intoScaleHold).not.toContain("height");
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Whether the caller must persist the drag offset is the resize's answer to
|
|
143
|
+
* give, not something to infer from the element's tweens.
|
|
144
|
+
*
|
|
145
|
+
* An element whose scale is an instant hold HAS a scale-group tween and still
|
|
146
|
+
* commits width/height. Guessing from the tweens withheld an offset nobody had
|
|
147
|
+
* written, and the element snapped back to its authored position on every drag.
|
|
148
|
+
*/
|
|
149
|
+
it("leaves the drag offset to the caller when it commits size, not scale", async () => {
|
|
150
|
+
const el = document.createElement("div");
|
|
151
|
+
el.id = "card";
|
|
152
|
+
el.setAttribute("data-hf-studio-original-box-width", "630");
|
|
153
|
+
el.setAttribute("data-hf-studio-original-box-height", "408");
|
|
154
|
+
document.body.append(el);
|
|
155
|
+
|
|
156
|
+
const scaleHold = hold("#card-to-0-scale", "scale", { scale: 1.2 });
|
|
157
|
+
const outcome = await tryGsapResizeIntercept(
|
|
158
|
+
{ id: "card", selector: "#card", element: el } as DomEditSelection,
|
|
159
|
+
{ width: 326, height: 213 },
|
|
160
|
+
[scaleHold],
|
|
161
|
+
null,
|
|
162
|
+
vi.fn() as never,
|
|
163
|
+
async () => [scaleHold],
|
|
164
|
+
);
|
|
165
|
+
|
|
166
|
+
expect(outcome.status).toBe("persisted");
|
|
167
|
+
expect(outcome.status === "persisted" && outcome.ownsDragOffset).not.toBe(true);
|
|
168
|
+
});
|
|
@@ -0,0 +1,274 @@
|
|
|
1
|
+
// @vitest-environment happy-dom
|
|
2
|
+
/**
|
|
3
|
+
* Every shape of animated element a composition can hand the resize, swept.
|
|
4
|
+
*
|
|
5
|
+
* Both faults this branch fixes were found one composition at a time, which is
|
|
6
|
+
* a bad way to find the third. So this drives the real intercept across the
|
|
7
|
+
* cross-product of what an element's tweens can look like and holds every run
|
|
8
|
+
* to the two rules that were broken:
|
|
9
|
+
*
|
|
10
|
+
* 1. Never address an animation the source does not have. Sending a stale id
|
|
11
|
+
* is what "animation not found" is, and it leaves the element unsavable.
|
|
12
|
+
* 2. Never leave a tween spanning two property groups. The parser classifies
|
|
13
|
+
* such a tween as neither, so it loses its group suffix and its id along
|
|
14
|
+
* with it, and every later edit has nothing to address.
|
|
15
|
+
*
|
|
16
|
+
* The server stand-in answers the way the real one does — an id it cannot find
|
|
17
|
+
* is a rejection — and applies what it is told, so a run that corrupts the
|
|
18
|
+
* animation list is caught by the next mutation in the same run rather than by
|
|
19
|
+
* a person noticing weeks later.
|
|
20
|
+
*/
|
|
21
|
+
import { afterEach, expect, it, vi } from "vitest";
|
|
22
|
+
import { classifyTweenPropertyGroup } from "@hyperframes/core/gsap-parser";
|
|
23
|
+
import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
|
|
24
|
+
import type { DomEditSelection } from "../components/editor/domEditingTypes";
|
|
25
|
+
import { usePlayerStore } from "../player/store/playerStore";
|
|
26
|
+
import { tryGsapResizeIntercept } from "./gsapResizeIntercept";
|
|
27
|
+
|
|
28
|
+
afterEach(() => {
|
|
29
|
+
vi.restoreAllMocks();
|
|
30
|
+
usePlayerStore.setState({ currentTime: 0, activeKeyframePct: null });
|
|
31
|
+
document.body.innerHTML = "";
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
type Props = Record<string, number>;
|
|
35
|
+
|
|
36
|
+
function tween(id: string, properties: Props, duration: number): GsapAnimation {
|
|
37
|
+
return {
|
|
38
|
+
id,
|
|
39
|
+
targetSelector: "#el",
|
|
40
|
+
propertyGroup: classifyTweenPropertyGroup(properties),
|
|
41
|
+
method: "to",
|
|
42
|
+
properties,
|
|
43
|
+
position: 0,
|
|
44
|
+
resolvedStart: 0,
|
|
45
|
+
duration,
|
|
46
|
+
...(duration === 0 ? { extras: { immediateRender: "__raw:true" } } : {}),
|
|
47
|
+
} as unknown as GsapAnimation;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** The dimensions an element's animations actually vary across. */
|
|
51
|
+
const SCALE = {
|
|
52
|
+
none: null,
|
|
53
|
+
"instant hold": () => tween("#el-scale", { scale: 1.2 }, 0),
|
|
54
|
+
tween: () => tween("#el-scale", { scale: 1.2 }, 2),
|
|
55
|
+
longhands: () => tween("#el-scale", { scaleX: 1.2, scaleY: 1.1 }, 2),
|
|
56
|
+
} as const;
|
|
57
|
+
const SIZE = {
|
|
58
|
+
none: null,
|
|
59
|
+
"instant hold": () => tween("#el-size", { width: 300, height: 200 }, 0),
|
|
60
|
+
tween: () => tween("#el-size", { width: 300, height: 200 }, 2),
|
|
61
|
+
} as const;
|
|
62
|
+
const POSITION = {
|
|
63
|
+
none: null,
|
|
64
|
+
"static hold": () => tween("#el-position", { x: 40, y: 60 }, 0),
|
|
65
|
+
tween: () => tween("#el-position", { x: 40, y: 60 }, 2),
|
|
66
|
+
} as const;
|
|
67
|
+
const EXTRA = {
|
|
68
|
+
none: null,
|
|
69
|
+
// What a 3D card carries, and the shape that produced two same-group ids.
|
|
70
|
+
"3d and rotation": () => [
|
|
71
|
+
tween("#el-other", { rotationY: -540, rotationX: 720, _auto: 0 }, 0),
|
|
72
|
+
tween("#el-rotation", { rotation: 720 }, 0),
|
|
73
|
+
tween("#el-other-2", { z: 50 }, 0),
|
|
74
|
+
],
|
|
75
|
+
// A tween that already spans two groups, which the resize has to split.
|
|
76
|
+
"a mixed tween": () => [tween("#el-mixed", { scale: 1.2, width: 300, height: 200 }, 0)],
|
|
77
|
+
} as const;
|
|
78
|
+
|
|
79
|
+
interface Recorded {
|
|
80
|
+
rejected: string[];
|
|
81
|
+
corrupted: string[];
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* The source, as the server sees it: a list of animations, a rejection for an
|
|
86
|
+
* id that is not in it, and the effect of each mutation applied.
|
|
87
|
+
*/
|
|
88
|
+
function fakeSource(initial: GsapAnimation[]) {
|
|
89
|
+
let current = initial;
|
|
90
|
+
const recorded: Recorded = { rejected: [], corrupted: [] };
|
|
91
|
+
|
|
92
|
+
const mergeInto = (id: string, properties: Props) => {
|
|
93
|
+
current = current.map((animation) => {
|
|
94
|
+
if (animation.id !== id) return animation;
|
|
95
|
+
const before = classifyTweenPropertyGroup(animation.properties ?? {});
|
|
96
|
+
const merged = { ...(animation.properties ?? {}), ...properties };
|
|
97
|
+
const after = classifyTweenPropertyGroup(merged);
|
|
98
|
+
// Mixing is only a fault when the resize CAUSED it. A tween that already
|
|
99
|
+
// spanned two groups is an input, and splitting it is the point.
|
|
100
|
+
if (before !== undefined && after === undefined) recorded.corrupted.push(id);
|
|
101
|
+
return { ...animation, properties: merged, propertyGroup: after } as GsapAnimation;
|
|
102
|
+
});
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
const split = (id: string) => {
|
|
106
|
+
const target = current.find((animation) => animation.id === id);
|
|
107
|
+
if (!target) return;
|
|
108
|
+
const byGroup = new Map<string, Props>();
|
|
109
|
+
for (const [key, value] of Object.entries(target.properties ?? {})) {
|
|
110
|
+
const group = classifyTweenPropertyGroup({ [key]: value as number }) ?? "other";
|
|
111
|
+
byGroup.set(group, { ...(byGroup.get(group) ?? {}), [key]: value as number });
|
|
112
|
+
}
|
|
113
|
+
current = [
|
|
114
|
+
...current.filter((animation) => animation.id !== id),
|
|
115
|
+
...[...byGroup].map(([group, properties]) =>
|
|
116
|
+
tween(`#el-split-${group}`, properties, target.duration ?? 0),
|
|
117
|
+
),
|
|
118
|
+
];
|
|
119
|
+
};
|
|
120
|
+
|
|
121
|
+
const apply = (mutation: Record<string, unknown>, id: string | undefined) => {
|
|
122
|
+
const properties = (mutation.properties ?? {}) as Props;
|
|
123
|
+
if (mutation.type === "split-into-property-groups") return id && split(id);
|
|
124
|
+
if (!id) {
|
|
125
|
+
if (mutation.type === "add")
|
|
126
|
+
current = [...current, tween(`#el-added-${current.length}`, properties, 0)];
|
|
127
|
+
return;
|
|
128
|
+
}
|
|
129
|
+
mergeInto(id, properties);
|
|
130
|
+
const framed = (mutation.keyframes as Array<{ properties: Props }> | undefined) ?? [];
|
|
131
|
+
for (const frame of framed) mergeInto(id, frame.properties);
|
|
132
|
+
};
|
|
133
|
+
|
|
134
|
+
const commitMutation = vi.fn(async (_selection: unknown, mutation: Record<string, unknown>) => {
|
|
135
|
+
const id = mutation.animationId as string | undefined;
|
|
136
|
+
if (id && !current.some((animation) => animation.id === id)) {
|
|
137
|
+
recorded.rejected.push(`${String(mutation.type)}:${id}`);
|
|
138
|
+
throw new Error("animation not found");
|
|
139
|
+
}
|
|
140
|
+
apply(mutation, id);
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
return { commitMutation, recorded, animations: () => current };
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
type Dimension = Array<[string, () => GsapAnimation[]]>;
|
|
147
|
+
|
|
148
|
+
function dimension(
|
|
149
|
+
entries: Record<string, null | (() => GsapAnimation | GsapAnimation[])>,
|
|
150
|
+
): Dimension {
|
|
151
|
+
return Object.entries(entries).map(([name, make]) => [
|
|
152
|
+
name,
|
|
153
|
+
() => {
|
|
154
|
+
if (!make) return [];
|
|
155
|
+
const made = make();
|
|
156
|
+
return Array.isArray(made) ? made : [made];
|
|
157
|
+
},
|
|
158
|
+
]);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function buildCases() {
|
|
162
|
+
const dimensions = [dimension(SCALE), dimension(SIZE), dimension(POSITION), dimension(EXTRA)];
|
|
163
|
+
let combos: Array<Array<[string, () => GsapAnimation[]]>> = [[]];
|
|
164
|
+
for (const next of dimensions) {
|
|
165
|
+
combos = combos.flatMap((combo) => next.map((entry) => [...combo, entry]));
|
|
166
|
+
}
|
|
167
|
+
const labels = ["scale", "size", "position", "extra"];
|
|
168
|
+
return combos.map((combo) => ({
|
|
169
|
+
name: combo.map(([name], index) => `${labels[index]} ${name}`).join(" / "),
|
|
170
|
+
animations: combo.flatMap(([, make]) => make()),
|
|
171
|
+
}));
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
const CASES = buildCases();
|
|
175
|
+
|
|
176
|
+
it(`sweeps ${CASES.length} animated shapes without a stale id or a mixed tween`, async () => {
|
|
177
|
+
const failures: string[] = [];
|
|
178
|
+
|
|
179
|
+
for (const testCase of CASES) {
|
|
180
|
+
document.body.innerHTML = "";
|
|
181
|
+
const el = document.createElement("div");
|
|
182
|
+
el.id = "el";
|
|
183
|
+
el.setAttribute("data-hf-studio-original-box-width", "630");
|
|
184
|
+
el.setAttribute("data-hf-studio-original-box-height", "408");
|
|
185
|
+
document.body.append(el);
|
|
186
|
+
usePlayerStore.setState({ currentTime: 0, activeKeyframePct: null });
|
|
187
|
+
|
|
188
|
+
const source = fakeSource(testCase.animations);
|
|
189
|
+
try {
|
|
190
|
+
await tryGsapResizeIntercept(
|
|
191
|
+
{ id: "el", selector: "#el", element: el } as DomEditSelection,
|
|
192
|
+
{ width: 326, height: 213 },
|
|
193
|
+
testCase.animations,
|
|
194
|
+
null,
|
|
195
|
+
source.commitMutation as never,
|
|
196
|
+
async () => source.animations(),
|
|
197
|
+
);
|
|
198
|
+
} catch (error) {
|
|
199
|
+
// A rejection is recorded below; anything else is worth reporting as-is.
|
|
200
|
+
if (!(error instanceof Error) || error.message !== "animation not found") {
|
|
201
|
+
failures.push(`${testCase.name} — threw ${String(error)}`);
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
if (source.recorded.rejected.length > 0) {
|
|
206
|
+
failures.push(`${testCase.name} — stale id: ${source.recorded.rejected.join(", ")}`);
|
|
207
|
+
}
|
|
208
|
+
if (source.recorded.corrupted.length > 0) {
|
|
209
|
+
failures.push(`${testCase.name} — mixed tween: ${source.recorded.corrupted.join(", ")}`);
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
expect(failures).toEqual([]);
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* Which tween a resize edits when the element has several in the same group.
|
|
218
|
+
*
|
|
219
|
+
* A composition animates the same property more than once — a scale-in early
|
|
220
|
+
* and a scale-out late — and the one the user means is the one under the
|
|
221
|
+
* playhead. Editing the wrong one changes a moment they are not looking at and
|
|
222
|
+
* leaves the moment they ARE looking at unchanged, which reads as "the resize
|
|
223
|
+
* did nothing".
|
|
224
|
+
*/
|
|
225
|
+
function scaleAt(id: string, position: number): GsapAnimation {
|
|
226
|
+
return {
|
|
227
|
+
...tween(id, { scale: 1 }, 2),
|
|
228
|
+
position,
|
|
229
|
+
resolvedStart: position,
|
|
230
|
+
keyframes: {
|
|
231
|
+
keyframes: [
|
|
232
|
+
{ percentage: 0, properties: { scale: 1 } },
|
|
233
|
+
{ percentage: 100, properties: { scale: 1.4 } },
|
|
234
|
+
],
|
|
235
|
+
},
|
|
236
|
+
} as unknown as GsapAnimation;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
const PLAYHEADS: Array<[number, string]> = [
|
|
240
|
+
[0.5, "#el-early"],
|
|
241
|
+
[1.9, "#el-early"],
|
|
242
|
+
[3, "#el-early"],
|
|
243
|
+
[3.1, "#el-late"],
|
|
244
|
+
[4.5, "#el-late"],
|
|
245
|
+
[9, "#el-late"],
|
|
246
|
+
];
|
|
247
|
+
|
|
248
|
+
it.each(PLAYHEADS)("at t=%s edits the tween under the playhead (%s)", async (time, expected) => {
|
|
249
|
+
document.body.innerHTML = "";
|
|
250
|
+
const el = document.createElement("div");
|
|
251
|
+
el.id = "el";
|
|
252
|
+
el.setAttribute("data-hf-studio-original-box-width", "630");
|
|
253
|
+
el.setAttribute("data-hf-studio-original-box-height", "408");
|
|
254
|
+
document.body.append(el);
|
|
255
|
+
usePlayerStore.setState({ currentTime: time, activeKeyframePct: null });
|
|
256
|
+
|
|
257
|
+
const animations = [scaleAt("#el-early", 0), scaleAt("#el-late", 4)];
|
|
258
|
+
const commitMutation = vi.fn();
|
|
259
|
+
await tryGsapResizeIntercept(
|
|
260
|
+
{ id: "el", selector: "#el", element: el } as DomEditSelection,
|
|
261
|
+
{ width: 326, height: 213 },
|
|
262
|
+
animations,
|
|
263
|
+
null,
|
|
264
|
+
commitMutation as never,
|
|
265
|
+
async () => animations,
|
|
266
|
+
);
|
|
267
|
+
|
|
268
|
+
const touched = new Set(
|
|
269
|
+
commitMutation.mock.calls
|
|
270
|
+
.map((call) => (call[1] as { animationId?: string }).animationId)
|
|
271
|
+
.filter((id): id is string => id != null),
|
|
272
|
+
);
|
|
273
|
+
expect([...touched]).toEqual([expected]);
|
|
274
|
+
});
|
|
@@ -313,8 +313,8 @@ describe("useGsapAwareEditing anchored resize", () => {
|
|
|
313
313
|
act(() => h.root.unmount());
|
|
314
314
|
});
|
|
315
315
|
|
|
316
|
-
it("does not apply the anchor twice when
|
|
317
|
-
mocks.resize.mockResolvedValue({ status: "persisted" });
|
|
316
|
+
it("does not apply the anchor twice when the resize already settled the drop point", async () => {
|
|
317
|
+
mocks.resize.mockResolvedValue({ status: "persisted", ownsDragOffset: true });
|
|
318
318
|
const scale = { propertyGroup: "scale" } as GsapAnimation;
|
|
319
319
|
const h = mountResizeHandler([scale]);
|
|
320
320
|
await act(() => h.resize(h.selection, { width: 300, height: 200 }, { x: -50, y: -25 }));
|
|
@@ -322,4 +322,21 @@ describe("useGsapAwareEditing anchored resize", () => {
|
|
|
322
322
|
expect(h.fallback).not.toHaveBeenCalled();
|
|
323
323
|
act(() => h.root.unmount());
|
|
324
324
|
});
|
|
325
|
+
|
|
326
|
+
/**
|
|
327
|
+
* The same element, and the resize says it did NOT settle the drop point.
|
|
328
|
+
*
|
|
329
|
+
* This is the shape that broke: an element whose scale is an instant hold has
|
|
330
|
+
* a scale-group tween and still commits width/height. Reading the tweens said
|
|
331
|
+
* "scale route, it settles its own position", so the offset was withheld,
|
|
332
|
+
* nobody wrote it, and the element snapped back on every drag.
|
|
333
|
+
*/
|
|
334
|
+
it("applies the anchor when the resize leaves the drop point to the caller", async () => {
|
|
335
|
+
mocks.resize.mockResolvedValue({ status: "persisted" });
|
|
336
|
+
const scale = { propertyGroup: "scale" } as GsapAnimation;
|
|
337
|
+
const h = mountResizeHandler([scale]);
|
|
338
|
+
await act(() => h.resize(h.selection, { width: 300, height: 200 }, { x: -50, y: -25 }));
|
|
339
|
+
expect(mocks.drag).toHaveBeenCalledTimes(1);
|
|
340
|
+
act(() => h.root.unmount());
|
|
341
|
+
});
|
|
325
342
|
});
|
|
@@ -258,13 +258,21 @@ export function useGsapAwareEditing({
|
|
|
258
258
|
makeFetchFallback(selection),
|
|
259
259
|
);
|
|
260
260
|
assertGsapEditPersisted(outcome);
|
|
261
|
+
// What the resize actually did, not what its animations suggest
|
|
262
|
+
// it would do. An element whose scale is an instant hold has a
|
|
263
|
+
// scale-group tween and still commits width/height, so guessing
|
|
264
|
+
// from the tweens withheld an offset nobody had written and the
|
|
265
|
+
// element snapped back to its authored position on every drag.
|
|
266
|
+
const ownsDragOffset =
|
|
267
|
+
outcome.status === "persisted" && outcome.ownsDragOffset === true;
|
|
261
268
|
logResize("intercept-handled", {
|
|
262
269
|
scaleRoute,
|
|
263
|
-
|
|
270
|
+
ownsDragOffset,
|
|
271
|
+
willForwardOffset: !!(offset && !ownsDragOffset),
|
|
264
272
|
});
|
|
265
|
-
//
|
|
266
|
-
//
|
|
267
|
-
if (offset && !
|
|
273
|
+
// A resize that moved the element itself has already written
|
|
274
|
+
// where it landed. Everything else leaves the anchor to the drag.
|
|
275
|
+
if (offset && !ownsDragOffset) {
|
|
268
276
|
const dragOutcome = await tryGsapDragIntercept(
|
|
269
277
|
selection,
|
|
270
278
|
offset,
|
|
@@ -275,7 +283,7 @@ export function useGsapAwareEditing({
|
|
|
275
283
|
);
|
|
276
284
|
assertGsapEditPersisted(dragOutcome);
|
|
277
285
|
}
|
|
278
|
-
logResizeSettle(selection.element,
|
|
286
|
+
logResizeSettle(selection.element, ownsDragOffset ? "gsap-scale" : "gsap-size");
|
|
279
287
|
return;
|
|
280
288
|
} catch (error) {
|
|
281
289
|
trackGsapInteractionFailure(error, selection, "resize", "Resize animated layer");
|