@hyperframes/studio 0.6.104 → 0.6.106
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-BITwbxi-.css → index-BrhJl2JY.css} +1 -1
- package/dist/assets/{index-JOvcn0L_.js → index-CBS_7p5W.js} +1 -1
- package/dist/assets/{index-_elwW-31.js → index-CRCE5Dz_.js} +1 -1
- package/dist/assets/index-koqvg-_0.js +296 -0
- package/dist/index.html +2 -2
- package/package.json +5 -5
- package/src/components/StudioRightPanel.tsx +2 -0
- package/src/components/editor/AnimationCard.tsx +8 -24
- package/src/components/editor/ComputedTweenNotice.tsx +40 -0
- package/src/components/editor/GsapAnimationSection.tsx +5 -24
- package/src/components/editor/PropertyPanel.tsx +2 -0
- package/src/components/editor/gsapAnimationCallbacks.ts +33 -0
- package/src/components/editor/propertyPanelHelpers.ts +2 -0
- package/src/contexts/DomEditContext.tsx +4 -0
- package/src/hooks/gsapDragCommit.ts +90 -3
- package/src/hooks/gsapRuntimeBridge.ts +70 -19
- package/src/hooks/gsapRuntimeKeyframes.test.ts +47 -0
- package/src/hooks/gsapRuntimeKeyframes.ts +198 -123
- package/src/hooks/useDomEditSession.ts +2 -0
- package/src/hooks/useDomGeometryCommits.ts +9 -1
- package/src/hooks/useGsapAwareEditing.ts +21 -0
- package/src/hooks/useGsapTweenCache.ts +49 -7
- package/dist/assets/index-CtVcyRD2.js +0 -296
package/dist/index.html
CHANGED
|
@@ -5,8 +5,8 @@
|
|
|
5
5
|
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
|
|
6
6
|
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
|
7
7
|
<title>HyperFrames Studio</title>
|
|
8
|
-
<script type="module" crossorigin src="/assets/index-
|
|
9
|
-
<link rel="stylesheet" crossorigin href="/assets/index-
|
|
8
|
+
<script type="module" crossorigin src="/assets/index-koqvg-_0.js"></script>
|
|
9
|
+
<link rel="stylesheet" crossorigin href="/assets/index-BrhJl2JY.css">
|
|
10
10
|
</head>
|
|
11
11
|
<body>
|
|
12
12
|
<div id="root"></div>
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@hyperframes/studio",
|
|
3
|
-
"version": "0.6.
|
|
3
|
+
"version": "0.6.106",
|
|
4
4
|
"description": "",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -33,9 +33,9 @@
|
|
|
33
33
|
"@phosphor-icons/react": "^2.1.10",
|
|
34
34
|
"bpm-detective": "^2.0.5",
|
|
35
35
|
"mediabunny": "^1.45.3",
|
|
36
|
-
"@hyperframes/core": "0.6.
|
|
37
|
-
"@hyperframes/
|
|
38
|
-
"@hyperframes/
|
|
36
|
+
"@hyperframes/core": "0.6.106",
|
|
37
|
+
"@hyperframes/sdk": "0.6.106",
|
|
38
|
+
"@hyperframes/player": "0.6.106"
|
|
39
39
|
},
|
|
40
40
|
"devDependencies": {
|
|
41
41
|
"@types/react": "19",
|
|
@@ -49,7 +49,7 @@
|
|
|
49
49
|
"vite": "^6.4.2",
|
|
50
50
|
"vitest": "^3.2.4",
|
|
51
51
|
"zustand": "^5.0.0",
|
|
52
|
-
"@hyperframes/producer": "0.6.
|
|
52
|
+
"@hyperframes/producer": "0.6.106"
|
|
53
53
|
},
|
|
54
54
|
"peerDependencies": {
|
|
55
55
|
"react": "19",
|
|
@@ -87,6 +87,7 @@ export function StudioRightPanel({
|
|
|
87
87
|
commitAnimatedProperty,
|
|
88
88
|
handleSetArcPath,
|
|
89
89
|
handleUpdateArcSegment,
|
|
90
|
+
handleUnroll,
|
|
90
91
|
handleGsapAddKeyframe,
|
|
91
92
|
handleGsapRemoveKeyframe,
|
|
92
93
|
handleGsapConvertToKeyframes,
|
|
@@ -215,6 +216,7 @@ export function StudioRightPanel({
|
|
|
215
216
|
onSeekToTime={(t) => usePlayerStore.getState().requestSeek(t)}
|
|
216
217
|
onSetArcPath={handleSetArcPath}
|
|
217
218
|
onUpdateArcSegment={handleUpdateArcSegment}
|
|
219
|
+
onUnroll={handleUnroll}
|
|
218
220
|
recordingState={recordingState}
|
|
219
221
|
recordingDuration={recordingDuration}
|
|
220
222
|
onToggleRecording={onToggleRecording}
|
|
@@ -18,7 +18,8 @@ import {
|
|
|
18
18
|
import { buildTweenSummary } from "./gsapAnimationHelpers";
|
|
19
19
|
import { EaseCurveSection } from "./EaseCurveSection";
|
|
20
20
|
import { ArcPathControls } from "./ArcPathControls";
|
|
21
|
-
import type {
|
|
21
|
+
import type { GsapAnimationEditCallbacks } from "./gsapAnimationCallbacks";
|
|
22
|
+
import { ComputedTweenNotice } from "./ComputedTweenNotice";
|
|
22
23
|
import { P } from "./panelTokens";
|
|
23
24
|
const BOOLEAN_PROPS = new Set(["visibility"]);
|
|
24
25
|
const STRING_PROPS = new Set(["filter", "clipPath"]);
|
|
@@ -235,31 +236,9 @@ function parseNumericOrString(raw: string): number | string {
|
|
|
235
236
|
return Number.isFinite(num) ? num : raw;
|
|
236
237
|
}
|
|
237
238
|
|
|
238
|
-
interface AnimationCardProps {
|
|
239
|
+
interface AnimationCardProps extends GsapAnimationEditCallbacks {
|
|
239
240
|
animation: GsapAnimation;
|
|
240
241
|
defaultExpanded: boolean;
|
|
241
|
-
onUpdateProperty: (animationId: string, property: string, value: number | string) => void;
|
|
242
|
-
onUpdateMeta: (
|
|
243
|
-
animationId: string,
|
|
244
|
-
updates: { duration?: number; ease?: string; position?: number },
|
|
245
|
-
) => void;
|
|
246
|
-
onDeleteAnimation: (animationId: string) => void;
|
|
247
|
-
onAddProperty: (animationId: string, property: string) => void;
|
|
248
|
-
onRemoveProperty: (animationId: string, property: string) => void;
|
|
249
|
-
onUpdateFromProperty?: (animationId: string, property: string, value: number | string) => void;
|
|
250
|
-
onAddFromProperty?: (animationId: string, property: string) => void;
|
|
251
|
-
onRemoveFromProperty?: (animationId: string, property: string) => void;
|
|
252
|
-
onLivePreview?: (property: string, value: number | string) => void;
|
|
253
|
-
onLivePreviewEnd?: () => void;
|
|
254
|
-
onSetArcPath?: (
|
|
255
|
-
animationId: string,
|
|
256
|
-
config: { enabled: boolean; autoRotate?: boolean | number; segments?: ArcPathSegment[] },
|
|
257
|
-
) => void;
|
|
258
|
-
onUpdateArcSegment?: (
|
|
259
|
-
animationId: string,
|
|
260
|
-
segmentIndex: number,
|
|
261
|
-
update: Partial<ArcPathSegment>,
|
|
262
|
-
) => void;
|
|
263
242
|
}
|
|
264
243
|
|
|
265
244
|
// fallow-ignore-next-line complexity
|
|
@@ -278,6 +257,7 @@ export const AnimationCard = memo(function AnimationCard({
|
|
|
278
257
|
onLivePreviewEnd,
|
|
279
258
|
onSetArcPath,
|
|
280
259
|
onUpdateArcSegment,
|
|
260
|
+
onUnroll,
|
|
281
261
|
}: AnimationCardProps) {
|
|
282
262
|
const [expanded, setExpanded] = useState(defaultExpanded);
|
|
283
263
|
const [addingProp, setAddingProp] = useState(false);
|
|
@@ -397,6 +377,10 @@ export const AnimationCard = memo(function AnimationCard({
|
|
|
397
377
|
{expanded && (
|
|
398
378
|
<div className="pt-2">
|
|
399
379
|
<div className="space-y-3">
|
|
380
|
+
<ComputedTweenNotice
|
|
381
|
+
provenance={animation.provenance}
|
|
382
|
+
onUnroll={onUnroll ? () => onUnroll(animation.id) : undefined}
|
|
383
|
+
/>
|
|
400
384
|
<div className="flex items-start gap-2">
|
|
401
385
|
<div className="flex-1">
|
|
402
386
|
<p className="text-[10px] leading-relaxed text-neutral-400 italic">{summary}</p>
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { editabilityForProvenance, type GsapProvenance } from "@hyperframes/core/gsap-parser-acorn";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Notice shown for computed tweens: helper/loop tweens offer an "unroll to
|
|
5
|
+
* edit" action; runtime-computed values point to the Code tab. Literal tweens
|
|
6
|
+
* render nothing.
|
|
7
|
+
*/
|
|
8
|
+
export function ComputedTweenNotice({
|
|
9
|
+
provenance,
|
|
10
|
+
onUnroll,
|
|
11
|
+
}: {
|
|
12
|
+
provenance?: GsapProvenance;
|
|
13
|
+
onUnroll?: () => void;
|
|
14
|
+
}) {
|
|
15
|
+
const editability = editabilityForProvenance(provenance);
|
|
16
|
+
if (editability === "direct") return null;
|
|
17
|
+
if (editability === "source") {
|
|
18
|
+
return (
|
|
19
|
+
<div className="rounded-md border border-neutral-800 bg-neutral-900/50 px-2 py-1.5 text-[9px] text-neutral-400">
|
|
20
|
+
Computed value — edit it in the Code tab.
|
|
21
|
+
</div>
|
|
22
|
+
);
|
|
23
|
+
}
|
|
24
|
+
const source = provenance?.fn ? `${provenance.fn}()` : "a loop";
|
|
25
|
+
return (
|
|
26
|
+
<div className="flex items-center justify-between gap-2 rounded-md border border-neutral-800 bg-neutral-900/50 px-2 py-1.5 text-[9px] text-neutral-400">
|
|
27
|
+
<span>Generated by {source} — not directly editable.</span>
|
|
28
|
+
{onUnroll && (
|
|
29
|
+
<button
|
|
30
|
+
type="button"
|
|
31
|
+
onClick={onUnroll}
|
|
32
|
+
className="flex-shrink-0 rounded px-1.5 py-0.5 text-[9px] font-medium text-panel-accent hover:bg-neutral-800"
|
|
33
|
+
title="Rewrite the helper/loop into explicit tweens so this keyframe edits directly"
|
|
34
|
+
>
|
|
35
|
+
Unroll to edit
|
|
36
|
+
</button>
|
|
37
|
+
)}
|
|
38
|
+
</div>
|
|
39
|
+
);
|
|
40
|
+
}
|
|
@@ -1,37 +1,16 @@
|
|
|
1
1
|
import { memo, useState } from "react";
|
|
2
|
-
import type {
|
|
2
|
+
import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
|
|
3
3
|
import { Film } from "../../icons/SystemIcons";
|
|
4
4
|
import { Section } from "./propertyPanelPrimitives";
|
|
5
5
|
import { ADD_METHODS, ADD_METHOD_LABELS, METHOD_TOOLTIPS } from "./gsapAnimationConstants";
|
|
6
6
|
import { AnimationCard } from "./AnimationCard";
|
|
7
|
+
import type { GsapAnimationEditCallbacks } from "./gsapAnimationCallbacks";
|
|
7
8
|
|
|
8
|
-
interface GsapAnimationSectionProps {
|
|
9
|
+
interface GsapAnimationSectionProps extends GsapAnimationEditCallbacks {
|
|
9
10
|
animations: GsapAnimation[];
|
|
10
11
|
multipleTimelines?: boolean;
|
|
11
12
|
unsupportedTimelinePattern?: boolean;
|
|
12
|
-
onUpdateProperty: (animationId: string, property: string, value: number | string) => void;
|
|
13
|
-
onUpdateMeta: (
|
|
14
|
-
animationId: string,
|
|
15
|
-
updates: { duration?: number; ease?: string; position?: number },
|
|
16
|
-
) => void;
|
|
17
|
-
onDeleteAnimation: (animationId: string) => void;
|
|
18
|
-
onAddProperty: (animationId: string, property: string) => void;
|
|
19
|
-
onRemoveProperty: (animationId: string, property: string) => void;
|
|
20
|
-
onUpdateFromProperty?: (animationId: string, property: string, value: number | string) => void;
|
|
21
|
-
onAddFromProperty?: (animationId: string, property: string) => void;
|
|
22
|
-
onRemoveFromProperty?: (animationId: string, property: string) => void;
|
|
23
13
|
onAddAnimation: (method: "to" | "from" | "set" | "fromTo") => void;
|
|
24
|
-
onLivePreview?: (property: string, value: number | string) => void;
|
|
25
|
-
onLivePreviewEnd?: () => void;
|
|
26
|
-
onSetArcPath?: (
|
|
27
|
-
animationId: string,
|
|
28
|
-
config: { enabled: boolean; autoRotate?: boolean | number; segments?: ArcPathSegment[] },
|
|
29
|
-
) => void;
|
|
30
|
-
onUpdateArcSegment?: (
|
|
31
|
-
animationId: string,
|
|
32
|
-
segmentIndex: number,
|
|
33
|
-
update: Partial<ArcPathSegment>,
|
|
34
|
-
) => void;
|
|
35
14
|
}
|
|
36
15
|
|
|
37
16
|
export const GsapAnimationSection = memo(function GsapAnimationSection({
|
|
@@ -51,6 +30,7 @@ export const GsapAnimationSection = memo(function GsapAnimationSection({
|
|
|
51
30
|
onLivePreviewEnd,
|
|
52
31
|
onSetArcPath,
|
|
53
32
|
onUpdateArcSegment,
|
|
33
|
+
onUnroll,
|
|
54
34
|
}: GsapAnimationSectionProps) {
|
|
55
35
|
const [addMenuOpen, setAddMenuOpen] = useState(false);
|
|
56
36
|
|
|
@@ -88,6 +68,7 @@ export const GsapAnimationSection = memo(function GsapAnimationSection({
|
|
|
88
68
|
onLivePreviewEnd={onLivePreviewEnd}
|
|
89
69
|
onSetArcPath={onSetArcPath}
|
|
90
70
|
onUpdateArcSegment={onUpdateArcSegment}
|
|
71
|
+
onUnroll={onUnroll}
|
|
91
72
|
/>
|
|
92
73
|
))}
|
|
93
74
|
|
|
@@ -74,6 +74,7 @@ export const PropertyPanel = memo(function PropertyPanel({
|
|
|
74
74
|
onAddGsapAnimation,
|
|
75
75
|
onSetArcPath,
|
|
76
76
|
onUpdateArcSegment,
|
|
77
|
+
onUnroll,
|
|
77
78
|
onAddKeyframe,
|
|
78
79
|
onRemoveKeyframe,
|
|
79
80
|
onConvertToKeyframes,
|
|
@@ -531,6 +532,7 @@ export const PropertyPanel = memo(function PropertyPanel({
|
|
|
531
532
|
onAddAnimation={onAddGsapAnimation}
|
|
532
533
|
onSetArcPath={onSetArcPath}
|
|
533
534
|
onUpdateArcSegment={onUpdateArcSegment}
|
|
535
|
+
onUnroll={onUnroll}
|
|
534
536
|
/>
|
|
535
537
|
)}
|
|
536
538
|
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import type { ArcPathSegment } from "@hyperframes/core/gsap-parser";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Edit callbacks shared by GsapAnimationSection and each AnimationCard it
|
|
5
|
+
* renders. Extracted so the two prop interfaces don't duplicate the (large)
|
|
6
|
+
* signatures the section forwards straight through to the card.
|
|
7
|
+
*/
|
|
8
|
+
export interface GsapAnimationEditCallbacks {
|
|
9
|
+
onUpdateProperty: (animationId: string, property: string, value: number | string) => void;
|
|
10
|
+
onUpdateMeta: (
|
|
11
|
+
animationId: string,
|
|
12
|
+
updates: { duration?: number; ease?: string; position?: number },
|
|
13
|
+
) => void;
|
|
14
|
+
onDeleteAnimation: (animationId: string) => void;
|
|
15
|
+
onAddProperty: (animationId: string, property: string) => void;
|
|
16
|
+
onRemoveProperty: (animationId: string, property: string) => void;
|
|
17
|
+
onUpdateFromProperty?: (animationId: string, property: string, value: number | string) => void;
|
|
18
|
+
onAddFromProperty?: (animationId: string, property: string) => void;
|
|
19
|
+
onRemoveFromProperty?: (animationId: string, property: string) => void;
|
|
20
|
+
onLivePreview?: (property: string, value: number | string) => void;
|
|
21
|
+
onLivePreviewEnd?: () => void;
|
|
22
|
+
onSetArcPath?: (
|
|
23
|
+
animationId: string,
|
|
24
|
+
config: { enabled: boolean; autoRotate?: boolean | number; segments?: ArcPathSegment[] },
|
|
25
|
+
) => void;
|
|
26
|
+
onUpdateArcSegment?: (
|
|
27
|
+
animationId: string,
|
|
28
|
+
segmentIndex: number,
|
|
29
|
+
update: Partial<ArcPathSegment>,
|
|
30
|
+
) => void;
|
|
31
|
+
/** Unroll a computed (helper/loop) tween into literal tweens so it edits directly. */
|
|
32
|
+
onUnroll?: (animationId: string) => void;
|
|
33
|
+
}
|
|
@@ -56,6 +56,8 @@ export interface PropertyPanelProps {
|
|
|
56
56
|
segmentIndex: number,
|
|
57
57
|
update: Partial<import("@hyperframes/core/gsap-parser").ArcPathSegment>,
|
|
58
58
|
) => void;
|
|
59
|
+
/** Unroll computed (helper/loop) tweens into literal tweens for direct editing. */
|
|
60
|
+
onUnroll?: (animationId: string) => void;
|
|
59
61
|
onAddKeyframe?: (
|
|
60
62
|
animationId: string,
|
|
61
63
|
percentage: number,
|
|
@@ -56,6 +56,7 @@ export interface DomEditActionsValue extends Pick<
|
|
|
56
56
|
| "commitAnimatedProperty"
|
|
57
57
|
| "handleSetArcPath"
|
|
58
58
|
| "handleUpdateArcSegment"
|
|
59
|
+
| "handleUnroll"
|
|
59
60
|
| "invalidateGsapCache"
|
|
60
61
|
| "previewIframeRef"
|
|
61
62
|
| "commitMutation"
|
|
@@ -160,6 +161,7 @@ export function DomEditProvider({
|
|
|
160
161
|
commitAnimatedProperty,
|
|
161
162
|
handleSetArcPath,
|
|
162
163
|
handleUpdateArcSegment,
|
|
164
|
+
handleUnroll,
|
|
163
165
|
invalidateGsapCache,
|
|
164
166
|
previewIframeRef,
|
|
165
167
|
commitMutation,
|
|
@@ -229,6 +231,7 @@ export function DomEditProvider({
|
|
|
229
231
|
commitAnimatedProperty,
|
|
230
232
|
handleSetArcPath,
|
|
231
233
|
handleUpdateArcSegment,
|
|
234
|
+
handleUnroll,
|
|
232
235
|
invalidateGsapCache,
|
|
233
236
|
previewIframeRef,
|
|
234
237
|
commitMutation: stableCommitMutation,
|
|
@@ -284,6 +287,7 @@ export function DomEditProvider({
|
|
|
284
287
|
commitAnimatedProperty,
|
|
285
288
|
handleSetArcPath,
|
|
286
289
|
handleUpdateArcSegment,
|
|
290
|
+
handleUnroll,
|
|
287
291
|
invalidateGsapCache,
|
|
288
292
|
previewIframeRef,
|
|
289
293
|
stableCommitMutation,
|
|
@@ -160,14 +160,93 @@ async function commitFlatViaKeyframes(
|
|
|
160
160
|
properties: Record<string, number>,
|
|
161
161
|
callbacks: GsapDragCommitCallbacks,
|
|
162
162
|
beforeReload?: () => void,
|
|
163
|
+
iframe?: HTMLIFrameElement | null,
|
|
164
|
+
selector?: string,
|
|
163
165
|
): Promise<void> {
|
|
166
|
+
const ct = usePlayerStore.getState().currentTime;
|
|
167
|
+
const ts = resolveTweenStart(anim);
|
|
168
|
+
const td = resolveTweenDuration(anim);
|
|
169
|
+
const outsideRange = ts !== null && td > 0 && (ct < ts - 0.01 || ct > ts + td + 0.01);
|
|
170
|
+
|
|
171
|
+
// Read the runtime position at the tween's start time so the 0% keyframe
|
|
172
|
+
// captures the actual interpolated value (e.g. x=300 after a preceding slide),
|
|
173
|
+
// not the identity value (x=0) that a blind convert would produce.
|
|
174
|
+
const resolvedFromValues: Record<string, number | string> = {};
|
|
175
|
+
if (iframe && selector && ts !== null) {
|
|
176
|
+
try {
|
|
177
|
+
const iframeWin = iframe.contentWindow as any;
|
|
178
|
+
const gsapLib = iframeWin?.gsap;
|
|
179
|
+
const el = iframe.contentDocument?.querySelector(selector);
|
|
180
|
+
const timelines = iframeWin?.__timelines;
|
|
181
|
+
const mainTl = timelines ? (Object.values(timelines)[0] as any) : null;
|
|
182
|
+
if (gsapLib && el && mainTl?.seek) {
|
|
183
|
+
mainTl.seek(ts);
|
|
184
|
+
for (const key of Object.keys(properties)) {
|
|
185
|
+
const v = Number(gsapLib.getProperty(el, key));
|
|
186
|
+
if (Number.isFinite(v)) resolvedFromValues[key] = roundTo3(v);
|
|
187
|
+
}
|
|
188
|
+
mainTl.seek(ct);
|
|
189
|
+
}
|
|
190
|
+
} catch {
|
|
191
|
+
/* iframe access failed — fall back to identity values */
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
if (outsideRange && ts !== null) {
|
|
196
|
+
// Outside the tween's range: add a brand new keyframed tween at the drag
|
|
197
|
+
// time instead of extending/replacing the existing one. This keeps all
|
|
198
|
+
// existing tweens untouched and creates a clean hold at the dragged position.
|
|
199
|
+
const tweenEnd = ts + td;
|
|
200
|
+
const holdStart = ct > tweenEnd ? tweenEnd : ct;
|
|
201
|
+
const holdEnd = ct > tweenEnd ? ct : ts;
|
|
202
|
+
const holdDur = Math.max(0.01, holdEnd - holdStart);
|
|
203
|
+
const kfs =
|
|
204
|
+
ct > tweenEnd
|
|
205
|
+
? [
|
|
206
|
+
{ percentage: 0, properties: resolvedFromValues },
|
|
207
|
+
{ percentage: 100, properties },
|
|
208
|
+
]
|
|
209
|
+
: [
|
|
210
|
+
{ percentage: 0, properties },
|
|
211
|
+
{ percentage: 100, properties: resolvedFromValues },
|
|
212
|
+
];
|
|
213
|
+
console.log(
|
|
214
|
+
"[drag:5] outside range — adding new tween",
|
|
215
|
+
JSON.stringify({
|
|
216
|
+
ct,
|
|
217
|
+
ts,
|
|
218
|
+
td,
|
|
219
|
+
holdStart: roundTo3(holdStart),
|
|
220
|
+
holdDur: roundTo3(holdDur),
|
|
221
|
+
from: resolvedFromValues,
|
|
222
|
+
to: properties,
|
|
223
|
+
}),
|
|
224
|
+
);
|
|
225
|
+
await callbacks.commitMutation(
|
|
226
|
+
selection,
|
|
227
|
+
{
|
|
228
|
+
type: "add-with-keyframes",
|
|
229
|
+
targetSelector: anim.targetSelector,
|
|
230
|
+
position: roundTo3(holdStart),
|
|
231
|
+
duration: roundTo3(holdDur),
|
|
232
|
+
keyframes: kfs,
|
|
233
|
+
},
|
|
234
|
+
{ label: "Move layer (new keyframe)", softReload: true, beforeReload },
|
|
235
|
+
);
|
|
236
|
+
return;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
// Inside range: convert the flat tween to keyframes, then add at current %.
|
|
164
240
|
const coalesceKey = `gsap:convert-drag:${anim.id}`;
|
|
165
241
|
await callbacks.commitMutation(
|
|
166
242
|
selection,
|
|
167
|
-
{
|
|
243
|
+
{
|
|
244
|
+
type: "convert-to-keyframes",
|
|
245
|
+
animationId: anim.id,
|
|
246
|
+
...(Object.keys(resolvedFromValues).length > 0 ? { resolvedFromValues } : {}),
|
|
247
|
+
},
|
|
168
248
|
{ label: "Convert to keyframes for drag", skipReload: true, coalesceKey },
|
|
169
249
|
);
|
|
170
|
-
|
|
171
250
|
const pct = computeCurrentPercentage(selection, anim);
|
|
172
251
|
|
|
173
252
|
await callbacks.commitMutation(
|
|
@@ -350,6 +429,14 @@ export async function commitGsapPositionFromDrag(
|
|
|
350
429
|
);
|
|
351
430
|
}
|
|
352
431
|
} else {
|
|
353
|
-
await commitFlatViaKeyframes(
|
|
432
|
+
await commitFlatViaKeyframes(
|
|
433
|
+
selection,
|
|
434
|
+
anim,
|
|
435
|
+
{ x: newX, y: newY },
|
|
436
|
+
callbacks,
|
|
437
|
+
restoreOffset,
|
|
438
|
+
iframe,
|
|
439
|
+
selector,
|
|
440
|
+
);
|
|
354
441
|
}
|
|
355
442
|
}
|
|
@@ -73,7 +73,11 @@ function findGsapPositionAnimation(
|
|
|
73
73
|
else if (a.targetSelector.includes(",")) score -= 5;
|
|
74
74
|
const pos = a.resolvedStart ?? (typeof a.position === "number" ? a.position : 0);
|
|
75
75
|
const dur = a.duration ?? 0;
|
|
76
|
-
if (currentTime >= pos - 0.05 && currentTime <= pos + dur + 0.05) score +=
|
|
76
|
+
if (currentTime >= pos - 0.05 && currentTime <= pos + dur + 0.05) score += 50;
|
|
77
|
+
else
|
|
78
|
+
score -= Math.round(
|
|
79
|
+
Math.min(Math.abs(currentTime - pos), Math.abs(currentTime - pos - dur)) * 5,
|
|
80
|
+
);
|
|
77
81
|
return { anim: a, score };
|
|
78
82
|
});
|
|
79
83
|
scored.sort((a, b) => b.score - a.score);
|
|
@@ -84,6 +88,34 @@ function findGsapPositionAnimation(
|
|
|
84
88
|
|
|
85
89
|
// ── Property-group tween resolution ───────────────────────────────────────
|
|
86
90
|
|
|
91
|
+
/**
|
|
92
|
+
* From a set of candidate tweens, pick the one whose time range is closest to
|
|
93
|
+
* the current playhead. A tween that *contains* the playhead wins outright;
|
|
94
|
+
* otherwise the nearest endpoint wins. This ensures a drag at t=6s edits (or
|
|
95
|
+
* extends) the 4s tween, not the 1.5s one. Tie-break: most keyframes (so a
|
|
96
|
+
* gesture-recorded tween beats a stub when both are equidistant).
|
|
97
|
+
*/
|
|
98
|
+
function pickClosestToPlayhead(anims: GsapAnimation[]): GsapAnimation | null {
|
|
99
|
+
if (anims.length <= 1) return anims[0] ?? null;
|
|
100
|
+
const ct = usePlayerStore.getState().currentTime;
|
|
101
|
+
return anims.reduce((best, a) => {
|
|
102
|
+
const s = resolveTweenStart(a) ?? 0;
|
|
103
|
+
const e = s + resolveTweenDuration(a);
|
|
104
|
+
const dist = ct >= s && ct <= e ? 0 : Math.min(Math.abs(ct - s), Math.abs(ct - e));
|
|
105
|
+
const bestS = resolveTweenStart(best) ?? 0;
|
|
106
|
+
const bestE = bestS + resolveTweenDuration(best);
|
|
107
|
+
const bestDist =
|
|
108
|
+
ct >= bestS && ct <= bestE ? 0 : Math.min(Math.abs(ct - bestS), Math.abs(ct - bestE));
|
|
109
|
+
if (dist < bestDist) return a;
|
|
110
|
+
if (
|
|
111
|
+
dist === bestDist &&
|
|
112
|
+
(a.keyframes?.keyframes.length ?? 0) > (best.keyframes?.keyframes.length ?? 0)
|
|
113
|
+
)
|
|
114
|
+
return a;
|
|
115
|
+
return best;
|
|
116
|
+
});
|
|
117
|
+
}
|
|
118
|
+
|
|
87
119
|
/**
|
|
88
120
|
* Find the tween for a given property group, splitting a legacy mixed tween
|
|
89
121
|
* if necessary. Returns the resolved animation or null if none exists.
|
|
@@ -101,15 +133,10 @@ async function resolveGroupTween(
|
|
|
101
133
|
commitMutation: GsapDragCommitCallbacks["commitMutation"],
|
|
102
134
|
fetchFallbackAnimations?: () => Promise<GsapAnimation[]>,
|
|
103
135
|
): Promise<{ anim: GsapAnimation; animations: GsapAnimation[] } | null> {
|
|
104
|
-
// 1. Already-split group tween —
|
|
105
|
-
//
|
|
136
|
+
// 1. Already-split group tween — pick the one closest to the current
|
|
137
|
+
// playhead so a drag at t=6s edits the tween at 4s, not the one at 1.5s.
|
|
106
138
|
const groupAnims = animations.filter((a) => a.propertyGroup === group);
|
|
107
|
-
const groupAnim =
|
|
108
|
-
groupAnims.length > 1
|
|
109
|
-
? groupAnims.sort(
|
|
110
|
-
(a, b) => (b.keyframes?.keyframes.length ?? 0) - (a.keyframes?.keyframes.length ?? 0),
|
|
111
|
-
)[0]
|
|
112
|
-
: (groupAnims[0] ?? null);
|
|
139
|
+
const groupAnim = pickClosestToPlayhead(groupAnims);
|
|
113
140
|
if (groupAnim) return { anim: groupAnim, animations };
|
|
114
141
|
|
|
115
142
|
// 2. Legacy mixed tween — split it, then re-fetch
|
|
@@ -171,9 +198,19 @@ export async function tryGsapDragIntercept(
|
|
|
171
198
|
fetchFallbackAnimations?: () => Promise<GsapAnimation[]>,
|
|
172
199
|
): Promise<boolean> {
|
|
173
200
|
const selector = selectorFromSelection(selection);
|
|
174
|
-
|
|
201
|
+
console.log(
|
|
202
|
+
"[drag:4] tryGsapDragIntercept",
|
|
203
|
+
JSON.stringify({
|
|
204
|
+
sel: selection.id,
|
|
205
|
+
selector,
|
|
206
|
+
animCount: animations.length,
|
|
207
|
+
groups: animations.map((a) => a.propertyGroup).filter(Boolean),
|
|
208
|
+
}),
|
|
209
|
+
);
|
|
210
|
+
if (!selector) {
|
|
211
|
+
return false;
|
|
212
|
+
}
|
|
175
213
|
|
|
176
|
-
// Resolve the position-group tween, splitting legacy mixed tweens if needed.
|
|
177
214
|
const resolved = await resolveGroupTween(
|
|
178
215
|
"position",
|
|
179
216
|
animations,
|
|
@@ -181,26 +218,40 @@ export async function tryGsapDragIntercept(
|
|
|
181
218
|
commitMutation,
|
|
182
219
|
fetchFallbackAnimations,
|
|
183
220
|
);
|
|
221
|
+
console.log(
|
|
222
|
+
"[drag:4] resolveGroupTween('position') →",
|
|
223
|
+
resolved
|
|
224
|
+
? JSON.stringify({ id: resolved.anim.id, group: resolved.anim.propertyGroup })
|
|
225
|
+
: "null",
|
|
226
|
+
);
|
|
184
227
|
|
|
185
|
-
// Fallback: use the legacy scoring heuristic for compositions that don't
|
|
186
|
-
// have group-tagged tweens at all (e.g. hand-written scripts).
|
|
187
228
|
let posAnim = resolved?.anim ?? null;
|
|
188
229
|
if (!posAnim) {
|
|
189
230
|
posAnim = findGsapPositionAnimation(animations, selector);
|
|
190
231
|
if (!posAnim && fetchFallbackAnimations) {
|
|
191
232
|
const fresh = await fetchFallbackAnimations();
|
|
192
233
|
posAnim = findGsapPositionAnimation(fresh, selector);
|
|
234
|
+
console.log(
|
|
235
|
+
"[drag:4] findGsapPositionAnimation (fetched) →",
|
|
236
|
+
posAnim ? posAnim.id : "null",
|
|
237
|
+
"freshCount:",
|
|
238
|
+
fresh.length,
|
|
239
|
+
);
|
|
193
240
|
}
|
|
194
241
|
}
|
|
195
|
-
if (!posAnim)
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
// trade-off — CSS path must NEVER touch GSAP-targeted elements because
|
|
199
|
-
// changing the CSS offset corrupts all existing keyframes (baked mismatch).
|
|
242
|
+
if (!posAnim) {
|
|
243
|
+
return false;
|
|
244
|
+
}
|
|
200
245
|
|
|
201
246
|
const gsapPos = readGsapPositionFromIframe(iframe, selector);
|
|
202
|
-
if (!gsapPos)
|
|
247
|
+
if (!gsapPos) {
|
|
248
|
+
return false;
|
|
249
|
+
}
|
|
203
250
|
|
|
251
|
+
console.log(
|
|
252
|
+
"[drag:4] committing GSAP position drag",
|
|
253
|
+
JSON.stringify({ posAnimId: posAnim.id, gsapPos }),
|
|
254
|
+
);
|
|
204
255
|
await commitGsapPositionFromDrag(selection, posAnim, offset, gsapPos, iframe, selector, {
|
|
205
256
|
commitMutation,
|
|
206
257
|
fetchAnimations: fetchFallbackAnimations,
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { arcPathFromMotionPathValue } from "./gsapRuntimeKeyframes";
|
|
3
|
+
|
|
4
|
+
describe("arcPathFromMotionPathValue", () => {
|
|
5
|
+
it("builds arc config from object form { path, curviness }", () => {
|
|
6
|
+
const arc = arcPathFromMotionPathValue({
|
|
7
|
+
path: [
|
|
8
|
+
{ x: 0, y: 0 },
|
|
9
|
+
{ x: 100, y: -50 },
|
|
10
|
+
{ x: 200, y: 0 },
|
|
11
|
+
{ x: 300, y: 80 },
|
|
12
|
+
],
|
|
13
|
+
curviness: 2,
|
|
14
|
+
});
|
|
15
|
+
expect(arc?.enabled).toBe(true);
|
|
16
|
+
expect(arc?.segments).toHaveLength(3); // 4 waypoints → 3 segments
|
|
17
|
+
expect(arc?.segments.every((s) => s.curviness === 2)).toBe(true);
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
it("builds arc config from bare array form (default curviness 1)", () => {
|
|
21
|
+
const arc = arcPathFromMotionPathValue([
|
|
22
|
+
{ x: 0, y: 0 },
|
|
23
|
+
{ x: 50, y: 50 },
|
|
24
|
+
]);
|
|
25
|
+
expect(arc?.enabled).toBe(true);
|
|
26
|
+
expect(arc?.segments).toHaveLength(1);
|
|
27
|
+
expect(arc?.segments[0]!.curviness).toBe(1);
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it("carries autoRotate", () => {
|
|
31
|
+
const arc = arcPathFromMotionPathValue({
|
|
32
|
+
path: [
|
|
33
|
+
{ x: 0, y: 0 },
|
|
34
|
+
{ x: 10, y: 10 },
|
|
35
|
+
],
|
|
36
|
+
autoRotate: true,
|
|
37
|
+
});
|
|
38
|
+
expect(arc?.autoRotate).toBe(true);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it("returns undefined for fewer than 2 points, missing path, or string path", () => {
|
|
42
|
+
expect(arcPathFromMotionPathValue({ path: [{ x: 0, y: 0 }] })).toBeUndefined();
|
|
43
|
+
expect(arcPathFromMotionPathValue({ curviness: 2 })).toBeUndefined();
|
|
44
|
+
expect(arcPathFromMotionPathValue({ path: "M0 0 L10 10" })).toBeUndefined();
|
|
45
|
+
expect(arcPathFromMotionPathValue(null)).toBeUndefined();
|
|
46
|
+
});
|
|
47
|
+
});
|