@hyperframes/studio 0.7.18 → 0.7.20
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-DNLS_l47.js +459 -0
- package/dist/assets/{index-mZiDOLTB.js → index-CUt5jJXy.js} +1 -1
- package/dist/assets/{index-B7_M3NXS.js → index-D-KLiCTU.js} +1 -1
- package/dist/assets/index-WxXW8fW3.js +396 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.html +1 -1
- package/dist/index.js +1246 -807
- package/dist/index.js.map +1 -1
- package/package.json +8 -7
- package/src/components/StudioPreviewArea.tsx +92 -17
- package/src/components/TimelineToolbar.tsx +8 -0
- package/src/components/editor/MotionPathOverlay.tsx +4 -2
- package/src/components/editor/keyframeDrag.test.ts +195 -0
- package/src/components/editor/keyframeDrag.ts +105 -0
- package/src/components/editor/keyframeRetime.test.ts +140 -0
- package/src/components/editor/keyframeRetime.ts +121 -0
- package/src/contexts/DomEditContext.tsx +12 -0
- package/src/contexts/TimelineEditContext.tsx +2 -0
- package/src/hooks/gsapDragCommit.ts +28 -1
- package/src/hooks/gsapPositionDetection.ts +98 -0
- package/src/hooks/gsapRuntimeBridge.test.ts +33 -0
- package/src/hooks/gsapRuntimeBridge.ts +41 -97
- package/src/hooks/useDomEditSession.ts +10 -0
- package/src/hooks/useDomEditWiring.ts +17 -0
- package/src/hooks/useGsapKeyframeOps.test.tsx +101 -0
- package/src/hooks/useGsapKeyframeOps.ts +63 -2
- package/src/hooks/useGsapSelectionHandlers.ts +72 -1
- package/src/hooks/useGsapTweenCache.ts +16 -7
- package/src/hooks/useKeyframeKeyboard.ts +29 -32
- package/src/player/components/KeyframeDiamondContextMenu.tsx +21 -2
- package/src/player/components/PlayerControls.tsx +40 -13
- package/src/player/components/Timeline.tsx +8 -0
- package/src/player/components/TimelineCanvas.tsx +7 -0
- package/src/player/components/TimelineClipDiamonds.tsx +109 -17
- package/src/player/components/timelineCallbacks.ts +6 -0
- package/src/utils/gsapSoftReload.test.ts +29 -0
- package/src/utils/gsapSoftReload.ts +19 -0
- package/dist/assets/hyperframes-player-BGW5hpsb.js +0 -425
- package/dist/assets/index-D0468l1X.js +0 -375
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure move-vs-resize decision + absolute-time remap for keyframe drag-to-retime.
|
|
3
|
+
*
|
|
4
|
+
* Keyframes live inside the ANIMATION's window (tween position + duration), which
|
|
5
|
+
* is usually shorter than the clip. Dragging a keyframe is one of:
|
|
6
|
+
* - MOVE: the drop stays within `[tweenStart, tweenEnd]` → re-key the tween-%.
|
|
7
|
+
* - RESIZE: the drop crosses the tween boundary (the LAST keyframe past the end,
|
|
8
|
+
* or the FIRST before the start, but still inside the clip — the gesture layer
|
|
9
|
+
* already clamped to neighbours + clip). The tween's window grows so the
|
|
10
|
+
* dragged keyframe lands exactly where dropped; every OTHER keyframe keeps its
|
|
11
|
+
* ABSOLUTE time (its tween-% remaps onto the new, longer window). Value + ease
|
|
12
|
+
* are preserved per keyframe.
|
|
13
|
+
*
|
|
14
|
+
* Kept pure (no React/store/GSAP) so the trickiest math is unit-testable. The
|
|
15
|
+
* caller supplies the resolved tween window + the drop's absolute time.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
export interface RetimeKeyframe {
|
|
19
|
+
/** Tween-relative percentage (the writer/runtime key on this). */
|
|
20
|
+
percentage: number;
|
|
21
|
+
properties: Record<string, number | string>;
|
|
22
|
+
ease?: string;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** One existing keyframe's old→new tween-% under a resize remap. */
|
|
26
|
+
export interface KeyframePctRemap {
|
|
27
|
+
/** The existing keyframe's current tween-relative %. */
|
|
28
|
+
from: number;
|
|
29
|
+
/** Its new tween-relative % on the resized window. */
|
|
30
|
+
to: number;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export interface KeyframeRetimeResult {
|
|
34
|
+
kind: "noop" | "move" | "resize";
|
|
35
|
+
/** MOVE: tween-relative drop position. */
|
|
36
|
+
toTweenPct?: number;
|
|
37
|
+
/** RESIZE: new tween position (absolute seconds). */
|
|
38
|
+
position?: number;
|
|
39
|
+
/** RESIZE: new tween duration (seconds). */
|
|
40
|
+
duration?: number;
|
|
41
|
+
/**
|
|
42
|
+
* RESIZE: each existing keyframe's old→new tween-%. The commit re-keys each
|
|
43
|
+
* keyframe IN PLACE (round-tripping its value node), so `_auto`, per-keyframe
|
|
44
|
+
* `ease`, `easeEach`, and the outer tween `ease` all survive — unlike rebuilding
|
|
45
|
+
* a fresh keyframes array.
|
|
46
|
+
*/
|
|
47
|
+
pctRemap?: KeyframePctRemap[];
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Below this (tween-%) a move resolves onto the source keyframe → skip the write. */
|
|
51
|
+
const NOOP_EPSILON_PCT = 0.1;
|
|
52
|
+
/** Slack (seconds) for the within-tween boundary test. */
|
|
53
|
+
const EPSILON_TIME = 1e-4;
|
|
54
|
+
|
|
55
|
+
const round3 = (n: number) => Math.round(n * 1000) / 1000;
|
|
56
|
+
const round1 = (n: number) => Math.round(n * 10) / 10; // 0.1% precision
|
|
57
|
+
const clamp = (n: number, lo: number, hi: number) => Math.max(lo, Math.min(hi, n));
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Decide move vs resize for a dragged keyframe and, for resize, return the new
|
|
61
|
+
* tween window + remapped keyframes.
|
|
62
|
+
*
|
|
63
|
+
* - `keyframes`: the tween's keyframes (tween-relative %, with value + ease).
|
|
64
|
+
* - `draggedTweenPct`: identifies which keyframe is being dragged (closest match).
|
|
65
|
+
* - `tweenStart` / `tweenDuration`: the tween's resolved absolute window.
|
|
66
|
+
* - `dropAbsTime`: the drop's absolute time (handler converts clip-% → seconds).
|
|
67
|
+
*/
|
|
68
|
+
export function resolveKeyframeRetime(opts: {
|
|
69
|
+
keyframes: ReadonlyArray<RetimeKeyframe>;
|
|
70
|
+
draggedTweenPct: number;
|
|
71
|
+
tweenStart: number;
|
|
72
|
+
tweenDuration: number;
|
|
73
|
+
dropAbsTime: number;
|
|
74
|
+
}): KeyframeRetimeResult {
|
|
75
|
+
const { keyframes, draggedTweenPct, tweenStart, tweenDuration, dropAbsTime } = opts;
|
|
76
|
+
if (tweenDuration <= 0) return { kind: "noop" };
|
|
77
|
+
const tweenEnd = tweenStart + tweenDuration;
|
|
78
|
+
|
|
79
|
+
// Within the tween window → plain move (re-key the tween-%). This branch never
|
|
80
|
+
// touches the keyframes array, so it still works for synthesized flat tweens.
|
|
81
|
+
if (dropAbsTime >= tweenStart - EPSILON_TIME && dropAbsTime <= tweenEnd + EPSILON_TIME) {
|
|
82
|
+
const toTweenPct = clamp(((dropAbsTime - tweenStart) / tweenDuration) * 100, 0, 100);
|
|
83
|
+
if (Math.abs(toTweenPct - draggedTweenPct) < NOOP_EPSILON_PCT) return { kind: "noop" };
|
|
84
|
+
return { kind: "move", toTweenPct };
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// Boundary resize needs the real keyframes to remap; a flat tween has none here.
|
|
88
|
+
if (keyframes.length === 0) return { kind: "noop" };
|
|
89
|
+
|
|
90
|
+
const newStart = Math.min(dropAbsTime, tweenStart);
|
|
91
|
+
const newEnd = Math.max(dropAbsTime, tweenEnd);
|
|
92
|
+
const newDuration = Math.max(0.01, newEnd - newStart);
|
|
93
|
+
|
|
94
|
+
// The dragged keyframe is the one whose tween-% is closest to draggedTweenPct.
|
|
95
|
+
let draggedIdx = 0;
|
|
96
|
+
let best = Infinity;
|
|
97
|
+
keyframes.forEach((kf, i) => {
|
|
98
|
+
const d = Math.abs(kf.percentage - draggedTweenPct);
|
|
99
|
+
if (d < best) {
|
|
100
|
+
best = d;
|
|
101
|
+
draggedIdx = i;
|
|
102
|
+
}
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
// Map each existing keyframe to its new tween-% on the grown window, preserving
|
|
106
|
+
// its absolute time (the dragged one lands at the drop). Carry only the old→new
|
|
107
|
+
// percentages; the commit re-keys in place so value + ease + _auto + easeEach
|
|
108
|
+
// survive verbatim (no rebuilt keyframes array).
|
|
109
|
+
const pctRemap: KeyframePctRemap[] = keyframes.map((kf, i) => {
|
|
110
|
+
const absTime =
|
|
111
|
+
i === draggedIdx ? dropAbsTime : tweenStart + (kf.percentage / 100) * tweenDuration;
|
|
112
|
+
return { from: kf.percentage, to: round1(((absTime - newStart) / newDuration) * 100) };
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
return {
|
|
116
|
+
kind: "resize",
|
|
117
|
+
position: round3(newStart),
|
|
118
|
+
duration: round3(newDuration),
|
|
119
|
+
pctRemap,
|
|
120
|
+
};
|
|
121
|
+
}
|
|
@@ -54,6 +54,9 @@ export interface DomEditActionsValue extends Pick<
|
|
|
54
54
|
| "handleGsapAddKeyframe"
|
|
55
55
|
| "handleGsapAddKeyframeBatch"
|
|
56
56
|
| "handleGsapRemoveKeyframe"
|
|
57
|
+
| "handleGsapMoveKeyframeToPlayhead"
|
|
58
|
+
| "handleGsapMoveKeyframe"
|
|
59
|
+
| "handleGsapResizeKeyframedTween"
|
|
57
60
|
| "handleGsapConvertToKeyframes"
|
|
58
61
|
| "handleGsapRemoveAllKeyframes"
|
|
59
62
|
| "handleResetSelectedElementKeyframes"
|
|
@@ -169,6 +172,9 @@ export function DomEditProvider({
|
|
|
169
172
|
handleGsapAddKeyframe,
|
|
170
173
|
handleGsapAddKeyframeBatch,
|
|
171
174
|
handleGsapRemoveKeyframe,
|
|
175
|
+
handleGsapMoveKeyframeToPlayhead,
|
|
176
|
+
handleGsapMoveKeyframe,
|
|
177
|
+
handleGsapResizeKeyframedTween,
|
|
172
178
|
handleGsapConvertToKeyframes,
|
|
173
179
|
handleGsapRemoveAllKeyframes,
|
|
174
180
|
handleResetSelectedElementKeyframes,
|
|
@@ -247,6 +253,9 @@ export function DomEditProvider({
|
|
|
247
253
|
handleGsapAddKeyframe,
|
|
248
254
|
handleGsapAddKeyframeBatch,
|
|
249
255
|
handleGsapRemoveKeyframe,
|
|
256
|
+
handleGsapMoveKeyframeToPlayhead,
|
|
257
|
+
handleGsapMoveKeyframe,
|
|
258
|
+
handleGsapResizeKeyframedTween,
|
|
250
259
|
handleGsapConvertToKeyframes,
|
|
251
260
|
handleGsapRemoveAllKeyframes,
|
|
252
261
|
handleResetSelectedElementKeyframes,
|
|
@@ -311,6 +320,9 @@ export function DomEditProvider({
|
|
|
311
320
|
handleGsapAddKeyframe,
|
|
312
321
|
handleGsapAddKeyframeBatch,
|
|
313
322
|
handleGsapRemoveKeyframe,
|
|
323
|
+
handleGsapMoveKeyframeToPlayhead,
|
|
324
|
+
handleGsapMoveKeyframe,
|
|
325
|
+
handleGsapResizeKeyframedTween,
|
|
314
326
|
handleGsapConvertToKeyframes,
|
|
315
327
|
handleGsapRemoveAllKeyframes,
|
|
316
328
|
handleResetSelectedElementKeyframes,
|
|
@@ -156,6 +156,33 @@ function findPositionSetAnimation(
|
|
|
156
156
|
);
|
|
157
157
|
}
|
|
158
158
|
|
|
159
|
+
/**
|
|
160
|
+
* Find the EXISTING static position HOLD to update for a static-hold drag. Not
|
|
161
|
+
* just a `set`: a degenerate `tl.to("#el",{duration:0,x,y})` (what
|
|
162
|
+
* remove-all-keyframes leaves behind) is a held position too, and the next drag
|
|
163
|
+
* must UPDATE it in place rather than append a second `gsap.set` that fights it
|
|
164
|
+
* (the duplicate-position-write bug). Only zero-duration holds qualify — a
|
|
165
|
+
* live-duration `to`/`from` is NOT a static hold (and in the static path it's a
|
|
166
|
+
* stale/phantom parse: re-committing it would resurrect a just-deleted tween).
|
|
167
|
+
* Prefers a `set` (the canonical static channel) when both forms exist.
|
|
168
|
+
*/
|
|
169
|
+
function findExistingPositionWrite(
|
|
170
|
+
animations: GsapAnimation[],
|
|
171
|
+
selector: string,
|
|
172
|
+
): GsapAnimation | null {
|
|
173
|
+
const set = findPositionSetAnimation(animations, selector);
|
|
174
|
+
if (set) return set;
|
|
175
|
+
return (
|
|
176
|
+
animations.find(
|
|
177
|
+
(a) =>
|
|
178
|
+
a.targetSelector === selector &&
|
|
179
|
+
a.propertyGroup === "position" &&
|
|
180
|
+
!a.keyframes &&
|
|
181
|
+
(a.duration ?? 0) === 0,
|
|
182
|
+
) ?? null
|
|
183
|
+
);
|
|
184
|
+
}
|
|
185
|
+
|
|
159
186
|
/**
|
|
160
187
|
* Commit a STATIC element drag as a `tl.set("#el",{x,y})` — the single-source
|
|
161
188
|
* position channel for elements with no position animation. Idempotent: a
|
|
@@ -235,7 +262,7 @@ export async function commitStaticGsapPosition(
|
|
|
235
262
|
);
|
|
236
263
|
}
|
|
237
264
|
|
|
238
|
-
export {
|
|
265
|
+
export { findExistingPositionWrite };
|
|
239
266
|
|
|
240
267
|
function findRotationSetAnimation(
|
|
241
268
|
animations: GsapAnimation[],
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* GSAP position-write detection helpers for the drag bridge: read the live
|
|
3
|
+
* runtime position from the preview iframe, and find/score the position
|
|
4
|
+
* animation for a selector (and pick the tween closest to the playhead).
|
|
5
|
+
*
|
|
6
|
+
* Extracted from gsapRuntimeBridge.ts to keep that file under the size cap.
|
|
7
|
+
*/
|
|
8
|
+
import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
|
|
9
|
+
import { usePlayerStore } from "../player/store/playerStore";
|
|
10
|
+
import { getIframeGsap, queryIframeElement } from "./gsapShared";
|
|
11
|
+
import { resolveTweenStart, resolveTweenDuration } from "../utils/globalTimeCompiler";
|
|
12
|
+
|
|
13
|
+
// fallow-ignore-next-line complexity
|
|
14
|
+
export function readGsapPositionFromIframe(
|
|
15
|
+
iframe: HTMLIFrameElement | null,
|
|
16
|
+
elementSelector: string,
|
|
17
|
+
): { x: number; y: number } | null {
|
|
18
|
+
const gsap = getIframeGsap(iframe);
|
|
19
|
+
if (!gsap) return null;
|
|
20
|
+
|
|
21
|
+
const element = queryIframeElement(iframe, elementSelector);
|
|
22
|
+
if (!element) return null;
|
|
23
|
+
|
|
24
|
+
const x = Number(gsap.getProperty(element, "x")) || 0;
|
|
25
|
+
const y = Number(gsap.getProperty(element, "y")) || 0;
|
|
26
|
+
return { x, y };
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// fallow-ignore-next-line complexity
|
|
30
|
+
function animHasPosition(anim: GsapAnimation): boolean {
|
|
31
|
+
if (anim.keyframes?.keyframes.some((kf) => "x" in kf.properties || "y" in kf.properties))
|
|
32
|
+
return true;
|
|
33
|
+
if (anim.method === "fromTo") {
|
|
34
|
+
const from = anim.fromProperties;
|
|
35
|
+
return (
|
|
36
|
+
"x" in anim.properties || "y" in anim.properties || !!(from && ("x" in from || "y" in from))
|
|
37
|
+
);
|
|
38
|
+
}
|
|
39
|
+
return "x" in anim.properties || "y" in anim.properties;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// fallow-ignore-next-line complexity
|
|
43
|
+
export function findGsapPositionAnimation(
|
|
44
|
+
animations: GsapAnimation[],
|
|
45
|
+
selector?: string,
|
|
46
|
+
): GsapAnimation | null {
|
|
47
|
+
if (animations.length === 0) return null;
|
|
48
|
+
const currentTime = usePlayerStore.getState().currentTime;
|
|
49
|
+
|
|
50
|
+
const scored = animations
|
|
51
|
+
.filter((a) => animHasPosition(a) || a.keyframes || animations.length === 1)
|
|
52
|
+
.map((a) => {
|
|
53
|
+
let score = 0;
|
|
54
|
+
if (animHasPosition(a)) score += 10;
|
|
55
|
+
if (a.keyframes) score += 5;
|
|
56
|
+
if (selector && a.targetSelector === selector) score += 8;
|
|
57
|
+
else if (a.targetSelector.includes(",")) score -= 5;
|
|
58
|
+
const pos = a.resolvedStart ?? (typeof a.position === "number" ? a.position : 0);
|
|
59
|
+
const dur = a.duration ?? 0;
|
|
60
|
+
if (currentTime >= pos - 0.05 && currentTime <= pos + dur + 0.05) score += 50;
|
|
61
|
+
else
|
|
62
|
+
score -= Math.round(
|
|
63
|
+
Math.min(Math.abs(currentTime - pos), Math.abs(currentTime - pos - dur)) * 5,
|
|
64
|
+
);
|
|
65
|
+
return { anim: a, score };
|
|
66
|
+
});
|
|
67
|
+
scored.sort((a, b) => b.score - a.score);
|
|
68
|
+
return scored[0]?.anim ?? animations[0];
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* From a set of candidate tweens, pick the one whose time range is closest to
|
|
73
|
+
* the current playhead. A tween that *contains* the playhead wins outright;
|
|
74
|
+
* otherwise the nearest endpoint wins. This ensures a drag at t=6s edits (or
|
|
75
|
+
* extends) the 4s tween, not the 1.5s one. Tie-break: most keyframes (so a
|
|
76
|
+
* gesture-recorded tween beats a stub when both are equidistant).
|
|
77
|
+
*/
|
|
78
|
+
// fallow-ignore-next-line complexity
|
|
79
|
+
export function pickClosestToPlayhead(anims: GsapAnimation[]): GsapAnimation | null {
|
|
80
|
+
if (anims.length <= 1) return anims[0] ?? null;
|
|
81
|
+
const ct = usePlayerStore.getState().currentTime;
|
|
82
|
+
return anims.reduce((best, a) => {
|
|
83
|
+
const s = resolveTweenStart(a) ?? 0;
|
|
84
|
+
const e = s + resolveTweenDuration(a);
|
|
85
|
+
const dist = ct >= s && ct <= e ? 0 : Math.min(Math.abs(ct - s), Math.abs(ct - e));
|
|
86
|
+
const bestS = resolveTweenStart(best) ?? 0;
|
|
87
|
+
const bestE = bestS + resolveTweenDuration(best);
|
|
88
|
+
const bestDist =
|
|
89
|
+
ct >= bestS && ct <= bestE ? 0 : Math.min(Math.abs(ct - bestS), Math.abs(ct - bestE));
|
|
90
|
+
if (dist < bestDist) return a;
|
|
91
|
+
if (
|
|
92
|
+
dist === bestDist &&
|
|
93
|
+
(a.keyframes?.keyframes.length ?? 0) > (best.keyframes?.keyframes.length ?? 0)
|
|
94
|
+
)
|
|
95
|
+
return a;
|
|
96
|
+
return best;
|
|
97
|
+
});
|
|
98
|
+
}
|
|
@@ -127,6 +127,39 @@ describe("tryGsapDragIntercept — stale-parse guard (no resurrection after dele
|
|
|
127
127
|
});
|
|
128
128
|
});
|
|
129
129
|
|
|
130
|
+
it("updates a degenerate duration:0 hold-`to` in place instead of appending a gsap.set", async () => {
|
|
131
|
+
const commitMutation = vi.fn();
|
|
132
|
+
const iframe = fakeIframe("puck-b", []); // runtime empty → STATIC path
|
|
133
|
+
// What remove-all-keyframes leaves behind: a zero-duration immediateRender
|
|
134
|
+
// `tl.to` hold. A drag must UPDATE it, not append a 2nd (gsap.set) position
|
|
135
|
+
// write that silently overrides it (the duplicate-position-write bug).
|
|
136
|
+
const degenerateHold = {
|
|
137
|
+
id: "#puck-b-to-0-position",
|
|
138
|
+
targetSelector: "#puck-b",
|
|
139
|
+
method: "to",
|
|
140
|
+
propertyGroup: "position",
|
|
141
|
+
properties: { x: -766, y: 314 },
|
|
142
|
+
position: 1.333,
|
|
143
|
+
resolvedStart: 1.333,
|
|
144
|
+
duration: 0,
|
|
145
|
+
} as unknown as GsapAnimation;
|
|
146
|
+
|
|
147
|
+
const handled = await tryGsapDragIntercept(
|
|
148
|
+
selection,
|
|
149
|
+
{ x: -50, y: 30 },
|
|
150
|
+
[degenerateHold],
|
|
151
|
+
iframe,
|
|
152
|
+
commitMutation,
|
|
153
|
+
);
|
|
154
|
+
|
|
155
|
+
expect(handled).toBe(true);
|
|
156
|
+
// In-place update (2 coalesced update-property), NOT an `add`/`add-keyframe`.
|
|
157
|
+
const types = commitMutation.mock.calls.map(([, m]) => m.type);
|
|
158
|
+
expect(types.every((t: string) => t === "update-property")).toBe(true);
|
|
159
|
+
expect(types).not.toContain("add");
|
|
160
|
+
expect(types).not.toContain("add-keyframe");
|
|
161
|
+
});
|
|
162
|
+
|
|
130
163
|
it("does not trip the stale-parse guard when the runtime still has the tween", async () => {
|
|
131
164
|
const logSpy = vi.spyOn(console, "log").mockImplementation(() => {});
|
|
132
165
|
const liveTween = {
|
|
@@ -21,110 +21,24 @@ import {
|
|
|
21
21
|
commitKeyframedSizeFromResize,
|
|
22
22
|
commitWholePathOffset,
|
|
23
23
|
computeCurrentPercentage,
|
|
24
|
-
|
|
24
|
+
findExistingPositionWrite,
|
|
25
25
|
findRotationSetAnimation,
|
|
26
26
|
findSizeSetAnimation,
|
|
27
27
|
materializeIfDynamic,
|
|
28
28
|
} from "./gsapDragCommit";
|
|
29
29
|
import { resolveTweenStart, resolveTweenDuration } from "../utils/globalTimeCompiler";
|
|
30
30
|
import type { GsapDragCommitCallbacks } from "./gsapDragCommit";
|
|
31
|
-
import {
|
|
31
|
+
import { selectorFromSelection } from "./gsapShared";
|
|
32
|
+
import {
|
|
33
|
+
findGsapPositionAnimation,
|
|
34
|
+
pickClosestToPlayhead,
|
|
35
|
+
readGsapPositionFromIframe,
|
|
36
|
+
} from "./gsapPositionDetection";
|
|
32
37
|
import { hasNonHoldTweenForElement } from "./gsapRuntimeKeyframes";
|
|
33
38
|
import { roundTo3 } from "../utils/rounding";
|
|
34
39
|
|
|
35
|
-
// ── Runtime reads ──────────────────────────────────────────────────────────
|
|
36
|
-
|
|
37
|
-
// fallow-ignore-next-line complexity
|
|
38
|
-
function readGsapPositionFromIframe(
|
|
39
|
-
iframe: HTMLIFrameElement | null,
|
|
40
|
-
elementSelector: string,
|
|
41
|
-
): { x: number; y: number } | null {
|
|
42
|
-
const gsap = getIframeGsap(iframe);
|
|
43
|
-
if (!gsap) return null;
|
|
44
|
-
|
|
45
|
-
const element = queryIframeElement(iframe, elementSelector);
|
|
46
|
-
if (!element) return null;
|
|
47
|
-
|
|
48
|
-
const x = Number(gsap.getProperty(element, "x")) || 0;
|
|
49
|
-
const y = Number(gsap.getProperty(element, "y")) || 0;
|
|
50
|
-
return { x, y };
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
// ── Animation matching ─────────────────────────────────────────────────────
|
|
54
|
-
|
|
55
|
-
// fallow-ignore-next-line complexity
|
|
56
|
-
function animHasPosition(anim: GsapAnimation): boolean {
|
|
57
|
-
if (anim.keyframes?.keyframes.some((kf) => "x" in kf.properties || "y" in kf.properties))
|
|
58
|
-
return true;
|
|
59
|
-
if (anim.method === "fromTo") {
|
|
60
|
-
const from = anim.fromProperties;
|
|
61
|
-
return (
|
|
62
|
-
"x" in anim.properties || "y" in anim.properties || !!(from && ("x" in from || "y" in from))
|
|
63
|
-
);
|
|
64
|
-
}
|
|
65
|
-
return "x" in anim.properties || "y" in anim.properties;
|
|
66
|
-
}
|
|
67
|
-
|
|
68
|
-
function findGsapPositionAnimation(
|
|
69
|
-
animations: GsapAnimation[],
|
|
70
|
-
selector?: string,
|
|
71
|
-
): GsapAnimation | null {
|
|
72
|
-
if (animations.length === 0) return null;
|
|
73
|
-
const currentTime = usePlayerStore.getState().currentTime;
|
|
74
|
-
|
|
75
|
-
const scored = animations
|
|
76
|
-
.filter((a) => animHasPosition(a) || a.keyframes || animations.length === 1)
|
|
77
|
-
.map((a) => {
|
|
78
|
-
let score = 0;
|
|
79
|
-
if (animHasPosition(a)) score += 10;
|
|
80
|
-
if (a.keyframes) score += 5;
|
|
81
|
-
if (selector && a.targetSelector === selector) score += 8;
|
|
82
|
-
else if (a.targetSelector.includes(",")) score -= 5;
|
|
83
|
-
const pos = a.resolvedStart ?? (typeof a.position === "number" ? a.position : 0);
|
|
84
|
-
const dur = a.duration ?? 0;
|
|
85
|
-
if (currentTime >= pos - 0.05 && currentTime <= pos + dur + 0.05) score += 50;
|
|
86
|
-
else
|
|
87
|
-
score -= Math.round(
|
|
88
|
-
Math.min(Math.abs(currentTime - pos), Math.abs(currentTime - pos - dur)) * 5,
|
|
89
|
-
);
|
|
90
|
-
return { anim: a, score };
|
|
91
|
-
});
|
|
92
|
-
scored.sort((a, b) => b.score - a.score);
|
|
93
|
-
return scored[0]?.anim ?? animations[0];
|
|
94
|
-
}
|
|
95
|
-
|
|
96
|
-
// ── Selector resolution ────────────────────────────────────────────────────
|
|
97
|
-
|
|
98
40
|
// ── Property-group tween resolution ───────────────────────────────────────
|
|
99
41
|
|
|
100
|
-
/**
|
|
101
|
-
* From a set of candidate tweens, pick the one whose time range is closest to
|
|
102
|
-
* the current playhead. A tween that *contains* the playhead wins outright;
|
|
103
|
-
* otherwise the nearest endpoint wins. This ensures a drag at t=6s edits (or
|
|
104
|
-
* extends) the 4s tween, not the 1.5s one. Tie-break: most keyframes (so a
|
|
105
|
-
* gesture-recorded tween beats a stub when both are equidistant).
|
|
106
|
-
*/
|
|
107
|
-
function pickClosestToPlayhead(anims: GsapAnimation[]): GsapAnimation | null {
|
|
108
|
-
if (anims.length <= 1) return anims[0] ?? null;
|
|
109
|
-
const ct = usePlayerStore.getState().currentTime;
|
|
110
|
-
return anims.reduce((best, a) => {
|
|
111
|
-
const s = resolveTweenStart(a) ?? 0;
|
|
112
|
-
const e = s + resolveTweenDuration(a);
|
|
113
|
-
const dist = ct >= s && ct <= e ? 0 : Math.min(Math.abs(ct - s), Math.abs(ct - e));
|
|
114
|
-
const bestS = resolveTweenStart(best) ?? 0;
|
|
115
|
-
const bestE = bestS + resolveTweenDuration(best);
|
|
116
|
-
const bestDist =
|
|
117
|
-
ct >= bestS && ct <= bestE ? 0 : Math.min(Math.abs(ct - bestS), Math.abs(ct - bestE));
|
|
118
|
-
if (dist < bestDist) return a;
|
|
119
|
-
if (
|
|
120
|
-
dist === bestDist &&
|
|
121
|
-
(a.keyframes?.keyframes.length ?? 0) > (best.keyframes?.keyframes.length ?? 0)
|
|
122
|
-
)
|
|
123
|
-
return a;
|
|
124
|
-
return best;
|
|
125
|
-
});
|
|
126
|
-
}
|
|
127
|
-
|
|
128
42
|
/**
|
|
129
43
|
* Find the tween for a given property group, splitting a legacy mixed tween
|
|
130
44
|
* if necessary. Returns the resolved animation or null if none exists.
|
|
@@ -212,18 +126,48 @@ export async function tryGsapDragIntercept(
|
|
|
212
126
|
return false;
|
|
213
127
|
}
|
|
214
128
|
|
|
129
|
+
// Self-heal: enforce a single position write BEFORE committing. A corrupted
|
|
130
|
+
// file can carry 2+ conflicting position writes for one selector (e.g. a
|
|
131
|
+
// degenerate `tl.to(...,{duration:0,x,y})` AND a `gsap.set(...,{x,y})`) — the
|
|
132
|
+
// later one silently overrides the earlier, so the element "can't move". Keep
|
|
133
|
+
// the live keyframed/real tween if present (else any), strip the rest, so the
|
|
134
|
+
// commit below updates ONE write instead of fighting duplicates.
|
|
135
|
+
let workingAnimations = animations;
|
|
136
|
+
const isPosWrite = (a: GsapAnimation) =>
|
|
137
|
+
a.targetSelector === selector && a.propertyGroup === "position";
|
|
138
|
+
if (animations.filter(isPosWrite).length > 1 && fetchFallbackAnimations) {
|
|
139
|
+
const fresh = await fetchFallbackAnimations();
|
|
140
|
+
const dupes = fresh.filter(isPosWrite);
|
|
141
|
+
if (dupes.length > 1) {
|
|
142
|
+
const keeper =
|
|
143
|
+
dupes.find((a) => a.keyframes) ?? dupes.find((a) => (a.duration ?? 0) > 0) ?? dupes[0]!;
|
|
144
|
+
await commitMutation(
|
|
145
|
+
selection,
|
|
146
|
+
{
|
|
147
|
+
type: "consolidate-position-writes",
|
|
148
|
+
targetSelector: selector,
|
|
149
|
+
keepAnimationId: keeper.id,
|
|
150
|
+
},
|
|
151
|
+
{ label: "Consolidate position writes", skipReload: true },
|
|
152
|
+
);
|
|
153
|
+
workingAnimations = await fetchFallbackAnimations();
|
|
154
|
+
} else {
|
|
155
|
+
workingAnimations = fresh;
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
215
159
|
const resolved = await resolveGroupTween(
|
|
216
160
|
"position",
|
|
217
|
-
|
|
161
|
+
workingAnimations,
|
|
218
162
|
selection,
|
|
219
163
|
commitMutation,
|
|
220
164
|
fetchFallbackAnimations,
|
|
221
165
|
);
|
|
222
166
|
|
|
223
167
|
let posAnim = resolved?.anim ?? null;
|
|
224
|
-
let resolvedAnimations = resolved?.animations ??
|
|
168
|
+
let resolvedAnimations = resolved?.animations ?? workingAnimations;
|
|
225
169
|
if (!posAnim) {
|
|
226
|
-
posAnim = findGsapPositionAnimation(
|
|
170
|
+
posAnim = findGsapPositionAnimation(workingAnimations, selector);
|
|
227
171
|
if (!posAnim && fetchFallbackAnimations) {
|
|
228
172
|
const fresh = await fetchFallbackAnimations();
|
|
229
173
|
resolvedAnimations = fresh;
|
|
@@ -253,7 +197,7 @@ export async function tryGsapDragIntercept(
|
|
|
253
197
|
const existingSet =
|
|
254
198
|
posAnim && posAnim.method === "set" && posAnim.targetSelector === selector
|
|
255
199
|
? posAnim
|
|
256
|
-
:
|
|
200
|
+
: findExistingPositionWrite(resolvedAnimations, selector);
|
|
257
201
|
await commitStaticGsapPosition(selection, offset, gsapPos, selector, existingSet, {
|
|
258
202
|
commitMutation,
|
|
259
203
|
fetchAnimations: fetchFallbackAnimations,
|
|
@@ -201,6 +201,8 @@ export function useDomEditSession({
|
|
|
201
201
|
addKeyframe,
|
|
202
202
|
addKeyframeBatch,
|
|
203
203
|
removeKeyframe,
|
|
204
|
+
moveKeyframe,
|
|
205
|
+
resizeKeyframedTween,
|
|
204
206
|
convertToKeyframes,
|
|
205
207
|
removeAllKeyframes,
|
|
206
208
|
setArcPath,
|
|
@@ -356,6 +358,9 @@ export function useDomEditSession({
|
|
|
356
358
|
handleGsapAddKeyframe,
|
|
357
359
|
handleGsapAddKeyframeBatch,
|
|
358
360
|
handleGsapRemoveKeyframe,
|
|
361
|
+
handleGsapMoveKeyframeToPlayhead,
|
|
362
|
+
handleGsapMoveKeyframe,
|
|
363
|
+
handleGsapResizeKeyframedTween,
|
|
359
364
|
handleGsapConvertToKeyframes,
|
|
360
365
|
handleGsapRemoveAllKeyframes,
|
|
361
366
|
handleResetSelectedElementKeyframes,
|
|
@@ -393,6 +398,8 @@ export function useDomEditSession({
|
|
|
393
398
|
addKeyframe,
|
|
394
399
|
addKeyframeBatch,
|
|
395
400
|
removeKeyframe,
|
|
401
|
+
moveKeyframe,
|
|
402
|
+
resizeKeyframedTween,
|
|
396
403
|
convertToKeyframes,
|
|
397
404
|
removeAllKeyframes,
|
|
398
405
|
handleDomManualEditsReset,
|
|
@@ -553,6 +560,9 @@ export function useDomEditSession({
|
|
|
553
560
|
handleGsapAddKeyframe,
|
|
554
561
|
handleGsapAddKeyframeBatch,
|
|
555
562
|
handleGsapRemoveKeyframe,
|
|
563
|
+
handleGsapMoveKeyframeToPlayhead,
|
|
564
|
+
handleGsapMoveKeyframe,
|
|
565
|
+
handleGsapResizeKeyframedTween,
|
|
556
566
|
handleGsapConvertToKeyframes,
|
|
557
567
|
handleGsapRemoveAllKeyframes,
|
|
558
568
|
handleResetSelectedElementKeyframes,
|
|
@@ -87,6 +87,19 @@ export interface UseDomEditWiringParams {
|
|
|
87
87
|
properties: Record<string, number | string>,
|
|
88
88
|
) => Promise<void>;
|
|
89
89
|
removeKeyframe: (sel: DomEditSelection, animId: string, percentage: number) => void;
|
|
90
|
+
moveKeyframe: (
|
|
91
|
+
sel: DomEditSelection,
|
|
92
|
+
animId: string,
|
|
93
|
+
fromPercentage: number,
|
|
94
|
+
toPercentage: number,
|
|
95
|
+
) => void;
|
|
96
|
+
resizeKeyframedTween: (
|
|
97
|
+
sel: DomEditSelection,
|
|
98
|
+
animId: string,
|
|
99
|
+
position: number,
|
|
100
|
+
duration: number,
|
|
101
|
+
pctRemap: Array<{ from: number; to: number }>,
|
|
102
|
+
) => void;
|
|
90
103
|
convertToKeyframes: (
|
|
91
104
|
sel: DomEditSelection,
|
|
92
105
|
animId: string,
|
|
@@ -131,6 +144,8 @@ export function useDomEditWiring({
|
|
|
131
144
|
addKeyframe,
|
|
132
145
|
addKeyframeBatch,
|
|
133
146
|
removeKeyframe,
|
|
147
|
+
moveKeyframe,
|
|
148
|
+
resizeKeyframedTween,
|
|
134
149
|
convertToKeyframes,
|
|
135
150
|
removeAllKeyframes,
|
|
136
151
|
handleDomManualEditsReset,
|
|
@@ -224,6 +239,8 @@ export function useDomEditWiring({
|
|
|
224
239
|
addKeyframe,
|
|
225
240
|
addKeyframeBatch,
|
|
226
241
|
removeKeyframe,
|
|
242
|
+
moveKeyframe,
|
|
243
|
+
resizeKeyframedTween,
|
|
227
244
|
convertToKeyframes,
|
|
228
245
|
removeAllKeyframes,
|
|
229
246
|
handleDomManualEditsReset,
|