@hyperframes/studio 0.7.7 → 0.7.8
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-B2YXvFxf.js → index--lOAjFJl.js} +1 -1
- package/dist/assets/{index-BeRh2hMe.js → index-BSyZKYmH.js} +194 -194
- package/dist/assets/{index-BoASKOeE.js → index-Dgeszckd.js} +1 -1
- package/dist/assets/index-svYFaNuq.css +1 -0
- package/dist/index.d.ts +4 -1
- package/dist/index.html +2 -2
- package/dist/index.js +2699 -2052
- package/dist/index.js.map +1 -1
- package/package.json +5 -5
- package/src/components/StudioRightPanel.tsx +5 -1
- package/src/components/editor/DomEditOverlay.tsx +4 -12
- package/src/components/editor/MarqueeOverlay.tsx +40 -0
- package/src/components/editor/OffCanvasIndicators.tsx +2 -7
- package/src/components/editor/PropertyPanel.tsx +12 -0
- package/src/components/editor/Transform3DCube.tsx +313 -0
- package/src/components/editor/gsapAnimationConstants.ts +5 -0
- package/src/components/editor/marqueeCommit.ts +63 -20
- package/src/components/editor/propertyPanel3dTransform.tsx +311 -92
- package/src/components/editor/propertyPanelHelpers.ts +43 -21
- package/src/components/editor/transform3dProjection.test.ts +99 -0
- package/src/components/editor/transform3dProjection.ts +172 -0
- package/src/components/sidebar/AssetsTab.tsx +23 -213
- package/src/components/sidebar/AudioRow.tsx +202 -0
- package/src/contexts/DomEditContext.tsx +4 -0
- package/src/hooks/gsapDragCommit.test.ts +9 -5
- package/src/hooks/gsapDragCommit.ts +28 -9
- package/src/hooks/gsapRuntimeKeyframes.ts +50 -5
- package/src/hooks/gsapRuntimePatch.ts +162 -35
- package/src/hooks/useAnimatedPropertyCommit.ts +256 -78
- package/src/hooks/useDomEditCommits.ts +0 -2
- package/src/hooks/useDomEditSession.ts +4 -2
- package/src/hooks/useDomGeometryCommits.ts +3 -31
- package/src/hooks/useGsapAwareEditing.ts +31 -1
- package/src/hooks/useGsapKeyframeOps.ts +4 -1
- package/src/hooks/useGsapSelectionHandlers.ts +12 -9
- package/src/hooks/useGsapTweenCache.ts +6 -4
- package/src/utils/marqueeGeometry.test.ts +15 -98
- package/src/utils/marqueeGeometry.ts +1 -158
- package/dist/assets/index-BSkUuN8g.css +0 -1
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
import { useState, useRef, useEffect, useCallback } from "react";
|
|
2
|
+
import { ContextMenu } from "./AssetContextMenu";
|
|
3
|
+
import { basename, getAudioSubtype } from "./assetHelpers";
|
|
4
|
+
import { TIMELINE_ASSET_MIME } from "../../utils/timelineAssetDrop";
|
|
5
|
+
|
|
6
|
+
export function AudioRow({
|
|
7
|
+
projectId,
|
|
8
|
+
asset,
|
|
9
|
+
used,
|
|
10
|
+
meta,
|
|
11
|
+
onCopy,
|
|
12
|
+
isCopied,
|
|
13
|
+
onDelete,
|
|
14
|
+
onRename,
|
|
15
|
+
}: {
|
|
16
|
+
projectId: string;
|
|
17
|
+
asset: string;
|
|
18
|
+
used: boolean;
|
|
19
|
+
meta?: { description?: string; duration?: number };
|
|
20
|
+
onCopy: (path: string) => void;
|
|
21
|
+
isCopied: boolean;
|
|
22
|
+
onDelete?: (path: string) => void;
|
|
23
|
+
onRename?: (oldPath: string, newPath: string) => void;
|
|
24
|
+
}) {
|
|
25
|
+
const [contextMenu, setContextMenu] = useState<{ x: number; y: number } | null>(null);
|
|
26
|
+
const [playing, setPlaying] = useState(false);
|
|
27
|
+
const [bars, setBars] = useState<number[]>([]);
|
|
28
|
+
const audioRef = useRef<HTMLAudioElement | null>(null);
|
|
29
|
+
const actxRef = useRef<AudioContext | null>(null);
|
|
30
|
+
const analyserRef = useRef<AnalyserNode | null>(null);
|
|
31
|
+
const sourceRef = useRef<MediaElementAudioSourceNode | null>(null);
|
|
32
|
+
const animRef = useRef<number>(0);
|
|
33
|
+
const name = basename(asset);
|
|
34
|
+
const subtype = getAudioSubtype(asset);
|
|
35
|
+
const serveUrl = `/api/projects/${projectId}/preview/${asset}`;
|
|
36
|
+
|
|
37
|
+
useEffect(() => {
|
|
38
|
+
return () => {
|
|
39
|
+
cancelAnimationFrame(animRef.current);
|
|
40
|
+
audioRef.current?.pause();
|
|
41
|
+
actxRef.current?.close();
|
|
42
|
+
};
|
|
43
|
+
}, []);
|
|
44
|
+
|
|
45
|
+
useEffect(() => {
|
|
46
|
+
if (playing) {
|
|
47
|
+
const barCount = 24;
|
|
48
|
+
const loop = () => {
|
|
49
|
+
const analyser = analyserRef.current;
|
|
50
|
+
if (!analyser) {
|
|
51
|
+
animRef.current = requestAnimationFrame(loop);
|
|
52
|
+
return;
|
|
53
|
+
}
|
|
54
|
+
const data = new Uint8Array(analyser.frequencyBinCount);
|
|
55
|
+
analyser.getByteFrequencyData(data);
|
|
56
|
+
const step = Math.floor(data.length / barCount);
|
|
57
|
+
const next: number[] = [];
|
|
58
|
+
for (let i = 0; i < barCount; i++) {
|
|
59
|
+
let sum = 0;
|
|
60
|
+
for (let j = 0; j < step; j++) sum += data[i * step + j];
|
|
61
|
+
next.push(sum / step / 255);
|
|
62
|
+
}
|
|
63
|
+
setBars(next);
|
|
64
|
+
if (audioRef.current && !audioRef.current.paused)
|
|
65
|
+
animRef.current = requestAnimationFrame(loop);
|
|
66
|
+
};
|
|
67
|
+
animRef.current = requestAnimationFrame(loop);
|
|
68
|
+
} else {
|
|
69
|
+
setBars([]);
|
|
70
|
+
}
|
|
71
|
+
return () => cancelAnimationFrame(animRef.current);
|
|
72
|
+
}, [playing]);
|
|
73
|
+
|
|
74
|
+
const togglePlay = useCallback(async () => {
|
|
75
|
+
if (playing) {
|
|
76
|
+
audioRef.current?.pause();
|
|
77
|
+
setPlaying(false);
|
|
78
|
+
cancelAnimationFrame(animRef.current);
|
|
79
|
+
return;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
if (!actxRef.current) {
|
|
83
|
+
actxRef.current = new AudioContext();
|
|
84
|
+
analyserRef.current = actxRef.current.createAnalyser();
|
|
85
|
+
analyserRef.current.fftSize = 256;
|
|
86
|
+
analyserRef.current.smoothingTimeConstant = 0.7;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
if (!audioRef.current) {
|
|
90
|
+
const el = new Audio();
|
|
91
|
+
el.onended = () => {
|
|
92
|
+
setPlaying(false);
|
|
93
|
+
cancelAnimationFrame(animRef.current);
|
|
94
|
+
};
|
|
95
|
+
audioRef.current = el;
|
|
96
|
+
sourceRef.current = actxRef.current.createMediaElementSource(el);
|
|
97
|
+
sourceRef.current.connect(analyserRef.current!);
|
|
98
|
+
analyserRef.current!.connect(actxRef.current.destination);
|
|
99
|
+
el.src = serveUrl;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
if (actxRef.current.state === "suspended") await actxRef.current.resume();
|
|
103
|
+
audioRef.current.currentTime = 0;
|
|
104
|
+
await audioRef.current.play();
|
|
105
|
+
setPlaying(true);
|
|
106
|
+
}, [serveUrl, playing]);
|
|
107
|
+
|
|
108
|
+
return (
|
|
109
|
+
<>
|
|
110
|
+
<div
|
|
111
|
+
draggable
|
|
112
|
+
onClick={() => onCopy(asset)}
|
|
113
|
+
onDragStart={(e) => {
|
|
114
|
+
e.dataTransfer.effectAllowed = "copy";
|
|
115
|
+
e.dataTransfer.setData(TIMELINE_ASSET_MIME, JSON.stringify({ path: asset }));
|
|
116
|
+
e.dataTransfer.setData("text/plain", asset);
|
|
117
|
+
}}
|
|
118
|
+
onContextMenu={(e) => {
|
|
119
|
+
e.preventDefault();
|
|
120
|
+
setContextMenu({ x: e.clientX, y: e.clientY });
|
|
121
|
+
}}
|
|
122
|
+
className={`group w-full text-left px-4 py-1.5 flex items-center gap-2.5 transition-all cursor-pointer ${
|
|
123
|
+
playing
|
|
124
|
+
? "bg-panel-accent/[0.06]"
|
|
125
|
+
: isCopied
|
|
126
|
+
? "bg-panel-accent/10"
|
|
127
|
+
: "hover:bg-panel-surface-hover"
|
|
128
|
+
}`}
|
|
129
|
+
>
|
|
130
|
+
<button
|
|
131
|
+
className={`w-7 h-7 rounded-md flex-shrink-0 flex items-center justify-center transition-all ${
|
|
132
|
+
playing
|
|
133
|
+
? "bg-panel-accent/15 text-panel-accent"
|
|
134
|
+
: "text-panel-text-5 group-hover:text-panel-text-3"
|
|
135
|
+
}`}
|
|
136
|
+
onClick={(e) => {
|
|
137
|
+
e.stopPropagation();
|
|
138
|
+
togglePlay();
|
|
139
|
+
}}
|
|
140
|
+
>
|
|
141
|
+
{playing ? (
|
|
142
|
+
<svg width="12" height="12" viewBox="0 0 24 24" fill="currentColor">
|
|
143
|
+
<rect x="6" y="4" width="4" height="16" rx="1" />
|
|
144
|
+
<rect x="14" y="4" width="4" height="16" rx="1" />
|
|
145
|
+
</svg>
|
|
146
|
+
) : (
|
|
147
|
+
<svg width="12" height="12" viewBox="0 0 24 24" fill="currentColor">
|
|
148
|
+
<polygon points="6,4 20,12 6,20" />
|
|
149
|
+
</svg>
|
|
150
|
+
)}
|
|
151
|
+
</button>
|
|
152
|
+
<div className="min-w-0 flex-1">
|
|
153
|
+
<div className="flex items-center gap-1.5">
|
|
154
|
+
<span
|
|
155
|
+
className={`text-[12px] font-medium truncate ${used ? "text-panel-text-1" : "text-panel-text-3"}`}
|
|
156
|
+
>
|
|
157
|
+
{name}
|
|
158
|
+
</span>
|
|
159
|
+
{!playing && (
|
|
160
|
+
<span className="text-[11px] text-panel-text-5 flex-shrink-0">
|
|
161
|
+
{meta?.duration ? `${meta.duration}s · ` : ""}
|
|
162
|
+
{subtype}
|
|
163
|
+
</span>
|
|
164
|
+
)}
|
|
165
|
+
{used && (
|
|
166
|
+
<span className="text-[9px] font-medium text-panel-accent bg-panel-accent/10 px-1.5 py-px rounded flex-shrink-0">
|
|
167
|
+
in use
|
|
168
|
+
</span>
|
|
169
|
+
)}
|
|
170
|
+
</div>
|
|
171
|
+
{bars.length > 0 && (
|
|
172
|
+
<div className="flex items-end gap-[2px] h-[14px] mt-0.5">
|
|
173
|
+
{bars.map((v, i) => (
|
|
174
|
+
<div
|
|
175
|
+
key={i}
|
|
176
|
+
className="flex-1 rounded-[1px]"
|
|
177
|
+
style={{
|
|
178
|
+
height: `${Math.max(10, v * 100)}%`,
|
|
179
|
+
background: `linear-gradient(to top, rgba(60, 230, 172, ${0.3 + v * 0.5}), rgba(60, 230, 172, ${0.5 + v * 0.5}))`,
|
|
180
|
+
transition: "height 80ms ease-out",
|
|
181
|
+
}}
|
|
182
|
+
/>
|
|
183
|
+
))}
|
|
184
|
+
</div>
|
|
185
|
+
)}
|
|
186
|
+
</div>
|
|
187
|
+
</div>
|
|
188
|
+
|
|
189
|
+
{contextMenu && (
|
|
190
|
+
<ContextMenu
|
|
191
|
+
x={contextMenu.x}
|
|
192
|
+
y={contextMenu.y}
|
|
193
|
+
asset={asset}
|
|
194
|
+
onClose={() => setContextMenu(null)}
|
|
195
|
+
onCopy={onCopy}
|
|
196
|
+
onDelete={onDelete}
|
|
197
|
+
onRename={onRename}
|
|
198
|
+
/>
|
|
199
|
+
)}
|
|
200
|
+
</>
|
|
201
|
+
);
|
|
202
|
+
}
|
|
@@ -55,6 +55,7 @@ export interface DomEditActionsValue extends Pick<
|
|
|
55
55
|
| "handleGsapRemoveAllKeyframes"
|
|
56
56
|
| "handleResetSelectedElementKeyframes"
|
|
57
57
|
| "commitAnimatedProperty"
|
|
58
|
+
| "commitAnimatedProperties"
|
|
58
59
|
| "handleSetArcPath"
|
|
59
60
|
| "handleUpdateArcSegment"
|
|
60
61
|
| "handleUnroll"
|
|
@@ -164,6 +165,7 @@ export function DomEditProvider({
|
|
|
164
165
|
handleGsapRemoveAllKeyframes,
|
|
165
166
|
handleResetSelectedElementKeyframes,
|
|
166
167
|
commitAnimatedProperty,
|
|
168
|
+
commitAnimatedProperties,
|
|
167
169
|
handleSetArcPath,
|
|
168
170
|
handleUpdateArcSegment,
|
|
169
171
|
handleUnroll,
|
|
@@ -238,6 +240,7 @@ export function DomEditProvider({
|
|
|
238
240
|
handleGsapRemoveAllKeyframes,
|
|
239
241
|
handleResetSelectedElementKeyframes,
|
|
240
242
|
commitAnimatedProperty,
|
|
243
|
+
commitAnimatedProperties,
|
|
241
244
|
handleSetArcPath,
|
|
242
245
|
handleUpdateArcSegment,
|
|
243
246
|
handleUnroll,
|
|
@@ -298,6 +301,7 @@ export function DomEditProvider({
|
|
|
298
301
|
handleGsapRemoveAllKeyframes,
|
|
299
302
|
handleResetSelectedElementKeyframes,
|
|
300
303
|
commitAnimatedProperty,
|
|
304
|
+
commitAnimatedProperties,
|
|
301
305
|
handleSetArcPath,
|
|
302
306
|
handleUpdateArcSegment,
|
|
303
307
|
handleUnroll,
|
|
@@ -332,7 +332,7 @@ describe("commitStaticGsapPosition — instantPatch (value-only set)", () => {
|
|
|
332
332
|
expect(yPatch.change.props[xMutation.property]).toBe(xMutation.value);
|
|
333
333
|
});
|
|
334
334
|
|
|
335
|
-
it("
|
|
335
|
+
it("ADDS a global gsap.set with a global-set instantPatch (off-timeline, no flash)", async () => {
|
|
336
336
|
const { commits, callbacks } = optionRecordingCallbacks();
|
|
337
337
|
|
|
338
338
|
await commitStaticGsapPosition(
|
|
@@ -340,13 +340,15 @@ describe("commitStaticGsapPosition — instantPatch (value-only set)", () => {
|
|
|
340
340
|
{ x: -50, y: 30 },
|
|
341
341
|
{ x: 0, y: 0 },
|
|
342
342
|
"#puck-a",
|
|
343
|
-
null, // no existing set → `add` a new
|
|
343
|
+
null, // no existing set → `add` a new base gsap.set
|
|
344
344
|
callbacks,
|
|
345
345
|
);
|
|
346
346
|
|
|
347
347
|
expect(commits).toHaveLength(1);
|
|
348
348
|
expect(commits[0].mutation.type).toBe("add");
|
|
349
|
-
expect(commits[0].
|
|
349
|
+
expect((commits[0].mutation as { global?: boolean }).global).toBe(true);
|
|
350
|
+
const patch = commits[0].options.instantPatch as { change: { kind: string } } | undefined;
|
|
351
|
+
expect(patch?.change.kind).toBe("global-set");
|
|
350
352
|
});
|
|
351
353
|
});
|
|
352
354
|
|
|
@@ -372,14 +374,16 @@ describe("commitStaticGsapRotation — instantPatch (value-only set)", () => {
|
|
|
372
374
|
expect(patch.change.props[m.property]).toBe(m.value);
|
|
373
375
|
});
|
|
374
376
|
|
|
375
|
-
it("
|
|
377
|
+
it("ADDS a global gsap.set with a global-set instantPatch (off-timeline, no flash)", async () => {
|
|
376
378
|
const { commits, callbacks } = optionRecordingCallbacks();
|
|
377
379
|
|
|
378
380
|
await commitStaticGsapRotation(selection(), 42, "#puck-a", null, callbacks);
|
|
379
381
|
|
|
380
382
|
expect(commits).toHaveLength(1);
|
|
381
383
|
expect(commits[0].mutation.type).toBe("add");
|
|
382
|
-
expect(commits[0].
|
|
384
|
+
expect((commits[0].mutation as { global?: boolean }).global).toBe(true);
|
|
385
|
+
const patch = commits[0].options.instantPatch as { change: { kind: string } } | undefined;
|
|
386
|
+
expect(patch?.change.kind).toBe("global-set");
|
|
383
387
|
});
|
|
384
388
|
});
|
|
385
389
|
|
|
@@ -120,18 +120,22 @@ interface UpdatePropertyMutation {
|
|
|
120
120
|
function setPatchFromUpdateProperties(
|
|
121
121
|
selector: string,
|
|
122
122
|
mutations: UpdatePropertyMutation[],
|
|
123
|
+
global = false,
|
|
123
124
|
): { selector: string; change: RuntimeTweenChange } {
|
|
124
125
|
const props: SetPatchProps = {};
|
|
125
126
|
for (const m of mutations) props[m.property as keyof SetPatchProps] = m.value;
|
|
126
|
-
|
|
127
|
+
// An off-timeline `gsap.set` has no runtime tween to patch — apply it to the
|
|
128
|
+
// element directly. An on-timeline `tl.set` mutates its tween (so a re-seek keeps it).
|
|
129
|
+
return { selector, change: { kind: global ? "global-set" : "set", props } };
|
|
127
130
|
}
|
|
128
131
|
|
|
129
132
|
/** Single-mutation convenience over {@link setPatchFromUpdateProperties}. */
|
|
130
133
|
function setPatchFromUpdateProperty(
|
|
131
134
|
selector: string,
|
|
132
135
|
mutation: UpdatePropertyMutation,
|
|
136
|
+
global = false,
|
|
133
137
|
): { selector: string; change: RuntimeTweenChange } {
|
|
134
|
-
return setPatchFromUpdateProperties(selector, [mutation]);
|
|
138
|
+
return setPatchFromUpdateProperties(selector, [mutation], global);
|
|
135
139
|
}
|
|
136
140
|
|
|
137
141
|
/**
|
|
@@ -194,22 +198,25 @@ export async function commitStaticGsapPosition(
|
|
|
194
198
|
// preview still reflects what DID persist. The x commit carries skipReload
|
|
195
199
|
// (no reload), so its instantPatch gives instant feedback without a reload;
|
|
196
200
|
// the y commit triggers the soft reload (skipped when the patch applies).
|
|
201
|
+
const global = !!existingSet.global;
|
|
197
202
|
await callbacks.commitMutation(selection, xMutation, {
|
|
198
203
|
label: "Move layer",
|
|
199
204
|
skipReload: true,
|
|
200
205
|
coalesceKey,
|
|
201
|
-
instantPatch: setPatchFromUpdateProperty(selector, xMutation),
|
|
206
|
+
instantPatch: setPatchFromUpdateProperty(selector, xMutation, global),
|
|
202
207
|
});
|
|
203
208
|
await callbacks.commitMutation(selection, yMutation, {
|
|
204
209
|
label: "Move layer",
|
|
205
210
|
softReload: true,
|
|
206
211
|
coalesceKey,
|
|
207
212
|
// Final commit of the coalesced x/y pair: carry both channels so the
|
|
208
|
-
// runtime
|
|
209
|
-
instantPatch: setPatchFromUpdateProperties(selector, [xMutation, yMutation]),
|
|
213
|
+
// runtime set lands the complete {x,y} pose in place.
|
|
214
|
+
instantPatch: setPatchFromUpdateProperties(selector, [xMutation, yMutation], global),
|
|
210
215
|
});
|
|
211
216
|
return;
|
|
212
217
|
}
|
|
218
|
+
// New static hold → a base `gsap.set` (off-timeline, no 0% keyframe marker), with
|
|
219
|
+
// an instant patch so the first nudge shows immediately (no soft-reload flash).
|
|
213
220
|
await callbacks.commitMutation(
|
|
214
221
|
selection,
|
|
215
222
|
{
|
|
@@ -218,8 +225,13 @@ export async function commitStaticGsapPosition(
|
|
|
218
225
|
method: "set",
|
|
219
226
|
position: 0,
|
|
220
227
|
properties: { x: newX, y: newY },
|
|
228
|
+
global: true,
|
|
229
|
+
},
|
|
230
|
+
{
|
|
231
|
+
label: "Move layer",
|
|
232
|
+
softReload: true,
|
|
233
|
+
instantPatch: { selector, change: { kind: "global-set", props: { x: newX, y: newY } } },
|
|
221
234
|
},
|
|
222
|
-
{ label: "Move layer", softReload: true },
|
|
223
235
|
);
|
|
224
236
|
}
|
|
225
237
|
|
|
@@ -264,11 +276,13 @@ export async function commitStaticGsapRotation(
|
|
|
264
276
|
await callbacks.commitMutation(selection, rotationMutation, {
|
|
265
277
|
label: "Rotate layer",
|
|
266
278
|
softReload: true,
|
|
267
|
-
// Value-only rotation set
|
|
268
|
-
|
|
279
|
+
// Value-only rotation set — patch the runtime in place (off-timeline gsap.set
|
|
280
|
+
// applies to the element directly; on-timeline tl.set patches its tween).
|
|
281
|
+
instantPatch: setPatchFromUpdateProperty(selector, rotationMutation, !!existingSet.global),
|
|
269
282
|
});
|
|
270
283
|
return;
|
|
271
284
|
}
|
|
285
|
+
// New static hold → off-timeline `gsap.set` (no 0% keyframe marker) + instant patch.
|
|
272
286
|
await callbacks.commitMutation(
|
|
273
287
|
selection,
|
|
274
288
|
{
|
|
@@ -277,8 +291,13 @@ export async function commitStaticGsapRotation(
|
|
|
277
291
|
method: "set",
|
|
278
292
|
position: 0,
|
|
279
293
|
properties: { rotation: newRotation },
|
|
294
|
+
global: true,
|
|
295
|
+
},
|
|
296
|
+
{
|
|
297
|
+
label: "Rotate layer",
|
|
298
|
+
softReload: true,
|
|
299
|
+
instantPatch: { selector, change: { kind: "global-set", props: { rotation: newRotation } } },
|
|
280
300
|
},
|
|
281
|
-
{ label: "Rotate layer", softReload: true },
|
|
282
301
|
);
|
|
283
302
|
}
|
|
284
303
|
|
|
@@ -12,12 +12,23 @@ import { buildArcPath, type ArcPathConfig } from "@hyperframes/core/gsap-parser-
|
|
|
12
12
|
import { parsePercentageKeyframes, toAbsoluteTime } from "./gsapShared";
|
|
13
13
|
import { roundTo3 } from "../utils/rounding";
|
|
14
14
|
|
|
15
|
+
/**
|
|
16
|
+
* A GSAP tween's `vars` object — intentionally open: it mixes channel values
|
|
17
|
+
* (numbers), easing (strings), flags (booleans), nested keyframes (objects) and
|
|
18
|
+
* callbacks. Named so call sites read as "GSAP config", not an untyped escape hatch.
|
|
19
|
+
*/
|
|
20
|
+
export type GsapVars = Record<string, unknown>;
|
|
21
|
+
|
|
15
22
|
export interface RuntimeTween {
|
|
16
23
|
targets?: () => Element[];
|
|
17
|
-
vars?:
|
|
24
|
+
vars?: GsapVars;
|
|
18
25
|
duration?: () => number;
|
|
19
26
|
startTime?: () => number;
|
|
20
27
|
invalidate?: () => RuntimeTween;
|
|
28
|
+
/** Remove this tween from its parent timeline (GSAP `kill()`). */
|
|
29
|
+
kill?: () => void;
|
|
30
|
+
/** The timeline this tween lives in — used to re-insert a rebuilt tween. */
|
|
31
|
+
parent?: RuntimeTimeline;
|
|
21
32
|
}
|
|
22
33
|
|
|
23
34
|
export interface RuntimeTimeline {
|
|
@@ -25,6 +36,8 @@ export interface RuntimeTimeline {
|
|
|
25
36
|
duration?: () => number;
|
|
26
37
|
time?: () => number;
|
|
27
38
|
invalidate?: () => RuntimeTimeline;
|
|
39
|
+
/** Add a tween at an absolute position — used to rebuild a keyframe tween in place. */
|
|
40
|
+
to?: (targets: Element[], vars: GsapVars, position?: number) => RuntimeTween;
|
|
28
41
|
}
|
|
29
42
|
|
|
30
43
|
type Pct = { percentage: number; properties: Record<string, number | string> };
|
|
@@ -184,6 +197,26 @@ function varsCarryChannel(vars: Record<string, unknown> | undefined, channels: s
|
|
|
184
197
|
return false;
|
|
185
198
|
}
|
|
186
199
|
|
|
200
|
+
/**
|
|
201
|
+
* Like `varsCarryChannel` but for a keyframe tween: the channels live inside the
|
|
202
|
+
* keyframe steps (`vars.keyframes`), not as own props of `vars`. Handles the object
|
|
203
|
+
* form (`{ "0%": {...} }`) and the array form (`[{...}, ...]`).
|
|
204
|
+
*/
|
|
205
|
+
function keyframeVarsCarryChannel(
|
|
206
|
+
vars: Record<string, unknown> | undefined,
|
|
207
|
+
channels: string[],
|
|
208
|
+
): boolean {
|
|
209
|
+
const kf = vars?.keyframes;
|
|
210
|
+
if (!kf || typeof kf !== "object") return false;
|
|
211
|
+
const steps = Array.isArray(kf) ? kf : Object.values(kf);
|
|
212
|
+
return steps.some(
|
|
213
|
+
(step) =>
|
|
214
|
+
step != null &&
|
|
215
|
+
typeof step === "object" &&
|
|
216
|
+
channels.some((ch) => Object.prototype.hasOwnProperty.call(step, ch)),
|
|
217
|
+
);
|
|
218
|
+
}
|
|
219
|
+
|
|
187
220
|
/**
|
|
188
221
|
* Resolve the live tween targeting `selector` using the SAME all-timelines scan
|
|
189
222
|
* `readRuntimeKeyframes` uses, so read and write agree on "which tween". With
|
|
@@ -197,6 +230,7 @@ function varsCarryChannel(vars: Record<string, unknown> | undefined, channels: s
|
|
|
197
230
|
* on a rotation-only set). With no channel-matching set, it falls back to the
|
|
198
231
|
* first matching set (back-compat). `channels` is ignored for `kind: "keyframe"`.
|
|
199
232
|
*/
|
|
233
|
+
// fallow-ignore-next-line complexity
|
|
200
234
|
export function resolveRuntimeTween(
|
|
201
235
|
iframe: HTMLIFrameElement | null,
|
|
202
236
|
selector: string,
|
|
@@ -219,7 +253,12 @@ export function resolveRuntimeTween(
|
|
|
219
253
|
? [compositionId]
|
|
220
254
|
: Object.keys(timelines).filter((k) => typeof timelines[k]?.getChildren === "function");
|
|
221
255
|
|
|
222
|
-
|
|
256
|
+
// Channels disambiguate co-located tweens for BOTH kinds: a `set` carries them as
|
|
257
|
+
// own vars props, a keyframe tween carries them inside its keyframe steps. An
|
|
258
|
+
// element can have a rotation keyframe tween AND a position keyframe tween; a
|
|
259
|
+
// rotation edit must land on the former. The reader passes no channels, so its
|
|
260
|
+
// playhead-containment path below is unchanged.
|
|
261
|
+
const wantChannels = channels && channels.length > 0 ? channels : null;
|
|
223
262
|
|
|
224
263
|
let first: ResolvedRuntimeTween | null = null;
|
|
225
264
|
let channelMatch: ResolvedRuntimeTween | null = null;
|
|
@@ -233,11 +272,15 @@ export function resolveRuntimeTween(
|
|
|
233
272
|
const isSet = !(dur > 0);
|
|
234
273
|
if (kind === "set" ? !isSet : isSet) continue;
|
|
235
274
|
if (wantChannels) {
|
|
236
|
-
|
|
275
|
+
const carries =
|
|
276
|
+
kind === "set"
|
|
277
|
+
? varsCarryChannel(tween.vars, wantChannels)
|
|
278
|
+
: keyframeVarsCarryChannel(tween.vars, wantChannels);
|
|
279
|
+
if (carries) {
|
|
237
280
|
if (channelMatch === null) channelMatch = { tween, timeline };
|
|
238
281
|
} else if (first === null) {
|
|
239
|
-
// A
|
|
240
|
-
// fallback, but never prefer it over a channel-matching
|
|
282
|
+
// A tween carrying only disjoint channels: remember as last-resort
|
|
283
|
+
// fallback, but never prefer it over a channel-matching one.
|
|
241
284
|
first = { tween, timeline };
|
|
242
285
|
}
|
|
243
286
|
continue;
|
|
@@ -267,6 +310,7 @@ function readCarriesChannel(read: ReadTween, channels: string[]): boolean {
|
|
|
267
310
|
* whenever the playhead sits in that tween's range but outside the position
|
|
268
311
|
* tween's). Omitted → any keyframed tween qualifies (back-compat).
|
|
269
312
|
*/
|
|
313
|
+
// fallow-ignore-next-line complexity
|
|
270
314
|
export function readRuntimeKeyframes(
|
|
271
315
|
iframe: HTMLIFrameElement | null,
|
|
272
316
|
selector: string,
|
|
@@ -334,6 +378,7 @@ export function readRuntimeKeyframes(
|
|
|
334
378
|
* only a hold may remain, and resurrecting the deleted tween from the stale parse
|
|
335
379
|
* must be avoided.
|
|
336
380
|
*/
|
|
381
|
+
// fallow-ignore-next-line complexity
|
|
337
382
|
export function hasNonHoldTweenForElement(
|
|
338
383
|
iframe: HTMLIFrameElement | null,
|
|
339
384
|
selector: string,
|