@hyperframes/studio 0.8.133 → 0.8.134
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-Cjcwveg0.js → hyperframes-player-Bro1gj0Z.js} +1 -1
- package/dist/assets/{index-ME-Z5jh6.js → index-BwNDTmAD.js} +1 -1
- package/dist/assets/{index-CwgWmfgx.js → index-DnVGIbEC.js} +1 -1
- package/dist/assets/{index-Dj21zXWA.js → index-wnXyQjW-.js} +141 -141
- package/dist/index.html +1 -1
- package/dist/index.js +684 -603
- package/dist/index.js.map +1 -1
- package/package.json +8 -8
- package/src/components/editor/manualEditsDom.ts +5 -1
- package/src/components/editor/plainTranslate.test.ts +21 -2
- package/src/components/editor/plainTranslate.ts +7 -0
- package/src/hooks/draggedGsapPosition.test.ts +34 -0
- package/src/hooks/draggedGsapPosition.ts +40 -15
- package/src/hooks/gsapDragCommit.ts +17 -10
- package/src/hooks/gsapDragPositionCommit.ts +8 -9
- package/src/hooks/gsapResizeIntercept.test.ts +72 -1
- package/src/hooks/gsapResizeIntercept.ts +84 -17
- package/src/hooks/gsapRuntimeBridge.ts +4 -1
- package/src/hooks/gsapTrailingKeyframes.realGsap.test.ts +44 -0
- package/src/hooks/useEditHistoryActions.test.tsx +20 -0
- package/src/hooks/useEditHistoryActions.ts +3 -0
- package/src/hooks/useEnableKeyframes.ts +32 -37
- package/src/hooks/useGsapAwareEditing.test.tsx +1 -0
- package/src/hooks/useGsapAwareEditing.ts +10 -2
- package/src/utils/elementGsap.ts +6 -5
- package/src/utils/gsapUndoRestore.test.ts +22 -0
- package/src/utils/gsapUndoRestore.ts +4 -2
- package/src/utils/studioPendingEdits.ts +12 -2
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@hyperframes/studio",
|
|
3
|
-
"version": "0.8.
|
|
3
|
+
"version": "0.8.134",
|
|
4
4
|
"description": "",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -60,12 +60,12 @@
|
|
|
60
60
|
"dockview-react": "8.3.1",
|
|
61
61
|
"gsap": "^3.13.0",
|
|
62
62
|
"mediabunny": "^1.45.3",
|
|
63
|
-
"@hyperframes/
|
|
64
|
-
"@hyperframes/
|
|
65
|
-
"@hyperframes/
|
|
66
|
-
"@hyperframes/
|
|
67
|
-
"@hyperframes/
|
|
68
|
-
"@hyperframes/
|
|
63
|
+
"@hyperframes/sdk": "0.8.134",
|
|
64
|
+
"@hyperframes/core": "0.8.134",
|
|
65
|
+
"@hyperframes/studio-server": "0.8.134",
|
|
66
|
+
"@hyperframes/parsers": "0.8.134",
|
|
67
|
+
"@hyperframes/player": "0.8.134",
|
|
68
|
+
"@hyperframes/shader-transitions": "0.8.134"
|
|
69
69
|
},
|
|
70
70
|
"devDependencies": {
|
|
71
71
|
"@tailwindcss/vite": "^4.3.3",
|
|
@@ -81,7 +81,7 @@
|
|
|
81
81
|
"vite": "^6.4.2",
|
|
82
82
|
"vitest": "^4.1.11",
|
|
83
83
|
"zustand": "^5.0.0",
|
|
84
|
-
"@hyperframes/producer": "0.8.
|
|
84
|
+
"@hyperframes/producer": "0.8.134"
|
|
85
85
|
},
|
|
86
86
|
"peerDependencies": {
|
|
87
87
|
"react": "19",
|
|
@@ -91,10 +91,14 @@ export function studioManualEditSavesIn(doc: Document): number {
|
|
|
91
91
|
return gestureSaves.get(doc) ?? 0;
|
|
92
92
|
}
|
|
93
93
|
|
|
94
|
+
export function countStudioPreviewChange(doc: Document): void {
|
|
95
|
+
gestureSaves.set(doc, studioManualEditSavesIn(doc) + 1);
|
|
96
|
+
}
|
|
97
|
+
|
|
94
98
|
/** Runs a gesture's save, counted as it starts and as it settles: a reload requested before shows the old file. */
|
|
95
99
|
export function countStudioManualEditSave<R>(element: HTMLElement, save: () => R): R {
|
|
96
100
|
const doc = element.ownerDocument;
|
|
97
|
-
const count = () =>
|
|
101
|
+
const count = () => countStudioPreviewChange(doc);
|
|
98
102
|
count();
|
|
99
103
|
const result = save();
|
|
100
104
|
void Promise.resolve(result).then(count, count);
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
// @vitest-environment happy-dom
|
|
2
|
-
import { describe, expect, it } from "vitest";
|
|
3
|
-
import { readTranslatePx } from "./plainTranslate";
|
|
2
|
+
import { describe, expect, it, vi } from "vitest";
|
|
3
|
+
import { readTranslatePx, readTranslatePxLeavingPercent } from "./plainTranslate";
|
|
4
4
|
|
|
5
5
|
function box(style: string): HTMLElement {
|
|
6
6
|
const element = document.createElement("div");
|
|
@@ -76,3 +76,22 @@ describe("readTranslatePx", () => {
|
|
|
76
76
|
},
|
|
77
77
|
);
|
|
78
78
|
});
|
|
79
|
+
|
|
80
|
+
describe("readTranslatePxLeavingPercent", () => {
|
|
81
|
+
it("seeds a first tween from the px a plain move left in CSS translate, leaving % to xPercent", () => {
|
|
82
|
+
const el = document.body.appendChild(document.createElement("div"));
|
|
83
|
+
// happy-dom computes no `translate`; Chromium resolves it to these strings.
|
|
84
|
+
const computed = (translate: string) =>
|
|
85
|
+
vi.spyOn(window, "getComputedStyle").mockReturnValue({ translate } as CSSStyleDeclaration);
|
|
86
|
+
computed("299.893px 203.262px");
|
|
87
|
+
expect(readTranslatePxLeavingPercent(el)).toEqual({ x: 299.893, y: 203.262 });
|
|
88
|
+
computed("calc(-50% + 120px) -50%");
|
|
89
|
+
expect(readTranslatePxLeavingPercent(el)).toEqual({ x: 120, y: 0 });
|
|
90
|
+
computed("calc(-50% - 120px) calc(min(10px, 20px) + 5px)");
|
|
91
|
+
expect(readTranslatePxLeavingPercent(el)).toEqual({ x: -120, y: 15 });
|
|
92
|
+
computed("none");
|
|
93
|
+
expect(readTranslatePxLeavingPercent(el)).toEqual({ x: 0, y: 0 });
|
|
94
|
+
vi.restoreAllMocks();
|
|
95
|
+
el.remove();
|
|
96
|
+
});
|
|
97
|
+
});
|
|
@@ -100,6 +100,13 @@ export function readTranslatePx(el: HTMLElement): Point {
|
|
|
100
100
|
};
|
|
101
101
|
}
|
|
102
102
|
|
|
103
|
+
export function readTranslatePxLeavingPercent(el: HTMLElement): Point {
|
|
104
|
+
const value = el.ownerDocument.defaultView?.getComputedStyle(el).translate || "none";
|
|
105
|
+
const [x = "0px", y = "0px"] = value === "none" ? [] : splitTopLevelWhitespace(value);
|
|
106
|
+
const px = (part: string) => roundTo3(evaluateLength(part, 0)) || 0;
|
|
107
|
+
return { x: px(x), y: px(y) };
|
|
108
|
+
}
|
|
109
|
+
|
|
103
110
|
export const UNREADABLE_TRANSLATE =
|
|
104
111
|
"Studio can't read this layer's translate. Move it in the Code tab.";
|
|
105
112
|
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
// @vitest-environment happy-dom
|
|
2
|
+
import { expect, it, vi } from "vitest";
|
|
3
|
+
import type { DomEditSelection } from "../components/editor/domEditingTypes";
|
|
4
|
+
import { freezeDragStamp, restoreDragOffset } from "./draggedGsapPosition";
|
|
5
|
+
import { commitStaticGsapPosition } from "./gsapDragCommit";
|
|
6
|
+
|
|
7
|
+
function stamp(element: HTMLElement, base: { x: number; y: number }) {
|
|
8
|
+
element.setAttribute("data-hf-drag-gsap-base-x", String(base.x));
|
|
9
|
+
element.setAttribute("data-hf-drag-gsap-base-y", String(base.y));
|
|
10
|
+
element.setAttribute("data-hf-drag-initial-offset-x", "0");
|
|
11
|
+
element.setAttribute("data-hf-drag-initial-offset-y", "0");
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
// A resize released at x 60 moves its anchor by -50; the next drag presses before that write is planned.
|
|
15
|
+
it("a commit that outlives its gesture uses the gesture's stamp, not the next one's", async () => {
|
|
16
|
+
const element = document.createElement("div");
|
|
17
|
+
stamp(element, { x: 60, y: 37.5 });
|
|
18
|
+
const frozen = freezeDragStamp(element);
|
|
19
|
+
stamp(element, { x: 10, y: 4 });
|
|
20
|
+
const commitMutation = vi.fn();
|
|
21
|
+
|
|
22
|
+
await commitStaticGsapPosition(
|
|
23
|
+
{ id: "target", selector: "#target", element } as DomEditSelection,
|
|
24
|
+
{ x: -50, y: -33.5 },
|
|
25
|
+
{ x: 0, y: 0 },
|
|
26
|
+
"#target",
|
|
27
|
+
null,
|
|
28
|
+
{ commitMutation, stamp: frozen },
|
|
29
|
+
);
|
|
30
|
+
|
|
31
|
+
expect(commitMutation.mock.calls[0]![1].properties).toEqual({ x: 10, y: 4 });
|
|
32
|
+
restoreDragOffset(element, frozen);
|
|
33
|
+
expect(element.getAttribute("data-hf-drag-initial-offset-x")).toBe("0");
|
|
34
|
+
});
|
|
@@ -36,10 +36,35 @@ export function readCssRotation(element: HTMLElement, withRotate = true): number
|
|
|
36
36
|
}
|
|
37
37
|
}
|
|
38
38
|
|
|
39
|
+
export interface DragStamp {
|
|
40
|
+
origX: number;
|
|
41
|
+
origY: number;
|
|
42
|
+
baseX: number;
|
|
43
|
+
baseY: number;
|
|
44
|
+
frozen?: boolean;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** The drag-start attributes as they are now; NaN for an absent base. */
|
|
48
|
+
export function readDragStamp(element: HTMLElement): DragStamp {
|
|
49
|
+
const read = (name: string) => Number.parseFloat(element.getAttribute(name) ?? "");
|
|
50
|
+
return {
|
|
51
|
+
origX: read("data-hf-drag-initial-offset-x") || 0,
|
|
52
|
+
origY: read("data-hf-drag-initial-offset-y") || 0,
|
|
53
|
+
baseX: read("data-hf-drag-gsap-base-x"),
|
|
54
|
+
baseY: read("data-hf-drag-gsap-base-y"),
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** A gesture's stamp read at its release, for commits that finish after the next gesture re-stamps the element. */
|
|
59
|
+
export const freezeDragStamp = (element: HTMLElement): DragStamp => ({
|
|
60
|
+
...readDragStamp(element),
|
|
61
|
+
frozen: true,
|
|
62
|
+
});
|
|
63
|
+
|
|
39
64
|
/**
|
|
40
65
|
* Translate a studio drag offset into absolute GSAP x/y, accounting for the
|
|
41
66
|
* element's rotation and its drag-start base pose. Reads the drag-start
|
|
42
|
-
*
|
|
67
|
+
* stamp (`readDragStamp` by default) set by `createManualOffsetDragMember`
|
|
43
68
|
* (`data-hf-drag-initial-offset-*`, `data-hf-drag-gsap-base-*`); `fallbackBase`
|
|
44
69
|
* is used when the base attributes are absent (e.g. a static element that GSAP
|
|
45
70
|
* hasn't given an x/y yet).
|
|
@@ -52,22 +77,19 @@ export function computeDraggedGsapPosition(
|
|
|
52
77
|
element: HTMLElement,
|
|
53
78
|
studioOffset: { x: number; y: number },
|
|
54
79
|
fallbackBase: { x: number; y: number },
|
|
80
|
+
stamp: DragStamp = readDragStamp(element),
|
|
55
81
|
): { newX: number; newY: number; baseGsapX: number; baseGsapY: number } {
|
|
56
82
|
const rotStyle = element.style.getPropertyValue("--hf-studio-rotation");
|
|
57
83
|
const rotDeg = Number.parseFloat(rotStyle) || 0;
|
|
58
84
|
const rad = (-rotDeg * Math.PI) / 180;
|
|
59
85
|
const cos = Math.cos(rad);
|
|
60
86
|
const sin = Math.sin(rad);
|
|
61
|
-
const
|
|
62
|
-
const
|
|
63
|
-
const deltaX = studioOffset.x - origX;
|
|
64
|
-
const deltaY = studioOffset.y - origY;
|
|
87
|
+
const deltaX = studioOffset.x - stamp.origX;
|
|
88
|
+
const deltaY = studioOffset.y - stamp.origY;
|
|
65
89
|
const adjX = deltaX * cos - deltaY * sin;
|
|
66
90
|
const adjY = deltaX * sin + deltaY * cos;
|
|
67
|
-
const
|
|
68
|
-
const
|
|
69
|
-
const baseGsapX = Number.isFinite(parsedBaseX) ? parsedBaseX : fallbackBase.x;
|
|
70
|
-
const baseGsapY = Number.isFinite(parsedBaseY) ? parsedBaseY : fallbackBase.y;
|
|
91
|
+
const baseGsapX = Number.isFinite(stamp.baseX) ? stamp.baseX : fallbackBase.x;
|
|
92
|
+
const baseGsapY = Number.isFinite(stamp.baseY) ? stamp.baseY : fallbackBase.y;
|
|
71
93
|
return {
|
|
72
94
|
newX: roundTo3(baseGsapX + adjX),
|
|
73
95
|
newY: roundTo3(baseGsapY + adjY),
|
|
@@ -76,12 +98,15 @@ export function computeDraggedGsapPosition(
|
|
|
76
98
|
};
|
|
77
99
|
}
|
|
78
100
|
|
|
79
|
-
/** Puts the drag's preview offset back once the written position renders instead.
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
101
|
+
/** Puts the drag's preview offset back once the written position renders instead. A frozen stamp's
|
|
102
|
+
* gesture is over: the attributes belong to the one after it. */
|
|
103
|
+
export function restoreDragOffset(
|
|
104
|
+
element: HTMLElement,
|
|
105
|
+
stamp: DragStamp = readDragStamp(element),
|
|
106
|
+
): void {
|
|
107
|
+
if (stamp.frozen) return;
|
|
108
|
+
element.style.setProperty("--hf-studio-offset-x", `${stamp.origX}px`);
|
|
109
|
+
element.style.setProperty("--hf-studio-offset-y", `${stamp.origY}px`);
|
|
85
110
|
element.removeAttribute("data-hf-drag-initial-offset-x");
|
|
86
111
|
element.removeAttribute("data-hf-drag-initial-offset-y");
|
|
87
112
|
}
|
|
@@ -12,7 +12,12 @@ import {
|
|
|
12
12
|
} from "../utils/globalTimeCompiler";
|
|
13
13
|
import { roundTo3, roundToLayoutPx } from "../utils/rounding";
|
|
14
14
|
import { computeElementPercentage, keyframeEases, writeTargetSelector } from "./gsapShared";
|
|
15
|
-
import {
|
|
15
|
+
import {
|
|
16
|
+
computeDraggedGsapPosition,
|
|
17
|
+
readDragStamp,
|
|
18
|
+
restoreDragOffset,
|
|
19
|
+
type DragStamp,
|
|
20
|
+
} from "./draggedGsapPosition";
|
|
16
21
|
import type { CommitMutation } from "./gsapScriptCommitTypes";
|
|
17
22
|
import { isGestureTransactionCommit, runGestureTransaction } from "./gestureTransaction";
|
|
18
23
|
import { setPatchFromUpdateProperty } from "./gsapDragStaticSetHelpers";
|
|
@@ -26,6 +31,8 @@ export {
|
|
|
26
31
|
export interface GsapDragCommitCallbacks {
|
|
27
32
|
commitMutation: CommitMutation;
|
|
28
33
|
fetchAnimations?: () => Promise<GsapAnimation[]>;
|
|
34
|
+
/** The gesture's drag stamp, when its commit can outlive it; else read from the element. */
|
|
35
|
+
stamp?: DragStamp;
|
|
29
36
|
}
|
|
30
37
|
|
|
31
38
|
/**
|
|
@@ -169,7 +176,12 @@ export async function commitStaticGsapPosition(
|
|
|
169
176
|
existingSet: GsapAnimation | null,
|
|
170
177
|
callbacks: GsapDragCommitCallbacks,
|
|
171
178
|
): Promise<void> {
|
|
172
|
-
const { newX, newY } = computeDraggedGsapPosition(
|
|
179
|
+
const { newX, newY } = computeDraggedGsapPosition(
|
|
180
|
+
selection.element,
|
|
181
|
+
studioOffset,
|
|
182
|
+
gsapPos,
|
|
183
|
+
callbacks.stamp,
|
|
184
|
+
);
|
|
173
185
|
if (existingSet) {
|
|
174
186
|
if (existingSet.keyframes) {
|
|
175
187
|
// Keyframed zero-duration hold (drag-path corruption): can't update-property
|
|
@@ -344,22 +356,17 @@ export async function commitWholePathOffset(
|
|
|
344
356
|
callbacks: GsapDragCommitCallbacks,
|
|
345
357
|
): Promise<void> {
|
|
346
358
|
const el = selection.element;
|
|
359
|
+
const stamp = callbacks.stamp ?? readDragStamp(el);
|
|
347
360
|
const { newX, newY, baseGsapX, baseGsapY } = computeDraggedGsapPosition(
|
|
348
361
|
el,
|
|
349
362
|
studioOffset,
|
|
350
363
|
gsapPos,
|
|
364
|
+
stamp,
|
|
351
365
|
);
|
|
352
366
|
const deltaX = newX - baseGsapX;
|
|
353
367
|
// fallow-ignore-next-line code-duplication
|
|
354
368
|
const deltaY = newY - baseGsapY;
|
|
355
|
-
const
|
|
356
|
-
const origY = Number.parseFloat(el.getAttribute("data-hf-drag-initial-offset-y") ?? "") || 0;
|
|
357
|
-
const restoreOffset = () => {
|
|
358
|
-
el.style.setProperty("--hf-studio-offset-x", `${origX}px`);
|
|
359
|
-
el.style.setProperty("--hf-studio-offset-y", `${origY}px`);
|
|
360
|
-
el.removeAttribute("data-hf-drag-initial-offset-x");
|
|
361
|
-
el.removeAttribute("data-hf-drag-initial-offset-y");
|
|
362
|
-
};
|
|
369
|
+
const restoreOffset = () => restoreDragOffset(el, stamp);
|
|
363
370
|
|
|
364
371
|
// fallow-ignore-next-line code-duplication
|
|
365
372
|
let effectiveAnim = anim;
|
|
@@ -9,7 +9,11 @@ import {
|
|
|
9
9
|
import { KEYFRAME_PCT_MATCH, playsNear } from "./gsapShared";
|
|
10
10
|
import { progressAtTime, runEaseOf, timeAtProgress } from "../utils/gsapKeyframeEases";
|
|
11
11
|
import { roundTo3 } from "../utils/rounding";
|
|
12
|
-
import {
|
|
12
|
+
import {
|
|
13
|
+
computeDraggedGsapPosition,
|
|
14
|
+
readDragStamp,
|
|
15
|
+
restoreDragOffset,
|
|
16
|
+
} from "./draggedGsapPosition";
|
|
13
17
|
import {
|
|
14
18
|
type GsapDragCommitCallbacks,
|
|
15
19
|
computeCurrentPercentage,
|
|
@@ -117,20 +121,15 @@ export async function commitGsapPositionFromDrag(
|
|
|
117
121
|
callbacks: GsapDragCommitCallbacks,
|
|
118
122
|
): Promise<GsapEditOutcome> {
|
|
119
123
|
const el = selection.element;
|
|
124
|
+
const stamp = callbacks.stamp ?? readDragStamp(el);
|
|
120
125
|
// fallow-ignore-next-line code-duplication
|
|
121
126
|
const { newX, newY, baseGsapX, baseGsapY } = computeDraggedGsapPosition(
|
|
122
127
|
el,
|
|
123
128
|
studioOffset,
|
|
124
129
|
gsapPos,
|
|
130
|
+
stamp,
|
|
125
131
|
);
|
|
126
|
-
const
|
|
127
|
-
const origY = Number.parseFloat(el.getAttribute("data-hf-drag-initial-offset-y") ?? "") || 0;
|
|
128
|
-
const restoreOffset = () => {
|
|
129
|
-
el.style.setProperty("--hf-studio-offset-x", `${origX}px`);
|
|
130
|
-
el.style.setProperty("--hf-studio-offset-y", `${origY}px`);
|
|
131
|
-
el.removeAttribute("data-hf-drag-initial-offset-x");
|
|
132
|
-
el.removeAttribute("data-hf-drag-initial-offset-y");
|
|
133
|
-
};
|
|
132
|
+
const restoreOffset = () => restoreDragOffset(el, stamp);
|
|
134
133
|
|
|
135
134
|
if (anim.arcPath?.enabled) {
|
|
136
135
|
const { activeKeyframePct, currentTime, setActiveKeyframePct } = usePlayerStore.getState();
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
// @vitest-environment happy-dom
|
|
2
|
-
import { afterEach, expect, it, vi } from "vitest";
|
|
2
|
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
3
3
|
import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
|
|
4
4
|
import type { DomEditSelection } from "../components/editor/domEditingTypes";
|
|
5
5
|
import { usePlayerStore } from "../player/store/playerStore";
|
|
@@ -182,6 +182,7 @@ it("reuses the ownership parse instead of fetching a resolved size group twice",
|
|
|
182
182
|
await expect(
|
|
183
183
|
tryGsapResizeIntercept(
|
|
184
184
|
selection,
|
|
185
|
+
// fallow-ignore-next-line code-duplication
|
|
185
186
|
{ width: 344, height: 344 },
|
|
186
187
|
[],
|
|
187
188
|
null,
|
|
@@ -573,6 +574,76 @@ it("hands the size to the element's CSS when its only tween is a fade", async ()
|
|
|
573
574
|
expect(commitMutation).not.toHaveBeenCalled();
|
|
574
575
|
});
|
|
575
576
|
|
|
577
|
+
describe("the first resize of a keyframed element under auto-record", () => {
|
|
578
|
+
const positionKeys = {
|
|
579
|
+
id: "#title-to-1-position",
|
|
580
|
+
targetSelector: "#title",
|
|
581
|
+
propertyGroup: "position",
|
|
582
|
+
method: "to",
|
|
583
|
+
properties: {},
|
|
584
|
+
keyframes: { keyframes: [{ percentage: 0, properties: { x: 300, y: 200 } }] },
|
|
585
|
+
position: 1,
|
|
586
|
+
resolvedStart: 1,
|
|
587
|
+
duration: 3,
|
|
588
|
+
} as unknown as GsapAnimation;
|
|
589
|
+
|
|
590
|
+
it("writes a size key at the playhead in one mutation and hands the draft size to GSAP", async () => {
|
|
591
|
+
usePlayerStore.setState({ currentTime: 2 });
|
|
592
|
+
const commitMutation = vi.fn();
|
|
593
|
+
const selection = titleSelection();
|
|
594
|
+
selection.element.setAttribute("data-hf-studio-box-size", "true");
|
|
595
|
+
|
|
596
|
+
// fallow-ignore-next-line code-duplication
|
|
597
|
+
const handled = await tryGsapResizeIntercept(
|
|
598
|
+
selection,
|
|
599
|
+
{ width: 424.2, height: 237 },
|
|
600
|
+
[positionKeys],
|
|
601
|
+
null,
|
|
602
|
+
commitMutation,
|
|
603
|
+
);
|
|
604
|
+
|
|
605
|
+
expect(handled).toEqual({ status: "persisted" });
|
|
606
|
+
expect(commitMutation.mock.calls.map((call) => call[1])).toEqual([
|
|
607
|
+
{
|
|
608
|
+
type: "add-with-keyframes",
|
|
609
|
+
targetSelector: "#title",
|
|
610
|
+
position: 2,
|
|
611
|
+
duration: 1,
|
|
612
|
+
keyframes: [{ percentage: 0, properties: { width: 424, height: 237 } }],
|
|
613
|
+
},
|
|
614
|
+
]);
|
|
615
|
+
expect(selection.element.hasAttribute("data-hf-studio-box-size")).toBe(false);
|
|
616
|
+
});
|
|
617
|
+
|
|
618
|
+
const fadeKeys = {
|
|
619
|
+
...positionKeys,
|
|
620
|
+
id: "#title-to-1-visual",
|
|
621
|
+
propertyGroup: "visual",
|
|
622
|
+
keyframes: { keyframes: [{ percentage: 0, properties: { opacity: 0 } }] },
|
|
623
|
+
} as unknown as GsapAnimation;
|
|
624
|
+
|
|
625
|
+
it.each([
|
|
626
|
+
["with auto-record off", positionKeys, false],
|
|
627
|
+
["on an element whose only keyframes fade it", fadeKeys, true],
|
|
628
|
+
])("hands the size to CSS %s", async (_, keyed, autoKeyframeEnabled) => {
|
|
629
|
+
usePlayerStore.setState({ autoKeyframeEnabled });
|
|
630
|
+
const commitMutation = vi.fn();
|
|
631
|
+
try {
|
|
632
|
+
const handled = await tryGsapResizeIntercept(
|
|
633
|
+
titleSelection(),
|
|
634
|
+
{ width: 424, height: 237 },
|
|
635
|
+
[keyed],
|
|
636
|
+
null,
|
|
637
|
+
commitMutation,
|
|
638
|
+
);
|
|
639
|
+
expect(handled).toEqual({ status: "element-size" });
|
|
640
|
+
expect(commitMutation).not.toHaveBeenCalled();
|
|
641
|
+
} finally {
|
|
642
|
+
usePlayerStore.setState({ autoKeyframeEnabled: true });
|
|
643
|
+
}
|
|
644
|
+
});
|
|
645
|
+
});
|
|
646
|
+
|
|
576
647
|
it.each([
|
|
577
648
|
["stays put", { x: 0, y: 0 }, { width: 300 }, undefined],
|
|
578
649
|
["moves", { x: -40, y: 0 }, { width: 300, x: 10, y: 0 }, true],
|
|
@@ -5,14 +5,18 @@
|
|
|
5
5
|
* frame can't jump. Split from gsapRuntimeBridge, which owns the shared
|
|
6
6
|
* group-tween resolution used by the drag/resize/rotate intercepts.
|
|
7
7
|
*/
|
|
8
|
-
import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
|
|
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
11
|
import {
|
|
12
12
|
STUDIO_ORIGINAL_BOX_HEIGHT_ATTR,
|
|
13
13
|
STUDIO_ORIGINAL_BOX_WIDTH_ATTR,
|
|
14
14
|
} from "../components/editor/manualEditsTypes";
|
|
15
|
-
import {
|
|
15
|
+
import {
|
|
16
|
+
setElementGsapPosition,
|
|
17
|
+
setElementGsapScale,
|
|
18
|
+
setElementGsapSize,
|
|
19
|
+
} from "../utils/elementGsap";
|
|
16
20
|
import { usePlayerStore } from "../player/store/playerStore";
|
|
17
21
|
import { readGsapProperty } from "./gsapRuntimeReaders";
|
|
18
22
|
import {
|
|
@@ -23,7 +27,11 @@ import {
|
|
|
23
27
|
findSizeSetAnimation,
|
|
24
28
|
} from "./gsapDragCommit";
|
|
25
29
|
import type { GsapDragCommitCallbacks } from "./gsapDragCommit";
|
|
26
|
-
import {
|
|
30
|
+
import {
|
|
31
|
+
computeDraggedGsapPosition,
|
|
32
|
+
restoreDragOffset,
|
|
33
|
+
type DragStamp,
|
|
34
|
+
} from "./draggedGsapPosition";
|
|
27
35
|
import { pickClosestToPlayhead, readGsapPositionFromIframe } from "./gsapPositionDetection";
|
|
28
36
|
import { commitWholePropertyOffset } from "./gsapWholePropertyOffsetCommit";
|
|
29
37
|
import { commitGsapPositionFromDrag } from "./gsapDragPositionCommit";
|
|
@@ -39,9 +47,11 @@ import {
|
|
|
39
47
|
} from "./gsapEditOutcome";
|
|
40
48
|
import { commitValueAtPlayhead } from "./gsapValueAtPlayhead";
|
|
41
49
|
import { preflightGsapResizeIntercept, resizeRoute } from "./gsapResizePreflight";
|
|
50
|
+
import { singleKeyTweenMutation } from "./useEnableKeyframes";
|
|
42
51
|
|
|
43
52
|
const SIZE_PROPS = new Set(["width", "height"]);
|
|
44
53
|
const POSITION_XY = new Set(["x", "y"]);
|
|
54
|
+
const MOVED_OR_SIZED = new Set(["x", "y", "xPercent", "yPercent", "width", "height"]);
|
|
45
55
|
|
|
46
56
|
/**
|
|
47
57
|
* The element's box before the resize draft ran, in CSS pixels.
|
|
@@ -88,6 +98,7 @@ export async function commitSizeAtPlayhead(
|
|
|
88
98
|
selection.element,
|
|
89
99
|
dragOffset,
|
|
90
100
|
readGsapPositionFromIframe(iframe, selector) ?? { x: 0, y: 0 },
|
|
101
|
+
callbacks.stamp,
|
|
91
102
|
)
|
|
92
103
|
: null;
|
|
93
104
|
const written = await commitValueAtPlayhead(
|
|
@@ -102,12 +113,43 @@ export async function commitSizeAtPlayhead(
|
|
|
102
113
|
...preGestureBoxSize(selection.element),
|
|
103
114
|
...(anchor && { x: anchor.baseGsapX, y: anchor.baseGsapY }),
|
|
104
115
|
},
|
|
105
|
-
...(anchor && { beforeReload: () => restoreDragOffset(selection.element) }),
|
|
116
|
+
...(anchor && { beforeReload: () => restoreDragOffset(selection.element, callbacks.stamp) }),
|
|
106
117
|
},
|
|
107
118
|
);
|
|
108
119
|
return written.status === "persisted" && anchor ? { ...written, ownsDragOffset: true } : written;
|
|
109
120
|
}
|
|
110
121
|
|
|
122
|
+
/** A keyed size is GSAP's at every time; the gesture's draft, re-applied after each seek, would pin it there. */
|
|
123
|
+
function handOverDraftSize(
|
|
124
|
+
selection: DomEditSelection,
|
|
125
|
+
written: GsapEditOutcome,
|
|
126
|
+
size: Record<string, number>,
|
|
127
|
+
draw: <T>(run: () => T) => T,
|
|
128
|
+
): GsapEditOutcome {
|
|
129
|
+
const { width, height } = size;
|
|
130
|
+
if (written.status !== "persisted" || width == null || height == null) return written;
|
|
131
|
+
draw(() => {
|
|
132
|
+
clearStudioBoxSize(selection.element);
|
|
133
|
+
setElementGsapSize(selection.element, width, height);
|
|
134
|
+
});
|
|
135
|
+
return written;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/** Under auto-record, a keyframed element's first resize is a size key at the playhead, held at all times. */
|
|
139
|
+
function firstSizeKey(
|
|
140
|
+
selection: DomEditSelection,
|
|
141
|
+
size: { width: number; height: number },
|
|
142
|
+
resizeGroup: PropertyGroupName,
|
|
143
|
+
animations: GsapAnimation[],
|
|
144
|
+
): Record<string, unknown> | null {
|
|
145
|
+
const { autoKeyframeEnabled, currentTime } = usePlayerStore.getState();
|
|
146
|
+
const keyframed = animations.some(
|
|
147
|
+
(a) => a.keyframes && animationWritesAnyProperty(a, MOVED_OR_SIZED),
|
|
148
|
+
);
|
|
149
|
+
if (resizeGroup !== "size" || !autoKeyframeEnabled || !keyframed) return null;
|
|
150
|
+
return singleKeyTweenMutation(selection, size, currentTime);
|
|
151
|
+
}
|
|
152
|
+
|
|
111
153
|
/**
|
|
112
154
|
* Whether this tween already states scale as `scaleX`/`scaleY`.
|
|
113
155
|
*
|
|
@@ -136,6 +178,7 @@ export async function tryGsapResizeIntercept(
|
|
|
136
178
|
fetchFallbackAnimations?: () => Promise<GsapAnimation[]>,
|
|
137
179
|
dragOffset?: { x: number; y: number },
|
|
138
180
|
draw: <T>(run: () => T) => T = (run) => run(),
|
|
181
|
+
stamp?: DragStamp,
|
|
139
182
|
): Promise<GsapEditOutcome> {
|
|
140
183
|
const fetchedAnimations = fetchFallbackAnimations ? await fetchFallbackAnimations() : [];
|
|
141
184
|
const outcome = preflightGsapResizeIntercept(selection, animations, iframe, fetchedAnimations);
|
|
@@ -170,10 +213,16 @@ export async function tryGsapResizeIntercept(
|
|
|
170
213
|
size,
|
|
171
214
|
});
|
|
172
215
|
if (!anim || isInstantHold(anim)) {
|
|
216
|
+
const sized = { width: roundToLayoutPx(size.width), height: roundToLayoutPx(size.height) };
|
|
173
217
|
const scriptWritesSize = allKnownAnimations.some((a) =>
|
|
174
218
|
animationWritesAnyProperty(a, SIZE_PROPS),
|
|
175
219
|
);
|
|
176
|
-
if (!scriptWritesSize)
|
|
220
|
+
if (!scriptWritesSize) {
|
|
221
|
+
const firstKey = firstSizeKey(selection, sized, resizeGroup, workingAnimations);
|
|
222
|
+
if (!firstKey) return { status: "element-size" };
|
|
223
|
+
await commitMutation(selection, firstKey, { label: "Resize", softReload: true });
|
|
224
|
+
return handOverDraftSize(selection, { status: "persisted" }, sized, draw);
|
|
225
|
+
}
|
|
177
226
|
const sel = selectorFromSelection(selection) ?? writeTargetSelector(selection);
|
|
178
227
|
if (!sel) return { status: "blocked", reason: "no-selector" };
|
|
179
228
|
// A scale hold is not a size hold.
|
|
@@ -191,8 +240,7 @@ export async function tryGsapResizeIntercept(
|
|
|
191
240
|
? (anim ?? findSizeSetAnimation(workingAnimations, sel, selection.element))
|
|
192
241
|
: findSizeSetAnimation(workingAnimations, sel, selection.element);
|
|
193
242
|
|
|
194
|
-
// Keyframe the size
|
|
195
|
-
// rotate do; a fade or a slide on the element gets a plain size.
|
|
243
|
+
// Keyframe the size when a real tween already animates it; a static size set stays a set.
|
|
196
244
|
if (resizeGroup === "size") {
|
|
197
245
|
const animatedTween = pickClosestToPlayhead(
|
|
198
246
|
workingAnimations.filter(
|
|
@@ -204,11 +252,19 @@ export async function tryGsapResizeIntercept(
|
|
|
204
252
|
);
|
|
205
253
|
if (animatedTween) {
|
|
206
254
|
logResize("intercept-route", { route: "keyframed-size", tweenId: animatedTween.id });
|
|
207
|
-
const
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
255
|
+
const written = await commitSizeAtPlayhead(
|
|
256
|
+
selection,
|
|
257
|
+
animatedTween,
|
|
258
|
+
sized,
|
|
259
|
+
iframe,
|
|
260
|
+
dragOffset,
|
|
261
|
+
{
|
|
262
|
+
commitMutation,
|
|
263
|
+
fetchAnimations: fetchFallbackAnimations,
|
|
264
|
+
stamp,
|
|
265
|
+
},
|
|
266
|
+
);
|
|
267
|
+
return handOverDraftSize(selection, written, sized, draw);
|
|
212
268
|
}
|
|
213
269
|
}
|
|
214
270
|
|
|
@@ -333,8 +389,7 @@ export async function tryGsapResizeIntercept(
|
|
|
333
389
|
// it lands back on the drop point. The compensation only applies to a STATIC
|
|
334
390
|
// position (a `tl.set` hold or none) — a keyframed position path has no
|
|
335
391
|
// single anchor to preserve, so it keeps the plain center-scale behavior.
|
|
336
|
-
// The size route
|
|
337
|
-
// it needs none of this.
|
|
392
|
+
// The size route hands its draft to GSAP itself (handOverDraftSize).
|
|
338
393
|
// ponytail: for a 3D-rotated element the rects are AABBs, so the anchor is
|
|
339
394
|
// approximate rather than corner-exact.
|
|
340
395
|
// fallow-ignore-next-line complexity
|
|
@@ -363,6 +418,7 @@ export async function tryGsapResizeIntercept(
|
|
|
363
418
|
selection.element,
|
|
364
419
|
{ x: 0, y: 0 },
|
|
365
420
|
gsapPos,
|
|
421
|
+
stamp,
|
|
366
422
|
);
|
|
367
423
|
const base = { x: baseGsapX, y: baseGsapY };
|
|
368
424
|
setElementGsapPosition(draftEl, base.x, base.y);
|
|
@@ -424,6 +480,7 @@ export async function tryGsapResizeIntercept(
|
|
|
424
480
|
await commitGsapPositionFromDrag(selection, positionTween, delta, base, iframe, {
|
|
425
481
|
commitMutation,
|
|
426
482
|
fetchAnimations: fetchFallbackAnimations,
|
|
483
|
+
stamp,
|
|
427
484
|
}),
|
|
428
485
|
);
|
|
429
486
|
return true;
|
|
@@ -432,6 +489,7 @@ export async function tryGsapResizeIntercept(
|
|
|
432
489
|
await commitStaticGsapPosition(selection, delta, base, selector, existingSet, {
|
|
433
490
|
commitMutation,
|
|
434
491
|
fetchAnimations: fetchFallbackAnimations,
|
|
492
|
+
stamp,
|
|
435
493
|
});
|
|
436
494
|
return true;
|
|
437
495
|
};
|
|
@@ -453,9 +511,18 @@ export async function tryGsapResizeIntercept(
|
|
|
453
511
|
return { status: "persisted", ownsDragOffset: await finalizeScaleResizeCommit() };
|
|
454
512
|
}
|
|
455
513
|
|
|
456
|
-
const callbacks = { commitMutation, fetchAnimations: fetchFallbackAnimations };
|
|
457
|
-
if (resizeGroup === "size")
|
|
458
|
-
|
|
514
|
+
const callbacks = { commitMutation, fetchAnimations: fetchFallbackAnimations, stamp };
|
|
515
|
+
if (resizeGroup === "size") {
|
|
516
|
+
const written = await commitSizeAtPlayhead(
|
|
517
|
+
selection,
|
|
518
|
+
anim,
|
|
519
|
+
resizeProps,
|
|
520
|
+
iframe,
|
|
521
|
+
dragOffset,
|
|
522
|
+
callbacks,
|
|
523
|
+
);
|
|
524
|
+
return handOverDraftSize(selection, written, resizeProps, draw);
|
|
525
|
+
}
|
|
459
526
|
const written = await commitValueAtPlayhead(selection, anim, resizeProps, iframe, callbacks, {
|
|
460
527
|
label: "Resize",
|
|
461
528
|
backfill: resizeBackfill,
|
|
@@ -31,6 +31,7 @@ import { tweenReach, tweensForThisElement } from "./gsapTweenReach";
|
|
|
31
31
|
import { resolveTweenDuration } from "../utils/globalTimeCompiler";
|
|
32
32
|
import { roundTo3 } from "../utils/rounding";
|
|
33
33
|
import type { GsapDragCommitCallbacks } from "./gsapDragCommit";
|
|
34
|
+
import type { DragStamp } from "./draggedGsapPosition";
|
|
34
35
|
import { isInstantHold, selectorFromSelection, writeTargetSelector } from "./gsapShared";
|
|
35
36
|
import {
|
|
36
37
|
findGsapPositionAnimation,
|
|
@@ -243,6 +244,7 @@ export async function tryGsapDragIntercept(
|
|
|
243
244
|
preflightOnly?: boolean;
|
|
244
245
|
preflightPassed?: boolean;
|
|
245
246
|
group?: boolean;
|
|
247
|
+
stamp?: DragStamp;
|
|
246
248
|
},
|
|
247
249
|
): Promise<GsapEditOutcome> {
|
|
248
250
|
if (!options?.preflightPassed) {
|
|
@@ -323,6 +325,7 @@ export async function tryGsapDragIntercept(
|
|
|
323
325
|
await commitStaticGsapPosition(selection, offset, gsapPos, selector, existingSet, {
|
|
324
326
|
commitMutation,
|
|
325
327
|
fetchAnimations: fetchFallbackAnimations,
|
|
328
|
+
stamp: options?.stamp,
|
|
326
329
|
});
|
|
327
330
|
return { status: "persisted" };
|
|
328
331
|
}
|
|
@@ -352,7 +355,7 @@ export async function tryGsapDragIntercept(
|
|
|
352
355
|
}
|
|
353
356
|
}
|
|
354
357
|
|
|
355
|
-
const cbs = { commitMutation, fetchAnimations: fetchFallbackAnimations };
|
|
358
|
+
const cbs = { commitMutation, fetchAnimations: fetchFallbackAnimations, stamp: options?.stamp };
|
|
356
359
|
if (route === "whole-path") {
|
|
357
360
|
await commitWholePathOffset(selection, posAnim, offset, gsapPos, iframe, selector, cbs);
|
|
358
361
|
} else {
|