@hyperframes/studio 0.7.25 → 0.7.26
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/assets/{index-CF35g_E4.js → index-BPya4QRs.js} +1 -1
- package/dist/assets/{index-HBY-OdAh.js → index-BZqvc9Ns.js} +183 -183
- package/dist/assets/{index-Bmd8RnCq.js → index-D3eh3pRK.js} +1 -1
- package/dist/index.d.ts +6 -0
- package/dist/index.html +1 -1
- package/dist/index.js +377 -210
- package/dist/index.js.map +1 -1
- package/package.json +7 -7
- package/src/components/TimelineToolbar.test.tsx +50 -0
- package/src/components/TimelineToolbar.tsx +70 -35
- package/src/components/editor/MotionPathOverlay.tsx +27 -4
- package/src/components/editor/SnapToolbar.test.tsx +124 -0
- package/src/components/editor/SnapToolbar.tsx +1 -0
- package/src/hooks/gsapRuntimeBridge.test.ts +66 -0
- package/src/hooks/gsapRuntimeBridge.ts +41 -1
- package/src/hooks/gsapTweenSynth.test.ts +55 -0
- package/src/hooks/gsapTweenSynth.ts +12 -6
- package/src/hooks/gsapWholePropertyOffsetCommit.test.ts +162 -0
- package/src/hooks/gsapWholePropertyOffsetCommit.ts +85 -0
- package/src/hooks/useAnimatedPropertyCommit.test.tsx +105 -0
- package/src/hooks/useAnimatedPropertyCommit.ts +22 -0
- package/src/hooks/useAppHotkeys.ts +3 -2
- package/src/hooks/useGsapScriptCommits.test.tsx +8 -8
- package/src/hooks/useGsapScriptCommits.ts +6 -1
- package/src/hooks/useRenderClipContent.test.ts +54 -1
- package/src/hooks/useRenderClipContent.ts +7 -5
- package/src/player/components/Timeline.test.ts +51 -1
- package/src/player/components/Timeline.tsx +0 -2
- package/src/player/components/TimelineCanvas.tsx +1 -0
- package/src/player/components/TimelineClipDiamonds.test.tsx +215 -0
- package/src/player/components/TimelineClipDiamonds.tsx +46 -2
- package/src/player/store/playerStore.ts +8 -0
- package/src/utils/gsapSoftReload.test.ts +12 -0
- package/src/utils/gsapSoftReload.ts +15 -2
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
// @vitest-environment happy-dom
|
|
2
|
+
|
|
3
|
+
import React, { act } from "react";
|
|
4
|
+
import { createRoot } from "react-dom/client";
|
|
5
|
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
6
|
+
import { TimelineClipDiamonds } from "./TimelineClipDiamonds";
|
|
7
|
+
|
|
8
|
+
(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
|
9
|
+
|
|
10
|
+
afterEach(() => {
|
|
11
|
+
document.body.innerHTML = "";
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
function pointerEvent(type: string, init: PointerEventInit): Event {
|
|
15
|
+
if (typeof PointerEvent === "function") return new PointerEvent(type, init);
|
|
16
|
+
return new MouseEvent(type, init);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function renderDiamonds(onClickKeyframe = vi.fn()) {
|
|
20
|
+
const host = document.createElement("div");
|
|
21
|
+
document.body.append(host);
|
|
22
|
+
const root = createRoot(host);
|
|
23
|
+
act(() => {
|
|
24
|
+
root.render(
|
|
25
|
+
<TimelineClipDiamonds
|
|
26
|
+
keyframesData={{
|
|
27
|
+
format: "percentage",
|
|
28
|
+
keyframes: [
|
|
29
|
+
{ percentage: 0, properties: { x: 0 } },
|
|
30
|
+
{ percentage: 50, properties: { x: 100 } },
|
|
31
|
+
],
|
|
32
|
+
}}
|
|
33
|
+
clipWidthPx={200}
|
|
34
|
+
clipHeightPx={48}
|
|
35
|
+
accentColor="#4ba3d2"
|
|
36
|
+
isSelected
|
|
37
|
+
currentPercentage={0}
|
|
38
|
+
elementId="clip-1"
|
|
39
|
+
selectedKeyframes={new Set()}
|
|
40
|
+
onClickKeyframe={onClickKeyframe}
|
|
41
|
+
/>,
|
|
42
|
+
);
|
|
43
|
+
});
|
|
44
|
+
return { host, root, onClickKeyframe };
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
describe("TimelineClipDiamonds", () => {
|
|
48
|
+
it("treats primary pointerup without drag as a keyframe click", () => {
|
|
49
|
+
const { host, root, onClickKeyframe } = renderDiamonds();
|
|
50
|
+
const diamond = host.querySelector<HTMLButtonElement>('button[title="50%"]');
|
|
51
|
+
expect(diamond).not.toBeNull();
|
|
52
|
+
|
|
53
|
+
act(() => {
|
|
54
|
+
diamond!.dispatchEvent(pointerEvent("pointerup", { bubbles: true, button: 0 }));
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
expect(onClickKeyframe).toHaveBeenCalledWith(50);
|
|
58
|
+
act(() => root.unmount());
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it("does not treat secondary pointerup as a keyframe click", () => {
|
|
62
|
+
const { host, root, onClickKeyframe } = renderDiamonds();
|
|
63
|
+
const diamond = host.querySelector<HTMLButtonElement>('button[title="50%"]');
|
|
64
|
+
expect(diamond).not.toBeNull();
|
|
65
|
+
|
|
66
|
+
act(() => {
|
|
67
|
+
diamond!.dispatchEvent(pointerEvent("pointerup", { bubbles: true, button: 2 }));
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
expect(onClickKeyframe).not.toHaveBeenCalled();
|
|
71
|
+
act(() => root.unmount());
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
// Regression: once the clip is selected, canDrag arms on every diamond
|
|
75
|
+
// press. A real click's few px of mouse/trackpad jitter then resolves (via
|
|
76
|
+
// the neighbour clamp) back onto ~the same position — "noop", not "move" —
|
|
77
|
+
// which fell through neither branch and silently did nothing: no
|
|
78
|
+
// selection, no retime. It must still count as the click it was.
|
|
79
|
+
it("treats a drag-armed press that resolves to a no-op move as a click", () => {
|
|
80
|
+
const onClickKeyframe = vi.fn();
|
|
81
|
+
const onMoveKeyframe = vi.fn();
|
|
82
|
+
const host = document.createElement("div");
|
|
83
|
+
document.body.append(host);
|
|
84
|
+
const root = createRoot(host);
|
|
85
|
+
act(() => {
|
|
86
|
+
root.render(
|
|
87
|
+
<TimelineClipDiamonds
|
|
88
|
+
keyframesData={{
|
|
89
|
+
format: "percentage",
|
|
90
|
+
keyframes: [
|
|
91
|
+
{ percentage: 0, properties: { x: 0 } },
|
|
92
|
+
{ percentage: 50, properties: { x: 100 } },
|
|
93
|
+
],
|
|
94
|
+
}}
|
|
95
|
+
clipWidthPx={5000}
|
|
96
|
+
clipHeightPx={48}
|
|
97
|
+
accentColor="#4ba3d2"
|
|
98
|
+
isSelected
|
|
99
|
+
currentPercentage={0}
|
|
100
|
+
elementId="clip-1"
|
|
101
|
+
selectedKeyframes={new Set()}
|
|
102
|
+
onClickKeyframe={onClickKeyframe}
|
|
103
|
+
onMoveKeyframe={onMoveKeyframe}
|
|
104
|
+
/>,
|
|
105
|
+
);
|
|
106
|
+
});
|
|
107
|
+
const diamond = host.querySelector<HTMLButtonElement>('button[title="50%"]');
|
|
108
|
+
expect(diamond).not.toBeNull();
|
|
109
|
+
|
|
110
|
+
act(() => {
|
|
111
|
+
diamond!.dispatchEvent(
|
|
112
|
+
pointerEvent("pointerdown", { bubbles: true, button: 0, clientX: 100 }),
|
|
113
|
+
);
|
|
114
|
+
// 4px of travel at a 5000px clip width is ~0.08 clip-% — above the drag
|
|
115
|
+
// threshold (so resolveKeyframeDrag doesn't short-circuit to "click"
|
|
116
|
+
// itself) but below the no-op epsilon once neighbour-clamped.
|
|
117
|
+
diamond!.dispatchEvent(pointerEvent("pointerup", { bubbles: true, button: 0, clientX: 104 }));
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
expect(onClickKeyframe).toHaveBeenCalledWith(50);
|
|
121
|
+
expect(onMoveKeyframe).not.toHaveBeenCalled();
|
|
122
|
+
act(() => root.unmount());
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
// Regression: a genuine retime (drag far enough to actually move the
|
|
126
|
+
// keyframe) committed the move but never selected/parked on the result —
|
|
127
|
+
// the diamond it was just dragged looked exactly like one nothing happened
|
|
128
|
+
// to. Select it at its NEW position too.
|
|
129
|
+
it("selects the keyframe at its new position after a real drag-retime", () => {
|
|
130
|
+
const onClickKeyframe = vi.fn();
|
|
131
|
+
const onMoveKeyframe = vi.fn();
|
|
132
|
+
const host = document.createElement("div");
|
|
133
|
+
document.body.append(host);
|
|
134
|
+
const root = createRoot(host);
|
|
135
|
+
act(() => {
|
|
136
|
+
root.render(
|
|
137
|
+
<TimelineClipDiamonds
|
|
138
|
+
keyframesData={{
|
|
139
|
+
format: "percentage",
|
|
140
|
+
keyframes: [
|
|
141
|
+
{ percentage: 0, properties: { x: 0 } },
|
|
142
|
+
{ percentage: 50, properties: { x: 100 } },
|
|
143
|
+
],
|
|
144
|
+
}}
|
|
145
|
+
clipWidthPx={200}
|
|
146
|
+
clipHeightPx={48}
|
|
147
|
+
accentColor="#4ba3d2"
|
|
148
|
+
isSelected
|
|
149
|
+
currentPercentage={0}
|
|
150
|
+
elementId="clip-1"
|
|
151
|
+
selectedKeyframes={new Set()}
|
|
152
|
+
onClickKeyframe={onClickKeyframe}
|
|
153
|
+
onMoveKeyframe={onMoveKeyframe}
|
|
154
|
+
/>,
|
|
155
|
+
);
|
|
156
|
+
});
|
|
157
|
+
const diamond = host.querySelector<HTMLButtonElement>('button[title="50%"]');
|
|
158
|
+
expect(diamond).not.toBeNull();
|
|
159
|
+
|
|
160
|
+
act(() => {
|
|
161
|
+
diamond!.dispatchEvent(
|
|
162
|
+
pointerEvent("pointerdown", { bubbles: true, button: 0, clientX: 100 }),
|
|
163
|
+
);
|
|
164
|
+
// 4px at a 200px clip width is 2 clip-% — well past the no-op epsilon,
|
|
165
|
+
// a real retime.
|
|
166
|
+
diamond!.dispatchEvent(pointerEvent("pointerup", { bubbles: true, button: 0, clientX: 104 }));
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
expect(onMoveKeyframe).toHaveBeenCalledWith("clip-1", 50, 52);
|
|
170
|
+
expect(onClickKeyframe).toHaveBeenCalledWith(52);
|
|
171
|
+
act(() => root.unmount());
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
// Regression: onClickKeyframe's state updates can re-render the diamond
|
|
175
|
+
// button out from under the gesture before the browser auto-synthesizes the
|
|
176
|
+
// "click" event that follows a button's pointerdown+pointerup. That orphaned
|
|
177
|
+
// click then bubbles to the ancestor clip's onClick, which toggles selection
|
|
178
|
+
// off whenever the clip is already selected — the state a diamond click
|
|
179
|
+
// always happens in — so every keyframe click immediately deselected its
|
|
180
|
+
// own clip. suppressClickRef lets that ancestor ignore the stray click.
|
|
181
|
+
it("arms suppressClickRef synchronously on a keyframe click", () => {
|
|
182
|
+
const suppressClickRef = { current: false };
|
|
183
|
+
const host = document.createElement("div");
|
|
184
|
+
document.body.append(host);
|
|
185
|
+
const root = createRoot(host);
|
|
186
|
+
act(() => {
|
|
187
|
+
root.render(
|
|
188
|
+
<TimelineClipDiamonds
|
|
189
|
+
keyframesData={{
|
|
190
|
+
format: "percentage",
|
|
191
|
+
keyframes: [{ percentage: 50, properties: { x: 100 } }],
|
|
192
|
+
}}
|
|
193
|
+
clipWidthPx={200}
|
|
194
|
+
clipHeightPx={48}
|
|
195
|
+
accentColor="#4ba3d2"
|
|
196
|
+
isSelected
|
|
197
|
+
currentPercentage={0}
|
|
198
|
+
elementId="clip-1"
|
|
199
|
+
selectedKeyframes={new Set()}
|
|
200
|
+
onClickKeyframe={vi.fn()}
|
|
201
|
+
suppressClickRef={suppressClickRef}
|
|
202
|
+
/>,
|
|
203
|
+
);
|
|
204
|
+
});
|
|
205
|
+
const diamond = host.querySelector<HTMLButtonElement>('button[title="50%"]');
|
|
206
|
+
expect(diamond).not.toBeNull();
|
|
207
|
+
|
|
208
|
+
act(() => {
|
|
209
|
+
diamond!.dispatchEvent(pointerEvent("pointerup", { bubbles: true, button: 0 }));
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
expect(suppressClickRef.current).toBe(true);
|
|
213
|
+
act(() => root.unmount());
|
|
214
|
+
});
|
|
215
|
+
});
|
|
@@ -45,6 +45,10 @@ interface TimelineClipDiamondsProps {
|
|
|
45
45
|
fromClipPercentage: number,
|
|
46
46
|
toClipPercentage: number,
|
|
47
47
|
) => void;
|
|
48
|
+
/** Set while resolving a diamond press so the ancestor clip's onClick (which
|
|
49
|
+
* toggles selection off when already selected) ignores the native "click"
|
|
50
|
+
* the browser auto-synthesizes after this button's pointerdown+pointerup. */
|
|
51
|
+
suppressClickRef?: React.RefObject<boolean>;
|
|
48
52
|
}
|
|
49
53
|
|
|
50
54
|
const DIAMOND_RATIO = 0.8;
|
|
@@ -76,6 +80,7 @@ export const TimelineClipDiamonds = memo(function TimelineClipDiamonds({
|
|
|
76
80
|
onShiftClickKeyframe,
|
|
77
81
|
onContextMenuKeyframe,
|
|
78
82
|
onMoveKeyframe,
|
|
83
|
+
suppressClickRef,
|
|
79
84
|
}: TimelineClipDiamondsProps) {
|
|
80
85
|
// Hooks must run before the early return below.
|
|
81
86
|
const dragRef = useRef<DragState | null>(null);
|
|
@@ -83,6 +88,21 @@ export const TimelineClipDiamonds = memo(function TimelineClipDiamonds({
|
|
|
83
88
|
// (that optimistic hold was the #1763 flake). The atomic move-keyframe commit
|
|
84
89
|
// on drop re-keys the diamond from source.
|
|
85
90
|
const [preview, setPreview] = useState<{ kfKey: string; clipPct: number } | null>(null);
|
|
91
|
+
// The button element can re-render (reposition/unmount) synchronously from
|
|
92
|
+
// the state updates onClickKeyframe/onMoveKeyframe trigger, before the
|
|
93
|
+
// browser gets to auto-synthesize the "click" event that normally follows
|
|
94
|
+
// pointerdown+pointerup on a button. That orphaned click then fires on
|
|
95
|
+
// whatever ancestor is still there — the clip wrapper — whose own onClick
|
|
96
|
+
// toggles selection off when the clip is already selected (the state a
|
|
97
|
+
// diamond click always happens in). Suppressing it here is the same fix
|
|
98
|
+
// already used for clip drag/resize in useTimelineClipDrag.ts.
|
|
99
|
+
const suppressNextClick = () => {
|
|
100
|
+
if (!suppressClickRef) return;
|
|
101
|
+
suppressClickRef.current = true;
|
|
102
|
+
requestAnimationFrame(() => {
|
|
103
|
+
suppressClickRef.current = false;
|
|
104
|
+
});
|
|
105
|
+
};
|
|
86
106
|
|
|
87
107
|
if (clipWidthPx < 20) return null;
|
|
88
108
|
|
|
@@ -102,7 +122,19 @@ export const TimelineClipDiamonds = memo(function TimelineClipDiamonds({
|
|
|
102
122
|
const canDrag = isSelected && !!onMoveKeyframe;
|
|
103
123
|
|
|
104
124
|
return (
|
|
105
|
-
<div
|
|
125
|
+
<div
|
|
126
|
+
className="absolute inset-0"
|
|
127
|
+
style={{
|
|
128
|
+
// Above the clip's trim-handle strips (TimelineClip.tsx, z-index 4) so
|
|
129
|
+
// a keyframe sitting in the first/last ~14px of the clip stays
|
|
130
|
+
// clickable instead of being covered by the resize handle. This div
|
|
131
|
+
// establishes its own stacking context (position + z-index), so the
|
|
132
|
+
// diamonds' own z-index (1/2) can't escape it on their own — the bump
|
|
133
|
+
// has to happen here.
|
|
134
|
+
zIndex: 5,
|
|
135
|
+
pointerEvents: "none",
|
|
136
|
+
}}
|
|
137
|
+
>
|
|
106
138
|
{sorted.map((kf, i) => {
|
|
107
139
|
if (i === 0) return null;
|
|
108
140
|
const prev = sorted[i - 1]!;
|
|
@@ -178,6 +210,8 @@ export const TimelineClipDiamonds = memo(function TimelineClipDiamonds({
|
|
|
178
210
|
const d = dragRef.current;
|
|
179
211
|
// No drag armed (canDrag false / non-primary press) → treat as a click.
|
|
180
212
|
if (!d || d.kfKey !== kfKey) {
|
|
213
|
+
if (e.button !== 0) return;
|
|
214
|
+
suppressNextClick();
|
|
181
215
|
if (e.shiftKey) onShiftClickKeyframe?.(elementId, kf.percentage);
|
|
182
216
|
else onClickKeyframe?.(kf.percentage);
|
|
183
217
|
return;
|
|
@@ -186,6 +220,7 @@ export const TimelineClipDiamonds = memo(function TimelineClipDiamonds({
|
|
|
186
220
|
dragRef.current = null;
|
|
187
221
|
setPreview(null);
|
|
188
222
|
e.currentTarget.releasePointerCapture?.(e.pointerId);
|
|
223
|
+
suppressNextClick();
|
|
189
224
|
const res = resolveKeyframeDrag({
|
|
190
225
|
pointerDownX: d.startX,
|
|
191
226
|
pointerUpX: e.clientX,
|
|
@@ -194,11 +229,20 @@ export const TimelineClipDiamonds = memo(function TimelineClipDiamonds({
|
|
|
194
229
|
draggedIndex: i,
|
|
195
230
|
sortedClipPcts,
|
|
196
231
|
});
|
|
197
|
-
if (res.kind === "click") {
|
|
232
|
+
if (res.kind === "click" || res.kind === "noop") {
|
|
233
|
+
// "noop" is a press with enough pointer jitter to arm a drag (canDrag
|
|
234
|
+
// is on for every diamond once the clip is selected) that resolved
|
|
235
|
+
// back onto ~the same position — no real retime, so treat it as the
|
|
236
|
+
// click it was. Otherwise a normal click with a few px of mouse/
|
|
237
|
+
// trackpad drift silently does nothing: no selection, no move.
|
|
198
238
|
if (e.shiftKey) onShiftClickKeyframe?.(elementId, kf.percentage);
|
|
199
239
|
else onClickKeyframe?.(kf.percentage);
|
|
200
240
|
} else if (res.kind === "move" && res.toClipPct != null) {
|
|
201
241
|
onMoveKeyframe?.(elementId, d.fromClipPct, res.toClipPct);
|
|
242
|
+
// A retime still targeted this exact diamond — park/select it at its
|
|
243
|
+
// new position, same as a plain click, or a drag that actually moved
|
|
244
|
+
// something looks identical to one that silently did nothing.
|
|
245
|
+
onClickKeyframe?.(res.toClipPct);
|
|
202
246
|
}
|
|
203
247
|
};
|
|
204
248
|
|
|
@@ -104,6 +104,12 @@ interface PlayerState {
|
|
|
104
104
|
setMotionPathArmed: (armed: boolean) => void;
|
|
105
105
|
motionPathCreateAvailable: boolean;
|
|
106
106
|
setMotionPathCreateAvailable: (available: boolean) => void;
|
|
107
|
+
/** Global toggle for the "Add keyframe" diamond in the timeline toolbar (#1808).
|
|
108
|
+
* When false, a manual drag/resize/rotate edit on an element that already has
|
|
109
|
+
* a live tween shifts every keyframe by the edit's delta (preserving the
|
|
110
|
+
* animation's shape) instead of inserting/updating a keyframe at the playhead. */
|
|
111
|
+
autoKeyframeEnabled: boolean;
|
|
112
|
+
setAutoKeyframeEnabled: (enabled: boolean) => void;
|
|
107
113
|
|
|
108
114
|
/** Multi-select: additional selected elements beyond selectedElementId. */
|
|
109
115
|
selectedElementIds: Set<string>;
|
|
@@ -238,6 +244,8 @@ export const usePlayerStore = create<PlayerState>((set, get) => ({
|
|
|
238
244
|
setMotionPathArmed: (armed) => set({ motionPathArmed: armed }),
|
|
239
245
|
motionPathCreateAvailable: false,
|
|
240
246
|
setMotionPathCreateAvailable: (available) => set({ motionPathCreateAvailable: available }),
|
|
247
|
+
autoKeyframeEnabled: true,
|
|
248
|
+
setAutoKeyframeEnabled: (enabled) => set({ autoKeyframeEnabled: enabled }),
|
|
241
249
|
|
|
242
250
|
selectedElementIds: new Set<string>(),
|
|
243
251
|
toggleSelectedElementId: (id: string) =>
|
|
@@ -100,6 +100,18 @@ describe("applySoftReload", () => {
|
|
|
100
100
|
expect(contentWindow.__hfStudioManualEditsApply).toHaveBeenCalled();
|
|
101
101
|
});
|
|
102
102
|
|
|
103
|
+
it("seeks to the caller-supplied currentTime override instead of the iframe's own __player.getTime()", () => {
|
|
104
|
+
// Regression: the iframe's raw __player.getTime() (2.0 here, per the mock)
|
|
105
|
+
// can desync from the studio's authoritative scrub position — e.g. a
|
|
106
|
+
// keyframe-node drag parks the playhead via the store before this reload's
|
|
107
|
+
// async commit resolves. The rebuilt timeline must re-seek to the caller's
|
|
108
|
+
// value, not the iframe's possibly-stale one.
|
|
109
|
+
const { iframe, contentWindow } = buildMockIframe();
|
|
110
|
+
const result = applySoftReload(iframe, SCRIPT_TEXT, undefined, 0);
|
|
111
|
+
expect(result).toBe("applied");
|
|
112
|
+
expect(contentWindow.__player.seek).toHaveBeenCalledWith(0);
|
|
113
|
+
});
|
|
114
|
+
|
|
103
115
|
it("strips a stale inline transform from an orphaned (non-timeline-child) element", () => {
|
|
104
116
|
// Repro: an element dragged via gsap.set whose keyframes were then removed is
|
|
105
117
|
// no longer a timeline child, so the timeline-children sweep misses it. Its
|
|
@@ -175,6 +175,7 @@ export function applySoftReload(
|
|
|
175
175
|
iframe: HTMLIFrameElement | null,
|
|
176
176
|
scriptText: string,
|
|
177
177
|
onAsyncFailure?: () => void,
|
|
178
|
+
currentTimeOverride?: number,
|
|
178
179
|
): SoftReloadResult {
|
|
179
180
|
if (!iframe || !scriptText) return "cannot-soft-reload";
|
|
180
181
|
|
|
@@ -210,7 +211,14 @@ export function applySoftReload(
|
|
|
210
211
|
// rather than killing the target timeline and appending an orphan script.
|
|
211
212
|
if (gsapScripts.length > 1 && staleScripts.length === 0) return "cannot-soft-reload";
|
|
212
213
|
|
|
213
|
-
|
|
214
|
+
// Prefer the caller-supplied scrub position (the studio's own authoritative
|
|
215
|
+
// currentTime, e.g. usePlayerStore) over the iframe's raw `__player.getTime()`:
|
|
216
|
+
// the two can desync (a keyframe-node drag parks the playhead via the store
|
|
217
|
+
// BEFORE this reload's async commit resolves, and the iframe's own GSAP clock
|
|
218
|
+
// doesn't reliably reflect that yet), which re-seeks the freshly rebuilt
|
|
219
|
+
// timeline to the wrong frame and leaves the element (and its overlay)
|
|
220
|
+
// rendered at a stale/unrelated position.
|
|
221
|
+
const currentTime = currentTimeOverride ?? win.__player?.getTime?.() ?? 0;
|
|
214
222
|
|
|
215
223
|
// Track whether the MotionPath async path was taken. When it is, the script
|
|
216
224
|
// executes inside pluginScript.onload — after applySoftReload has already
|
|
@@ -300,8 +308,13 @@ export function applySoftReload(
|
|
|
300
308
|
const s = doc.createElement("script");
|
|
301
309
|
s.textContent = `(function(){${scriptText}\n})();`;
|
|
302
310
|
doc.body.appendChild(s);
|
|
303
|
-
|
|
311
|
+
// Seek BEFORE rebind: __hfForceTimelineRebind's own internal force-render
|
|
312
|
+
// (see init.ts) renders the freshly-created timeline at whatever the
|
|
313
|
+
// runtime's internal scrub position already is, not at whatever we pass
|
|
314
|
+
// here afterward — a redundant seek() call after rebind can be a GSAP
|
|
315
|
+
// no-op if the timeline already reports being at that time internally.
|
|
304
316
|
win.__player?.seek?.(currentTime);
|
|
317
|
+
win.__hfForceTimelineRebind?.();
|
|
305
318
|
win.__hfStudioManualEditsApply?.();
|
|
306
319
|
};
|
|
307
320
|
|