@hyperframes/studio 0.7.96 → 0.7.98
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-CogOkBTX.js → hyperframes-player-B-R_2cNr.js} +1 -1
- package/dist/assets/{index-DfZlc9RM.js → index-ByovYZb8.js} +1 -1
- package/dist/assets/{index-OMTw4iIl.js → index-C3VqFrrP.js} +1 -1
- package/dist/assets/{index-YO-rhwSW.js → index-jdiZjBHE.js} +186 -186
- package/dist/index.html +1 -1
- package/dist/index.js +78 -29
- package/dist/index.js.map +1 -1
- package/package.json +7 -7
- package/src/components/editor/manualEditsDom.ts +14 -0
- package/src/components/editor/manualEditsDomPatches.test.ts +21 -2
- package/src/components/editor/manualEditsDomPatches.ts +5 -0
- package/src/components/editor/manualEditsSnapshot.ts +6 -0
- package/src/components/editor/manualEditsTypes.ts +14 -0
- package/src/hooks/gsapResizeDropPoint.test.ts +394 -0
- package/src/hooks/gsapResizeIntercept.test.ts +198 -0
- package/src/hooks/gsapResizeIntercept.ts +143 -35
- package/src/utils/elementGsap.ts +14 -0
|
@@ -284,3 +284,201 @@ it("non-uniform drag commits scaleX/scaleY longhands", async () => {
|
|
|
284
284
|
expect(serialized).toContain("scaleX");
|
|
285
285
|
expect(serialized).toContain("scaleY");
|
|
286
286
|
});
|
|
287
|
+
|
|
288
|
+
/**
|
|
289
|
+
* The bug: the original box size was read only from the element's INLINE
|
|
290
|
+
* width, and a composition sizes its elements from the stylesheet. With no
|
|
291
|
+
* inline width the code fell back to a hardcoded 200, so the committed scale
|
|
292
|
+
* came out `real / 200` times too large. Dropping a 630px chip at 2391px wide
|
|
293
|
+
* left it rendering at 7532px, over three times where it was dropped, and the
|
|
294
|
+
* next drag compounded it.
|
|
295
|
+
*/
|
|
296
|
+
// fallow-ignore-next-line code-duplication
|
|
297
|
+
it("scales from the element's real box, not a hardcoded fallback", async () => {
|
|
298
|
+
const el = document.createElement("div");
|
|
299
|
+
el.id = "clip";
|
|
300
|
+
// Sized by a stylesheet, so it carries no inline width, and the draft
|
|
301
|
+
// recorded the box it measured instead.
|
|
302
|
+
el.setAttribute("data-hf-studio-original-box-width", "630");
|
|
303
|
+
el.setAttribute("data-hf-studio-original-box-height", "252");
|
|
304
|
+
document.body.append(el);
|
|
305
|
+
const selection = { id: "clip", selector: "#clip", element: el } as DomEditSelection;
|
|
306
|
+
const commitMutation = vi.fn();
|
|
307
|
+
|
|
308
|
+
await tryGsapResizeIntercept(
|
|
309
|
+
selection,
|
|
310
|
+
{ width: 1260, height: 504 },
|
|
311
|
+
[keyframedScaleFixture()],
|
|
312
|
+
fakeIframe(el, { scaleX: 1, scaleY: 1 }),
|
|
313
|
+
commitMutation,
|
|
314
|
+
);
|
|
315
|
+
|
|
316
|
+
type Mutation = {
|
|
317
|
+
properties?: Record<string, number>;
|
|
318
|
+
keyframes?: Array<{ percentage: number; properties: Record<string, number> }>;
|
|
319
|
+
};
|
|
320
|
+
const committed = commitMutation.mock.calls
|
|
321
|
+
.map((call) => call[1] as Mutation)
|
|
322
|
+
.flatMap((mutation) => [
|
|
323
|
+
mutation.properties,
|
|
324
|
+
...(mutation.keyframes ?? []).map((frame) => frame.properties),
|
|
325
|
+
])
|
|
326
|
+
.filter((properties): properties is Record<string, number> => properties != null)
|
|
327
|
+
.find((properties) => properties.scale != null || properties.scaleX != null);
|
|
328
|
+
|
|
329
|
+
// Dropped at twice the element's own size, so the scale is about 2. The
|
|
330
|
+
// number that matters is that it is not the 6.3 which 1260/200 produced.
|
|
331
|
+
const scale = committed?.scale ?? committed?.scaleX ?? 0;
|
|
332
|
+
expect(scale).toBeCloseTo(2, 1);
|
|
333
|
+
expect(scale).toBeLessThan(3);
|
|
334
|
+
});
|
|
335
|
+
|
|
336
|
+
/**
|
|
337
|
+
* The bug: a uniform drag committed the `scale` shorthand into a tween whose
|
|
338
|
+
* keyframes already stated `scaleX`/`scaleY`. GSAP animates each property name
|
|
339
|
+
* independently, so the keyframe ran as `{ scaleX: 1, scaleY: 1, scale: 0.61 }`
|
|
340
|
+
* and the longhands won. The resize computed the right number, wrote it, and
|
|
341
|
+
* the element snapped straight back to its old size on release.
|
|
342
|
+
*/
|
|
343
|
+
// fallow-ignore-next-line code-duplication
|
|
344
|
+
it("does not mix the scale shorthand into a tween that speaks longhands", async () => {
|
|
345
|
+
const el = document.createElement("div");
|
|
346
|
+
el.id = "clip";
|
|
347
|
+
el.setAttribute("data-hf-studio-original-box-width", "630");
|
|
348
|
+
el.setAttribute("data-hf-studio-original-box-height", "252");
|
|
349
|
+
document.body.append(el);
|
|
350
|
+
const selection = { id: "clip", selector: "#clip", element: el } as DomEditSelection;
|
|
351
|
+
const longhandTween = {
|
|
352
|
+
...scaleFromTween(),
|
|
353
|
+
keyframes: {
|
|
354
|
+
keyframes: [
|
|
355
|
+
{ percentage: 0, properties: { scaleX: 1, scaleY: 1 } },
|
|
356
|
+
{ percentage: 100, properties: { scaleX: 1.15, scaleY: 1.15 } },
|
|
357
|
+
],
|
|
358
|
+
},
|
|
359
|
+
} as unknown as GsapAnimation;
|
|
360
|
+
const commitMutation = vi.fn();
|
|
361
|
+
|
|
362
|
+
// A uniform drop, so the old code took the shorthand branch and wrote
|
|
363
|
+
// `scale` into keyframes that already stated the longhands.
|
|
364
|
+
await tryGsapResizeIntercept(
|
|
365
|
+
selection,
|
|
366
|
+
{ width: 384, height: 216 },
|
|
367
|
+
[longhandTween],
|
|
368
|
+
fakeIframe(el, { scaleX: 1, scaleY: 1 }),
|
|
369
|
+
commitMutation,
|
|
370
|
+
);
|
|
371
|
+
|
|
372
|
+
const frames = commitMutation.mock.calls
|
|
373
|
+
.map((call) => call[1] as { keyframes?: Array<{ properties: Record<string, number> }> })
|
|
374
|
+
.flatMap((mutation) => mutation.keyframes ?? []);
|
|
375
|
+
expect(frames.length).toBeGreaterThan(0);
|
|
376
|
+
for (const frame of frames) {
|
|
377
|
+
const names = Object.keys(frame.properties);
|
|
378
|
+
const hasShorthand = names.includes("scale");
|
|
379
|
+
const hasLonghand = names.includes("scaleX") || names.includes("scaleY");
|
|
380
|
+
expect(hasShorthand && hasLonghand).toBe(false);
|
|
381
|
+
}
|
|
382
|
+
// And the resize still lands: 384/630 is about 0.61.
|
|
383
|
+
const resized = frames.find((frame) => frame.properties.scaleX != null);
|
|
384
|
+
expect(resized?.properties.scaleX).toBeCloseTo(0.61, 1);
|
|
385
|
+
});
|
|
386
|
+
|
|
387
|
+
/**
|
|
388
|
+
* The bug: a scale resize measured its drop-point correction while the
|
|
389
|
+
* gesture's own translation was still applied, but the position commit adds
|
|
390
|
+
* that correction onto the element's PRE-gesture position (it reads the
|
|
391
|
+
* gesture's base attributes). The two disagreed by the whole drag distance, so
|
|
392
|
+
* every scale resize of a statically positioned element persisted a position a
|
|
393
|
+
* drag-length away from where it was dropped — the element held still for one
|
|
394
|
+
* frame and then slid off.
|
|
395
|
+
*
|
|
396
|
+
* The fixture models the geometry the browser reported: a 630x252 element
|
|
397
|
+
* dragged from x=432 to x=587 with its box drafted down to 320x128, dropped at
|
|
398
|
+
* a committed scale of 0.837. Scaling about the centre puts it back on the drop
|
|
399
|
+
* point at its pre-gesture position, so the correct persisted correction is
|
|
400
|
+
* NONE.
|
|
401
|
+
*/
|
|
402
|
+
it("does not move a statically positioned element when a scale resize lands", async () => {
|
|
403
|
+
document.body.innerHTML = "";
|
|
404
|
+
const el = document.createElement("div");
|
|
405
|
+
el.id = "clip";
|
|
406
|
+
el.setAttribute("data-hf-studio-original-box-width", "630");
|
|
407
|
+
el.setAttribute("data-hf-studio-original-box-height", "252");
|
|
408
|
+
// The gesture's base pose — where the commit puts the element back, since a
|
|
409
|
+
// scale resize never persists the drag translation.
|
|
410
|
+
el.setAttribute("data-hf-drag-gsap-base-x", "432");
|
|
411
|
+
el.setAttribute("data-hf-drag-gsap-base-y", "173");
|
|
412
|
+
// The draft the gesture left applied: a smaller box at the dragged position.
|
|
413
|
+
el.setAttribute("data-hf-studio-box-size", "true");
|
|
414
|
+
el.setAttribute("data-hf-studio-original-width", "");
|
|
415
|
+
el.setAttribute("data-hf-studio-original-height", "");
|
|
416
|
+
el.style.width = "320px";
|
|
417
|
+
el.style.height = "128px";
|
|
418
|
+
document.body.append(el);
|
|
419
|
+
|
|
420
|
+
const pos = { x: 587, y: 235 };
|
|
421
|
+
const scale = { x: 1.648, y: 1.648 };
|
|
422
|
+
const [LEFT, TOP] = [120, 520];
|
|
423
|
+
el.getBoundingClientRect = () => {
|
|
424
|
+
const cssW = Number.parseFloat(el.style.width) || 630;
|
|
425
|
+
const cssH = Number.parseFloat(el.style.height) || 252;
|
|
426
|
+
const [w, h] = [cssW * scale.x, cssH * scale.y];
|
|
427
|
+
// GSAP scales about the element centre, so the box grows around it.
|
|
428
|
+
return {
|
|
429
|
+
x: LEFT + pos.x + cssW / 2 - w / 2,
|
|
430
|
+
y: TOP + pos.y + cssH / 2 - h / 2,
|
|
431
|
+
width: w,
|
|
432
|
+
height: h,
|
|
433
|
+
} as DOMRect;
|
|
434
|
+
};
|
|
435
|
+
const gsapStub = {
|
|
436
|
+
set: (_target: Element, vars: Record<string, number>) => {
|
|
437
|
+
if (vars.x != null) pos.x = vars.x;
|
|
438
|
+
if (vars.y != null) pos.y = vars.y;
|
|
439
|
+
if (vars.scaleX != null) scale.x = vars.scaleX;
|
|
440
|
+
if (vars.scaleY != null) scale.y = vars.scaleY;
|
|
441
|
+
},
|
|
442
|
+
getProperty: (_target: Element, prop: string) =>
|
|
443
|
+
({ scaleX: scale.x, scaleY: scale.y, x: pos.x, y: pos.y })[prop] ?? 0,
|
|
444
|
+
};
|
|
445
|
+
Object.assign(window, { gsap: gsapStub });
|
|
446
|
+
const iframe = {
|
|
447
|
+
contentWindow: { gsap: gsapStub, __timelines: {} },
|
|
448
|
+
contentDocument: document,
|
|
449
|
+
} as unknown as HTMLIFrameElement;
|
|
450
|
+
const positionHold = {
|
|
451
|
+
id: "#clip-set-0-position",
|
|
452
|
+
targetSelector: "#clip",
|
|
453
|
+
propertyGroup: "position",
|
|
454
|
+
method: "set",
|
|
455
|
+
properties: { x: 432, y: 173 },
|
|
456
|
+
position: 0,
|
|
457
|
+
resolvedStart: 0,
|
|
458
|
+
duration: 0,
|
|
459
|
+
global: true,
|
|
460
|
+
} as unknown as GsapAnimation;
|
|
461
|
+
const selection = { id: "clip", selector: "#clip", element: el } as DomEditSelection;
|
|
462
|
+
usePlayerStore.setState({ currentTime: 0.5 });
|
|
463
|
+
const commitMutation = vi.fn();
|
|
464
|
+
|
|
465
|
+
await tryGsapResizeIntercept(
|
|
466
|
+
selection,
|
|
467
|
+
{ width: 320, height: 128 },
|
|
468
|
+
[keyframedScaleFixture(), positionHold],
|
|
469
|
+
iframe,
|
|
470
|
+
commitMutation,
|
|
471
|
+
async () => [keyframedScaleFixture(), positionHold],
|
|
472
|
+
);
|
|
473
|
+
|
|
474
|
+
const positionWrites = commitMutation.mock.calls
|
|
475
|
+
.map((call) => call[1] as { properties?: Record<string, number> })
|
|
476
|
+
.filter((mutation) => mutation.properties?.x != null || mutation.properties?.y != null);
|
|
477
|
+
// Either it left the position alone, or it rewrote the same value.
|
|
478
|
+
for (const write of positionWrites) {
|
|
479
|
+
expect(write.properties?.x).toBe(432);
|
|
480
|
+
expect(write.properties?.y).toBe(173);
|
|
481
|
+
}
|
|
482
|
+
// And the live element ends on the drop point, not a drag away from it.
|
|
483
|
+
expect(el.getBoundingClientRect().x).toBeCloseTo(603.3, 0);
|
|
484
|
+
});
|
|
@@ -8,8 +8,13 @@
|
|
|
8
8
|
import type { GsapAnimation, PropertyGroupName } from "@hyperframes/core/gsap-parser";
|
|
9
9
|
import type { DomEditSelection } from "../components/editor/domEditingTypes";
|
|
10
10
|
import { clearStudioBoxSize } from "../components/editor/manualEdits";
|
|
11
|
-
import {
|
|
11
|
+
import {
|
|
12
|
+
STUDIO_ORIGINAL_BOX_HEIGHT_ATTR,
|
|
13
|
+
STUDIO_ORIGINAL_BOX_WIDTH_ATTR,
|
|
14
|
+
} from "../components/editor/manualEditsTypes";
|
|
15
|
+
import { setElementGsapPosition, setElementGsapScale } from "../utils/elementGsap";
|
|
12
16
|
import { usePlayerStore } from "../player/store/playerStore";
|
|
17
|
+
import { hasNonHoldTweenForElement } from "./gsapRuntimeKeyframes";
|
|
13
18
|
import { readAllAnimatedProperties, readGsapProperty } from "./gsapRuntimeReaders";
|
|
14
19
|
import {
|
|
15
20
|
commitStaticGsapPosition,
|
|
@@ -21,13 +26,14 @@ import {
|
|
|
21
26
|
materializeIfDynamic,
|
|
22
27
|
} from "./gsapDragCommit";
|
|
23
28
|
import type { GsapDragCommitCallbacks } from "./gsapDragCommit";
|
|
29
|
+
import { computeDraggedGsapPosition } from "./draggedGsapPosition";
|
|
24
30
|
import { pickClosestToPlayhead, readGsapPositionFromIframe } from "./gsapPositionDetection";
|
|
25
31
|
import { commitWholePropertyOffset } from "./gsapWholePropertyOffsetCommit";
|
|
32
|
+
import { commitGsapPositionFromDrag } from "./gsapDragPositionCommit";
|
|
26
33
|
import { resolveTweenStart, resolveTweenDuration } from "../utils/globalTimeCompiler";
|
|
27
34
|
import { isInstantHold, selectorFromSelection, writeTargetSelector } from "./gsapShared";
|
|
28
35
|
import { roundTo3 } from "../utils/rounding";
|
|
29
|
-
import { resolveGroupTween
|
|
30
|
-
import { hasNonHoldTweenForElement } from "./gsapRuntimeKeyframes";
|
|
36
|
+
import { resolveGroupTween } from "./gsapRuntimeBridge";
|
|
31
37
|
import { logResize } from "../utils/resizeDebug";
|
|
32
38
|
import {
|
|
33
39
|
animationWritesAnyProperty,
|
|
@@ -49,6 +55,42 @@ function synthesizeIdentityProps(
|
|
|
49
55
|
return id;
|
|
50
56
|
}
|
|
51
57
|
|
|
58
|
+
/**
|
|
59
|
+
* The element's box before the resize draft ran, in CSS pixels.
|
|
60
|
+
*
|
|
61
|
+
* Prefers the measurement the draft recorded. Falls back to the inline style it
|
|
62
|
+
* saved for restoring, which is a real value for the elements that carry one,
|
|
63
|
+
* and null when neither says anything.
|
|
64
|
+
*/
|
|
65
|
+
function originalBoxSize(
|
|
66
|
+
el: HTMLElement | null,
|
|
67
|
+
measuredAttr: string,
|
|
68
|
+
inlineProperty: "width" | "height",
|
|
69
|
+
): number | null {
|
|
70
|
+
const measured = Number.parseFloat(el?.getAttribute(measuredAttr) ?? "");
|
|
71
|
+
if (Number.isFinite(measured) && measured > 0) return measured;
|
|
72
|
+
const inline = Number.parseFloat(
|
|
73
|
+
el?.getAttribute(`data-hf-studio-original-${inlineProperty}`) ?? "",
|
|
74
|
+
);
|
|
75
|
+
return Number.isFinite(inline) && inline > 0 ? inline : null;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Whether this tween already states scale as `scaleX`/`scaleY`.
|
|
80
|
+
*
|
|
81
|
+
* Both forms are legal, and either alone is fine. A tween holding both is not:
|
|
82
|
+
* GSAP animates each property name independently, so the longhands run
|
|
83
|
+
* alongside the shorthand and win, which silently discards whatever the
|
|
84
|
+
* shorthand was set to.
|
|
85
|
+
*/
|
|
86
|
+
function tweenUsesScaleLonghands(anim: GsapAnimation | null): boolean {
|
|
87
|
+
const isLonghand = (name: string) => name === "scaleX" || name === "scaleY";
|
|
88
|
+
const inKeyframes = (anim?.keyframes?.keyframes ?? []).some((frame) =>
|
|
89
|
+
Object.keys(frame.properties ?? {}).some(isLonghand),
|
|
90
|
+
);
|
|
91
|
+
return inKeyframes || Object.keys(anim?.properties ?? {}).some(isLonghand);
|
|
92
|
+
}
|
|
93
|
+
|
|
52
94
|
// ── Resize intercept ──────────────────────────────────────────────────────
|
|
53
95
|
|
|
54
96
|
// fallow-ignore-next-line complexity
|
|
@@ -155,17 +197,26 @@ export async function tryGsapResizeIntercept(
|
|
|
155
197
|
let resizeProps: Record<string, number>;
|
|
156
198
|
let scaleDraftEl: HTMLElement | null = null;
|
|
157
199
|
let scaleDraftDropPoint: { x: number; y: number } | null = null;
|
|
200
|
+
/** The scale this commit is putting on the element, for the finalize step. */
|
|
201
|
+
let committedScale: { x: number; y: number } | null = null;
|
|
158
202
|
let nonUniformScale = false;
|
|
203
|
+
/** Whether this commit writes scaleX/scaleY rather than the `scale` shorthand. */
|
|
204
|
+
let useScaleLonghands = false;
|
|
159
205
|
if (resizeGroup === "scale") {
|
|
160
206
|
// Iframe-realm element — instanceof HTMLElement fails across realms; the
|
|
161
207
|
// selector targets composition elements, and every use below is duck-typed.
|
|
162
208
|
const el = iframe?.contentDocument?.querySelector(selector ?? "") as HTMLElement | null;
|
|
163
209
|
// The resize draft modifies el.style.width/height, so read the ORIGINAL
|
|
164
210
|
// dimensions saved by the draft system before it ran.
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
211
|
+
//
|
|
212
|
+
// The measured box first, then the inline one. The inline attributes exist
|
|
213
|
+
// to restore an inline style and are empty for anything sized by a
|
|
214
|
+
// stylesheet, which is how compositions are written, so reading them alone
|
|
215
|
+
// sent almost every element to the fallback below: a 630px chip scaled by
|
|
216
|
+
// 630/200, landing over three times the size it was dropped at, and worse
|
|
217
|
+
// on the next drag because the wrong scale then counted as its live one.
|
|
218
|
+
const cssW = originalBoxSize(el, STUDIO_ORIGINAL_BOX_WIDTH_ATTR, "width") ?? 200;
|
|
219
|
+
const cssH = originalBoxSize(el, STUDIO_ORIGINAL_BOX_HEIGHT_ATTR, "height") ?? cssW;
|
|
169
220
|
// `size` is the draft's CSS box; on screen it is multiplied by the element's
|
|
170
221
|
// LIVE scale (the draft divides the cursor delta by it — see
|
|
171
222
|
// resolveDomEditResizeGesture). The committed keyframe REPLACES that live
|
|
@@ -181,8 +232,18 @@ export async function tryGsapResizeIntercept(
|
|
|
181
232
|
// can't represent it — committing width-derived scale used to snap the
|
|
182
233
|
// height at drop. Commit scaleX/scaleY longhands instead; keep the uniform
|
|
183
234
|
// shorthand when the two agree (aspect-true drags, shift-drags).
|
|
235
|
+
//
|
|
236
|
+
// Unless the tween already speaks longhands, in which case a uniform drag
|
|
237
|
+
// has to as well. GSAP animates each property name on its own, so a
|
|
238
|
+
// keyframe holding `{ scaleX: 1, scaleY: 1, scale: 0.61 }` runs all three
|
|
239
|
+
// and the longhands win: the resize commits correctly and then does
|
|
240
|
+
// nothing, and the element snaps back to its old size on release. The
|
|
241
|
+
// tween never mixes the two forms in either direction.
|
|
184
242
|
nonUniformScale = Math.abs(newScaleX - newScaleY) > 0.01;
|
|
185
|
-
|
|
243
|
+
useScaleLonghands = nonUniformScale || tweenUsesScaleLonghands(anim);
|
|
244
|
+
resizeProps = useScaleLonghands
|
|
245
|
+
? { scaleX: newScaleX, scaleY: newScaleY }
|
|
246
|
+
: { scale: newScaleX };
|
|
186
247
|
logResize("intercept-route", {
|
|
187
248
|
route: "scale-tween",
|
|
188
249
|
cssW,
|
|
@@ -194,6 +255,13 @@ export async function tryGsapResizeIntercept(
|
|
|
194
255
|
nonUniformScale,
|
|
195
256
|
});
|
|
196
257
|
scaleDraftEl = el;
|
|
258
|
+
// What the commit ACTUALLY writes, which is what the finalize step below
|
|
259
|
+
// has to measure against. A near-uniform drag collapses to the shorthand,
|
|
260
|
+
// so taking the per-axis pair here measured the element at a scaleY the
|
|
261
|
+
// file never gets and tilted the correction by the difference.
|
|
262
|
+
committedScale = useScaleLonghands
|
|
263
|
+
? { x: newScaleX, y: newScaleY }
|
|
264
|
+
: { x: newScaleX, y: newScaleX };
|
|
197
265
|
// Where the user DROPPED the box: the draft (anchor-pinned to the
|
|
198
266
|
// gesture-start top-left) is still applied here, so this rect is exactly
|
|
199
267
|
// what the preview showed at release. The committed scale renders around
|
|
@@ -226,35 +294,61 @@ export async function tryGsapResizeIntercept(
|
|
|
226
294
|
if (!scaleDraftEl) return;
|
|
227
295
|
clearStudioBoxSize(scaleDraftEl);
|
|
228
296
|
if (!scaleDraftDropPoint || !selector) return;
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
297
|
+
// Put the committed scale on the live element before measuring.
|
|
298
|
+
//
|
|
299
|
+
// This step reads where the commit lands the box and shifts the position
|
|
300
|
+
// hold by the difference. That only works if the commit has actually
|
|
301
|
+
// rendered, and whether it had was luck: on the FIRST resize of an element
|
|
302
|
+
// the timeline had not re-seeked yet, so this measured the element at its
|
|
303
|
+
// natural size, still sitting on the drop point, computed a residual of
|
|
304
|
+
// zero, and skipped the correction entirely. The scale then landed, GSAP
|
|
305
|
+
// rendered it around the element's centre, and the element jumped by the
|
|
306
|
+
// whole drag distance. Elements that had been resized before got a
|
|
307
|
+
// correction only because their PREVIOUS scale made the residual non-zero.
|
|
308
|
+
//
|
|
309
|
+
// Setting it here costs nothing when the commit has already rendered (same
|
|
310
|
+
// value) and makes the measurement below mean what it says either way.
|
|
311
|
+
if (committedScale) {
|
|
312
|
+
setElementGsapScale(scaleDraftEl, committedScale.x, committedScale.y);
|
|
238
313
|
}
|
|
239
|
-
//
|
|
240
|
-
//
|
|
314
|
+
// Measure from the pre-gesture position, not the draft one.
|
|
315
|
+
//
|
|
316
|
+
// The resize draft translates the element to keep the dragged corner under
|
|
317
|
+
// the cursor, but the scale route never persists that translation — the
|
|
318
|
+
// element renders back at its pre-gesture position as soon as the commit
|
|
319
|
+
// lands. Measuring while the draft translation was still applied made the
|
|
320
|
+
// residual carry the whole drag distance, and the position commit then
|
|
321
|
+
// composed that residual onto the pre-gesture base (it reads the gesture's
|
|
322
|
+
// own base attributes, not the live value), so the element landed a full
|
|
323
|
+
// drag away from the drop point on every scale resize.
|
|
324
|
+
const gsapPos = readGsapPositionFromIframe(iframe, selector) ?? { x: 0, y: 0 };
|
|
325
|
+
const { baseGsapX, baseGsapY } = computeDraggedGsapPosition(
|
|
326
|
+
selection.element,
|
|
327
|
+
{ x: 0, y: 0 },
|
|
328
|
+
gsapPos,
|
|
329
|
+
);
|
|
330
|
+
const base = { x: baseGsapX, y: baseGsapY };
|
|
331
|
+
setElementGsapPosition(scaleDraftEl, base.x, base.y);
|
|
241
332
|
const post = scaleDraftEl.getBoundingClientRect();
|
|
242
333
|
const residual = { x: scaleDraftDropPoint.x - post.x, y: scaleDraftDropPoint.y - post.y };
|
|
243
334
|
if (!Number.isFinite(residual.x) || !Number.isFinite(residual.y)) return;
|
|
244
|
-
if (Math.abs(residual.x) < 0.5 && Math.abs(residual.y) < 0.5)
|
|
245
|
-
|
|
335
|
+
if (Math.abs(residual.x) < 0.5 && Math.abs(residual.y) < 0.5) {
|
|
336
|
+
logResize("scale-finalize", { skipped: "already-on-drop-point", residual, base });
|
|
337
|
+
return;
|
|
338
|
+
}
|
|
246
339
|
// The ONE corrected position — rounded once so the live runtime and the
|
|
247
340
|
// persisted file agree exactly (commitStaticGsapPosition composes the same
|
|
248
341
|
// rounded value from this delta).
|
|
249
342
|
const corrected = {
|
|
250
|
-
x: Math.round(
|
|
251
|
-
y: Math.round(
|
|
343
|
+
x: Math.round(base.x + residual.x),
|
|
344
|
+
y: Math.round(base.y + residual.y),
|
|
252
345
|
};
|
|
253
346
|
logResize("scale-finalize", {
|
|
254
347
|
dropPoint: scaleDraftDropPoint,
|
|
255
348
|
post: { x: post.x, y: post.y },
|
|
256
349
|
residual,
|
|
257
350
|
gsapPos,
|
|
351
|
+
base,
|
|
258
352
|
corrected,
|
|
259
353
|
});
|
|
260
354
|
// Correct the LIVE runtime NOW, synchronously: the soft reload above just
|
|
@@ -270,20 +364,34 @@ export async function tryGsapResizeIntercept(
|
|
|
270
364
|
const currentAnimations = fetchFallbackAnimations
|
|
271
365
|
? await fetchFallbackAnimations()
|
|
272
366
|
: (resolved?.animations ?? animations);
|
|
273
|
-
const existingSet = findExistingPositionWrite(currentAnimations, selector, selection.element);
|
|
274
367
|
// Delta chosen so the drag-path math composes back to exactly `corrected`
|
|
275
|
-
//
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
368
|
+
// — it adds this onto the same base the measurement above used.
|
|
369
|
+
const delta = { x: corrected.x - base.x, y: corrected.y - base.y };
|
|
370
|
+
// An element whose position is animated needs the correction written into
|
|
371
|
+
// that animation, at the playhead, or the tween renders its own value a
|
|
372
|
+
// frame later and the element leaves the drop point anyway. This used to
|
|
373
|
+
// stand down here instead, on the grounds that a keyframed path has no
|
|
374
|
+
// single anchor to preserve. It has one: the frame the user is looking at.
|
|
375
|
+
// Writing it is the same thing a drag on the same element does, through
|
|
376
|
+
// the same commit.
|
|
377
|
+
const positionTween = pickClosestToPlayhead(
|
|
378
|
+
currentAnimations.filter(
|
|
379
|
+
(a) => a.propertyGroup === "position" && !isInstantHold(a) && resolveTweenDuration(a) > 0,
|
|
380
|
+
),
|
|
381
|
+
);
|
|
382
|
+
if (positionTween) {
|
|
383
|
+
logResize("scale-finalize", { route: "position-keyframe", tweenId: positionTween.id });
|
|
384
|
+
await commitGsapPositionFromDrag(selection, positionTween, delta, base, iframe, selector, {
|
|
283
385
|
commitMutation,
|
|
284
386
|
fetchAnimations: fetchFallbackAnimations,
|
|
285
|
-
}
|
|
286
|
-
|
|
387
|
+
});
|
|
388
|
+
return;
|
|
389
|
+
}
|
|
390
|
+
const existingSet = findExistingPositionWrite(currentAnimations, selector, selection.element);
|
|
391
|
+
await commitStaticGsapPosition(selection, delta, base, selector, existingSet, {
|
|
392
|
+
commitMutation,
|
|
393
|
+
fetchAnimations: fetchFallbackAnimations,
|
|
394
|
+
});
|
|
287
395
|
};
|
|
288
396
|
|
|
289
397
|
// With auto-keyframe off (#1808), `anim` is already a real (non-"set")
|
|
@@ -341,7 +449,7 @@ export async function tryGsapResizeIntercept(
|
|
|
341
449
|
// normalizes every keyframe to the longhands. For an in-range resize the
|
|
342
450
|
// min/max window math below degenerates to the tween's own start/duration,
|
|
343
451
|
// so timing is unchanged.
|
|
344
|
-
if ((outsideRange ||
|
|
452
|
+
if ((outsideRange || useScaleLonghands) && ts !== null) {
|
|
345
453
|
// For flat tweens, synthesize the keyframes from the tween's properties
|
|
346
454
|
const kfs =
|
|
347
455
|
anim.keyframes?.keyframes ??
|
package/src/utils/elementGsap.ts
CHANGED
|
@@ -24,6 +24,20 @@ export function setElementGsapPosition(element: HTMLElement, x: number, y: numbe
|
|
|
24
24
|
return true;
|
|
25
25
|
}
|
|
26
26
|
|
|
27
|
+
/**
|
|
28
|
+
* Set the element's GSAP scale. Returns false when no runtime is reachable.
|
|
29
|
+
*
|
|
30
|
+
* Used to make the element show a scale that has been committed but not yet
|
|
31
|
+
* re-rendered by the timeline, so measuring it afterwards reports where the
|
|
32
|
+
* commit actually puts it rather than where it happened to be mid-flight.
|
|
33
|
+
*/
|
|
34
|
+
export function setElementGsapScale(element: HTMLElement, x: number, y: number): boolean {
|
|
35
|
+
const gsap = gsapOf(element);
|
|
36
|
+
if (!gsap?.set) return false;
|
|
37
|
+
gsap.set(element, { scaleX: x, scaleY: y });
|
|
38
|
+
return true;
|
|
39
|
+
}
|
|
40
|
+
|
|
27
41
|
/** The element's GSAP numeric property, or null when unreadable. */
|
|
28
42
|
export function readElementGsapNumber(element: HTMLElement, prop: string): number | null {
|
|
29
43
|
const value = Number(gsapOf(element)?.getProperty?.(element, prop));
|