@hyperframes/studio 0.6.122-alpha.0 → 0.7.1
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-i-MLqhmm.js → index-CnYOrmoA.js} +1 -1
- package/dist/assets/index-DzWIinxk.css +1 -0
- package/dist/assets/index-Z4bA3Fi0.js +374 -0
- package/dist/assets/{index-DSLrl2tB.js → index-wWkKTkIq.js} +24 -24
- package/dist/index.d.ts +8 -0
- package/dist/index.html +3 -3
- package/dist/index.js +3768 -2245
- package/dist/index.js.map +1 -1
- package/package.json +5 -5
- package/src/App.tsx +1 -0
- package/src/components/StudioHeader.tsx +2 -3
- package/src/components/StudioPreviewArea.tsx +12 -0
- package/src/components/TimelineToolbar.tsx +29 -16
- package/src/components/editor/DomEditOverlay.test.ts +0 -13
- package/src/components/editor/DomEditOverlay.tsx +3 -10
- package/src/components/editor/KeyframeNavigation.test.ts +36 -0
- package/src/components/editor/KeyframeNavigation.tsx +34 -4
- package/src/components/editor/MotionPathNode.tsx +98 -0
- package/src/components/editor/MotionPathOverlay.tsx +481 -0
- package/src/components/editor/SnapToolbar.tsx +28 -3
- package/src/components/editor/SourceEditor.tsx +21 -9
- package/src/components/editor/domEditOverlayGeometry.ts +12 -0
- package/src/components/editor/domEditOverlayGestures.ts +5 -1
- package/src/components/editor/domEditOverlayStartGesture.ts +5 -1
- package/src/components/editor/domEditingDom.ts +24 -9
- package/src/components/editor/manualEditingAvailability.test.ts +0 -12
- package/src/components/editor/manualEditingAvailability.ts +1 -11
- package/src/components/editor/manualEdits.ts +1 -0
- package/src/components/editor/manualEditsDom.ts +68 -41
- package/src/components/editor/manualEditsDomGsap.test.ts +82 -0
- package/src/components/editor/manualOffsetDrag.test.ts +12 -0
- package/src/components/editor/manualOffsetDrag.ts +79 -4
- package/src/components/editor/motionPathCommit.test.ts +130 -0
- package/src/components/editor/motionPathCommit.ts +83 -0
- package/src/components/editor/motionPathGeometry.test.ts +127 -0
- package/src/components/editor/motionPathGeometry.ts +116 -0
- package/src/components/editor/motionPathSelection.ts +33 -0
- package/src/components/editor/useDomEditOverlayGestures.ts +36 -18
- package/src/components/editor/useDomEditOverlayRects.ts +6 -0
- package/src/components/editor/useMotionPathData.ts +135 -0
- package/src/components/nle/NLELayout.tsx +17 -6
- package/src/components/panels/SlideshowPanel.tsx +2 -1
- package/src/components/panels/SlideshowSubPanels.tsx +2 -1
- package/src/components/renders/RenderQueue.tsx +1 -1
- package/src/components/renders/useRenderQueue.ts +3 -2
- package/src/hooks/draggedGsapPosition.ts +47 -0
- package/src/hooks/gsapDragCommit.test.ts +435 -0
- package/src/hooks/gsapDragCommit.ts +278 -290
- package/src/hooks/gsapDragPositionCommit.ts +410 -0
- package/src/hooks/gsapRuntimeBridge.test.ts +147 -0
- package/src/hooks/gsapRuntimeBridge.ts +86 -69
- package/src/hooks/gsapRuntimeKeyframes.test.ts +129 -1
- package/src/hooks/gsapRuntimeKeyframes.ts +183 -11
- package/src/hooks/gsapRuntimePatch.test.ts +462 -0
- package/src/hooks/gsapRuntimePatch.ts +159 -0
- package/src/hooks/gsapScriptCommitTypes.ts +11 -1
- package/src/hooks/gsapShared.test.ts +58 -0
- package/src/hooks/gsapShared.ts +28 -10
- package/src/hooks/gsapTargetCache.ts +0 -3
- package/src/hooks/useDomEditPreviewSync.ts +3 -0
- package/src/hooks/useDomEditSession.ts +0 -4
- package/src/hooks/useDomEditWiring.ts +1 -0
- package/src/hooks/useDomSelection.ts +12 -5
- package/src/hooks/useEnableKeyframes.test.ts +130 -0
- package/src/hooks/useEnableKeyframes.ts +298 -45
- package/src/hooks/useGestureCommit.ts +121 -53
- package/src/hooks/useGestureRecording.ts +61 -42
- package/src/hooks/useGsapAnimationFetchFallback.test.ts +36 -0
- package/src/hooks/useGsapAnimationFetchFallback.ts +65 -9
- package/src/hooks/useGsapAnimationOps.ts +1 -1
- package/src/hooks/useGsapAwareEditing.ts +38 -40
- package/src/hooks/useGsapScriptCommits.test.tsx +366 -0
- package/src/hooks/useGsapScriptCommits.ts +102 -6
- package/src/hooks/useGsapTweenCache.ts +12 -11
- package/src/hooks/useRazorSplit.ts +21 -2
- package/src/hooks/useSafeGsapCommitMutation.test.tsx +86 -0
- package/src/hooks/useSafeGsapCommitMutation.ts +8 -4
- package/src/hooks/useServerConnection.ts +4 -1
- package/src/hooks/useStudioContextValue.ts +3 -2
- package/src/hooks/useStudioUrlState.ts +97 -37
- package/src/player/components/ShortcutsPanel.tsx +8 -0
- package/src/player/components/TimelineClipDiamonds.tsx +10 -12
- package/src/player/hooks/useExpandedTimelineElements.test.ts +34 -0
- package/src/player/hooks/useExpandedTimelineElements.ts +20 -2
- package/src/player/hooks/useTimelineSyncCallbacks.ts +9 -1
- package/src/player/lib/mediaProbe.ts +7 -1
- package/src/player/store/playerStore.ts +38 -1
- package/src/telemetry/config.ts +3 -2
- package/src/utils/editDebugLog.ts +16 -0
- package/src/utils/generateId.ts +7 -0
- package/src/utils/gsapSoftReload.test.ts +217 -13
- package/src/utils/gsapSoftReload.ts +228 -50
- package/src/utils/studioHelpers.test.ts +31 -1
- package/src/utils/studioHelpers.ts +24 -0
- package/src/utils/studioPreviewHelpers.test.ts +26 -1
- package/src/utils/studioPreviewHelpers.ts +30 -6
- package/src/utils/studioTelemetry.ts +3 -1
- package/dist/assets/index-Baz2T-9U.js +0 -269
- package/dist/assets/index-BvEhf7SS.css +0 -1
- package/src/player/components/TimelinePropertyRows.tsx +0 -126
|
@@ -13,14 +13,22 @@ import type { DomEditSelection } from "../components/editor/domEditingTypes";
|
|
|
13
13
|
import { usePlayerStore } from "../player/store/playerStore";
|
|
14
14
|
|
|
15
15
|
import { readAllAnimatedProperties, readGsapProperty } from "./gsapRuntimeReaders";
|
|
16
|
+
import { commitGsapPositionFromDrag } from "./gsapDragPositionCommit";
|
|
16
17
|
import {
|
|
17
|
-
|
|
18
|
+
commitStaticGsapPosition,
|
|
19
|
+
commitStaticGsapRotation,
|
|
20
|
+
commitStaticGsapSize,
|
|
21
|
+
commitWholePathOffset,
|
|
18
22
|
computeCurrentPercentage,
|
|
23
|
+
findPositionSetAnimation,
|
|
24
|
+
findRotationSetAnimation,
|
|
25
|
+
findSizeSetAnimation,
|
|
19
26
|
materializeIfDynamic,
|
|
20
27
|
} from "./gsapDragCommit";
|
|
21
28
|
import { resolveTweenStart, resolveTweenDuration } from "../utils/globalTimeCompiler";
|
|
22
29
|
import type { GsapDragCommitCallbacks } from "./gsapDragCommit";
|
|
23
30
|
import { getIframeGsap, queryIframeElement, selectorFromSelection } from "./gsapShared";
|
|
31
|
+
import { hasNonHoldTweenForElement } from "./gsapRuntimeKeyframes";
|
|
24
32
|
import { roundTo3 } from "../utils/rounding";
|
|
25
33
|
|
|
26
34
|
// ── Runtime reads ──────────────────────────────────────────────────────────
|
|
@@ -196,17 +204,9 @@ export async function tryGsapDragIntercept(
|
|
|
196
204
|
iframe: HTMLIFrameElement | null,
|
|
197
205
|
commitMutation: GsapDragCommitCallbacks["commitMutation"],
|
|
198
206
|
fetchFallbackAnimations?: () => Promise<GsapAnimation[]>,
|
|
207
|
+
options?: { altKey?: boolean },
|
|
199
208
|
): Promise<boolean> {
|
|
200
209
|
const selector = selectorFromSelection(selection);
|
|
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
210
|
if (!selector) {
|
|
211
211
|
return false;
|
|
212
212
|
}
|
|
@@ -218,44 +218,62 @@ export async function tryGsapDragIntercept(
|
|
|
218
218
|
commitMutation,
|
|
219
219
|
fetchFallbackAnimations,
|
|
220
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
|
-
);
|
|
227
221
|
|
|
228
222
|
let posAnim = resolved?.anim ?? null;
|
|
223
|
+
let resolvedAnimations = resolved?.animations ?? animations;
|
|
229
224
|
if (!posAnim) {
|
|
230
225
|
posAnim = findGsapPositionAnimation(animations, selector);
|
|
231
226
|
if (!posAnim && fetchFallbackAnimations) {
|
|
232
227
|
const fresh = await fetchFallbackAnimations();
|
|
228
|
+
resolvedAnimations = fresh;
|
|
233
229
|
posAnim = findGsapPositionAnimation(fresh, selector);
|
|
234
|
-
console.log(
|
|
235
|
-
"[drag:4] findGsapPositionAnimation (fetched) →",
|
|
236
|
-
posAnim ? posAnim.id : "null",
|
|
237
|
-
"freshCount:",
|
|
238
|
-
fresh.length,
|
|
239
|
-
);
|
|
240
230
|
}
|
|
241
231
|
}
|
|
242
|
-
|
|
243
|
-
|
|
232
|
+
|
|
233
|
+
const gsapPos = readGsapPositionFromIframe(iframe, selector) ?? { x: 0, y: 0 };
|
|
234
|
+
|
|
235
|
+
// STATIC case (single source of truth = GSAP timeline): the element has no LIVE
|
|
236
|
+
// keyframed/tweened position motion. Use the strict non-hold check — a leftover
|
|
237
|
+
// position-hold `set` (after a delete-all, or a stale parse that lags it) must
|
|
238
|
+
// NOT count as live motion. Either way the position belongs in a
|
|
239
|
+
// `tl.set("#el",{x,y})`, not a keyframe conversion: re-nudge an existing set in
|
|
240
|
+
// place (idempotent), else add a new one. This also covers the stale-cache
|
|
241
|
+
// phantom — committing a set is correct because the element genuinely has no live motion.
|
|
242
|
+
if (!hasNonHoldTweenForElement(iframe, selector)) {
|
|
243
|
+
const existingSet =
|
|
244
|
+
posAnim && posAnim.method === "set" && posAnim.targetSelector === selector
|
|
245
|
+
? posAnim
|
|
246
|
+
: findPositionSetAnimation(resolvedAnimations, selector);
|
|
247
|
+
await commitStaticGsapPosition(selection, offset, gsapPos, selector, existingSet, {
|
|
248
|
+
commitMutation,
|
|
249
|
+
fetchAnimations: fetchFallbackAnimations,
|
|
250
|
+
});
|
|
251
|
+
return true;
|
|
244
252
|
}
|
|
245
253
|
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
254
|
+
if (!posAnim) return false;
|
|
255
|
+
|
|
256
|
+
// Verify the anim ID is still valid in the current file. The React-state
|
|
257
|
+
// `animations` list can lag behind the file after a prior mutation changed
|
|
258
|
+
// the tween's position/method (which changes the ID). Re-fetch to get the
|
|
259
|
+
// current ID and avoid a stale-ID remove that creates duplicate tweens.
|
|
260
|
+
if (fetchFallbackAnimations) {
|
|
261
|
+
const fresh = await fetchFallbackAnimations();
|
|
262
|
+
const freshMatch = fresh.find(
|
|
263
|
+
(a) =>
|
|
264
|
+
a.targetSelector === posAnim!.targetSelector && a.propertyGroup === posAnim!.propertyGroup,
|
|
265
|
+
);
|
|
266
|
+
if (freshMatch && freshMatch.id !== posAnim.id) {
|
|
267
|
+
posAnim = freshMatch;
|
|
268
|
+
}
|
|
249
269
|
}
|
|
250
270
|
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
fetchAnimations: fetchFallbackAnimations,
|
|
258
|
-
});
|
|
271
|
+
const cbs = { commitMutation, fetchAnimations: fetchFallbackAnimations };
|
|
272
|
+
if (options?.altKey) {
|
|
273
|
+
await commitWholePathOffset(selection, posAnim, offset, gsapPos, iframe, selector, cbs);
|
|
274
|
+
} else {
|
|
275
|
+
await commitGsapPositionFromDrag(selection, posAnim, offset, gsapPos, iframe, selector, cbs);
|
|
276
|
+
}
|
|
259
277
|
return true;
|
|
260
278
|
}
|
|
261
279
|
|
|
@@ -303,33 +321,14 @@ export async function tryGsapResizeIntercept(
|
|
|
303
321
|
);
|
|
304
322
|
|
|
305
323
|
let anim = resolved?.anim ?? null;
|
|
306
|
-
if (!anim) {
|
|
307
|
-
// No size-group tween exists — create one. Use the element's timing
|
|
308
|
-
// from any existing animation, or fall back to element data attributes.
|
|
309
|
-
const refAnim = animations[0];
|
|
310
|
-
const elStart =
|
|
311
|
-
refAnim?.resolvedStart ?? (Number.parseFloat(selection.dataAttributes?.start ?? "0") || 0);
|
|
312
|
-
const elDuration = Number.parseFloat(selection.dataAttributes?.duration ?? "5") || 5;
|
|
313
|
-
const ct = usePlayerStore.getState().currentTime;
|
|
314
|
-
const pct = elDuration > 0 ? Math.round(((ct - elStart) / elDuration) * 1000) / 10 : 0;
|
|
324
|
+
if (!anim || anim.method === "set") {
|
|
315
325
|
const sel = selectorFromSelection(selection);
|
|
316
326
|
if (!sel) return false;
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
position: roundTo3(elStart),
|
|
323
|
-
duration: roundTo3(elDuration),
|
|
324
|
-
keyframes: [
|
|
325
|
-
{
|
|
326
|
-
percentage: Math.max(0, Math.min(100, pct)),
|
|
327
|
-
properties: { width: Math.round(size.width), height: Math.round(size.height) },
|
|
328
|
-
},
|
|
329
|
-
],
|
|
330
|
-
},
|
|
331
|
-
{ label: "Resize (new size keyframe)", softReload: true },
|
|
332
|
-
);
|
|
327
|
+
const sizeSet = anim?.method === "set" ? anim : findSizeSetAnimation(animations, sel);
|
|
328
|
+
await commitStaticGsapSize(selection, size, sel, sizeSet, {
|
|
329
|
+
commitMutation,
|
|
330
|
+
fetchAnimations: fetchFallbackAnimations,
|
|
331
|
+
});
|
|
333
332
|
return true;
|
|
334
333
|
}
|
|
335
334
|
|
|
@@ -374,6 +373,13 @@ export async function tryGsapResizeIntercept(
|
|
|
374
373
|
{ type: "convert-to-keyframes", animationId: anim.id, resolvedFromValues },
|
|
375
374
|
{ label: "Convert to keyframes for resize", skipReload: true, coalesceKey },
|
|
376
375
|
);
|
|
376
|
+
if (fetchFallbackAnimations) {
|
|
377
|
+
const fresh = await fetchFallbackAnimations();
|
|
378
|
+
const refreshed = fresh.find(
|
|
379
|
+
(a) => a.targetSelector === anim!.targetSelector && a.keyframes,
|
|
380
|
+
);
|
|
381
|
+
if (refreshed) anim = refreshed;
|
|
382
|
+
}
|
|
377
383
|
}
|
|
378
384
|
}
|
|
379
385
|
|
|
@@ -463,6 +469,9 @@ export async function tryGsapRotationIntercept(
|
|
|
463
469
|
commitMutation: GsapDragCommitCallbacks["commitMutation"],
|
|
464
470
|
fetchFallbackAnimations?: () => Promise<GsapAnimation[]>,
|
|
465
471
|
): Promise<boolean> {
|
|
472
|
+
const selector = selectorFromSelection(selection);
|
|
473
|
+
if (!selector) return false;
|
|
474
|
+
|
|
466
475
|
// Resolve the rotation-group tween, splitting legacy mixed tweens if needed.
|
|
467
476
|
const resolved = await resolveGroupTween(
|
|
468
477
|
"rotation",
|
|
@@ -471,6 +480,7 @@ export async function tryGsapRotationIntercept(
|
|
|
471
480
|
commitMutation,
|
|
472
481
|
fetchFallbackAnimations,
|
|
473
482
|
);
|
|
483
|
+
const resolvedAnimations = resolved?.animations ?? animations;
|
|
474
484
|
|
|
475
485
|
// Fallback: legacy heuristic for hand-written scripts
|
|
476
486
|
let anim = resolved?.anim ?? null;
|
|
@@ -481,20 +491,27 @@ export async function tryGsapRotationIntercept(
|
|
|
481
491
|
anim = fresh.find((a) => "rotation" in a.properties || a.keyframes) ?? null;
|
|
482
492
|
}
|
|
483
493
|
}
|
|
484
|
-
if (!anim) return false;
|
|
485
494
|
|
|
486
|
-
|
|
487
|
-
|
|
495
|
+
// `angle` is the ABSOLUTE target rotation resolved by the gesture (gsap base +
|
|
496
|
+
// pointer sweep) or the inspector — so it IS the new rotation. No base re-add: the
|
|
497
|
+
// gesture's live preview already gsap.set this value (single source of truth).
|
|
498
|
+
const newRotation = Math.round(angle);
|
|
488
499
|
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
500
|
+
// STATIC case (single source of truth = GSAP timeline): no rotation tween, so the
|
|
501
|
+
// angle belongs in a `tl.set("#el",{rotation})`, not a keyframe conversion —
|
|
502
|
+
// mirroring the static position set. Idempotent: re-rotate updates an existing
|
|
503
|
+
// rotation set in place, else add a new one. This replaces the old
|
|
504
|
+
// `--hf-studio-rotation` CSS-var fallback (the same dual-channel bug class).
|
|
505
|
+
if (!anim) {
|
|
506
|
+
const existingSet = findRotationSetAnimation(resolvedAnimations, selector);
|
|
507
|
+
await commitStaticGsapRotation(selection, newRotation, selector, existingSet, {
|
|
508
|
+
commitMutation,
|
|
509
|
+
fetchAnimations: fetchFallbackAnimations,
|
|
510
|
+
});
|
|
511
|
+
return true;
|
|
494
512
|
}
|
|
495
513
|
|
|
496
514
|
const pct = computeCurrentPercentage(selection, anim);
|
|
497
|
-
const newRotation = Math.round(gsapRotation + angle);
|
|
498
515
|
|
|
499
516
|
if (anim.hasUnresolvedKeyframes || anim.hasUnresolvedSelector) {
|
|
500
517
|
const newId = await materializeIfDynamic(anim, iframe, commitMutation, selection);
|
|
@@ -1,5 +1,133 @@
|
|
|
1
1
|
import { describe, expect, it } from "vitest";
|
|
2
|
-
import {
|
|
2
|
+
import {
|
|
3
|
+
arcPathFromMotionPathValue,
|
|
4
|
+
hasNonHoldTweenForElement,
|
|
5
|
+
readRuntimeKeyframes,
|
|
6
|
+
} from "./gsapRuntimeKeyframes";
|
|
7
|
+
|
|
8
|
+
// Build a fake preview iframe whose runtime timeline holds the given child tweens
|
|
9
|
+
// and resolves `selector` to `el`.
|
|
10
|
+
function fakeIframe(el: { id: string }, children: unknown[], now?: number): HTMLIFrameElement {
|
|
11
|
+
const timeline = {
|
|
12
|
+
getChildren: () => children,
|
|
13
|
+
duration: () => 14.6,
|
|
14
|
+
...(now != null ? { time: () => now } : {}),
|
|
15
|
+
};
|
|
16
|
+
return {
|
|
17
|
+
contentWindow: { __timelines: { "index.html": timeline } },
|
|
18
|
+
contentDocument: { querySelector: (sel: string) => (sel === `#${el.id}` ? el : null) },
|
|
19
|
+
} as unknown as HTMLIFrameElement;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
describe("readRuntimeKeyframes — zero-duration set must not shadow the keyframed tween", () => {
|
|
23
|
+
const el = { id: "puck-b" };
|
|
24
|
+
const holdSet = {
|
|
25
|
+
targets: () => [el],
|
|
26
|
+
// `data` is the STUDIO_HOLD_MARKER sentinel ("hf-hold") from core's gsapParser.
|
|
27
|
+
// TODO(core follow-up): re-export STUDIO_HOLD_MARKER via the @hyperframes/core/
|
|
28
|
+
// gsap-parser subpath so this fixture can import the const instead of the literal.
|
|
29
|
+
vars: { x: 0, y: 0, data: "hf-hold" },
|
|
30
|
+
duration: () => 0,
|
|
31
|
+
startTime: () => 0,
|
|
32
|
+
};
|
|
33
|
+
const kfTween = {
|
|
34
|
+
targets: () => [el],
|
|
35
|
+
vars: {
|
|
36
|
+
keyframes: [
|
|
37
|
+
{ x: 0, y: 0 },
|
|
38
|
+
{ x: -180, y: -60 },
|
|
39
|
+
{ x: -320, y: 40 },
|
|
40
|
+
{ x: -460, y: -20 },
|
|
41
|
+
],
|
|
42
|
+
duration: 3.4,
|
|
43
|
+
ease: "power1.inOut",
|
|
44
|
+
},
|
|
45
|
+
duration: () => 3.4,
|
|
46
|
+
startTime: () => 1.0,
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
it("reads all 4 keyframes from the to() even when a hold-set precedes it", () => {
|
|
50
|
+
const read = readRuntimeKeyframes(fakeIframe(el, [holdSet, kfTween]), "#puck-b");
|
|
51
|
+
expect(read?.keyframes).toHaveLength(4);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it("returns null when the element only has a zero-duration set (no real motion)", () => {
|
|
55
|
+
expect(readRuntimeKeyframes(fakeIframe(el, [holdSet]), "#puck-b")).toBeNull();
|
|
56
|
+
});
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
describe("readRuntimeKeyframes — multiple tweens pick the one under the playhead", () => {
|
|
60
|
+
const el = { id: "puck-a" };
|
|
61
|
+
// Two non-overlapping gesture recordings → two separate keyframed tweens.
|
|
62
|
+
const gestureA = {
|
|
63
|
+
targets: () => [el],
|
|
64
|
+
vars: {
|
|
65
|
+
keyframes: [
|
|
66
|
+
{ x: 0, y: 0 },
|
|
67
|
+
{ x: -100, y: 50 },
|
|
68
|
+
],
|
|
69
|
+
duration: 2.03,
|
|
70
|
+
},
|
|
71
|
+
duration: () => 2.03,
|
|
72
|
+
startTime: () => 1.033, // range [1.033, 3.063]
|
|
73
|
+
};
|
|
74
|
+
const gestureB = {
|
|
75
|
+
targets: () => [el],
|
|
76
|
+
vars: {
|
|
77
|
+
keyframes: [
|
|
78
|
+
{ x: 10, y: 10 },
|
|
79
|
+
{ x: 20, y: 20 },
|
|
80
|
+
{ x: 30, y: 30 },
|
|
81
|
+
],
|
|
82
|
+
duration: 1.129,
|
|
83
|
+
},
|
|
84
|
+
duration: () => 1.129,
|
|
85
|
+
startTime: () => 3.342, // range [3.342, 4.471]
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
it("playhead inside the SECOND tween reads the second tween (not the first)", () => {
|
|
89
|
+
const read = readRuntimeKeyframes(fakeIframe(el, [gestureA, gestureB], 3.373), "#puck-a");
|
|
90
|
+
expect(read?.keyframes).toHaveLength(3); // gestureB
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it("playhead inside the FIRST tween reads the first tween", () => {
|
|
94
|
+
const read = readRuntimeKeyframes(fakeIframe(el, [gestureA, gestureB], 2.0), "#puck-a");
|
|
95
|
+
expect(read?.keyframes).toHaveLength(2); // gestureA
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it("playhead outside every range falls back to the first keyframed tween", () => {
|
|
99
|
+
const read = readRuntimeKeyframes(fakeIframe(el, [gestureA, gestureB], 9.0), "#puck-a");
|
|
100
|
+
expect(read?.keyframes).toHaveLength(2); // gestureA (first)
|
|
101
|
+
});
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
describe("hasNonHoldTweenForElement — strict live-tween existence (drag stale-parse guard)", () => {
|
|
105
|
+
const el = { id: "puck-b" };
|
|
106
|
+
const holdSet = {
|
|
107
|
+
targets: () => [el],
|
|
108
|
+
vars: { x: 0, y: 0, data: "hf-hold" },
|
|
109
|
+
duration: () => 0,
|
|
110
|
+
startTime: () => 0,
|
|
111
|
+
};
|
|
112
|
+
const liveTween = {
|
|
113
|
+
targets: () => [el],
|
|
114
|
+
vars: { x: -120, y: 40, duration: 1 },
|
|
115
|
+
duration: () => 1,
|
|
116
|
+
startTime: () => 1,
|
|
117
|
+
};
|
|
118
|
+
|
|
119
|
+
it("true when a non-hold tween targets the element", () => {
|
|
120
|
+
expect(hasNonHoldTweenForElement(fakeIframe(el, [liveTween]), "#puck-b")).toBe(true);
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
it("false when only a zero-duration hold/set remains (post delete-all)", () => {
|
|
124
|
+
expect(hasNonHoldTweenForElement(fakeIframe(el, [holdSet]), "#puck-b")).toBe(false);
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
it("false when the element has no tweens at all", () => {
|
|
128
|
+
expect(hasNonHoldTweenForElement(fakeIframe(el, []), "#puck-b")).toBe(false);
|
|
129
|
+
});
|
|
130
|
+
});
|
|
3
131
|
|
|
4
132
|
describe("arcPathFromMotionPathValue", () => {
|
|
5
133
|
it("builds arc config from object form { path, curviness }", () => {
|
|
@@ -12,20 +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
|
-
interface RuntimeTween {
|
|
15
|
+
export interface RuntimeTween {
|
|
16
16
|
targets?: () => Element[];
|
|
17
17
|
vars?: Record<string, unknown>;
|
|
18
18
|
duration?: () => number;
|
|
19
19
|
startTime?: () => number;
|
|
20
|
+
invalidate?: () => RuntimeTween;
|
|
20
21
|
}
|
|
21
22
|
|
|
22
|
-
interface RuntimeTimeline {
|
|
23
|
+
export interface RuntimeTimeline {
|
|
23
24
|
getChildren?: (deep: boolean) => RuntimeTween[];
|
|
24
25
|
duration?: () => number;
|
|
26
|
+
time?: () => number;
|
|
27
|
+
invalidate?: () => RuntimeTimeline;
|
|
25
28
|
}
|
|
26
29
|
|
|
27
30
|
type Pct = { percentage: number; properties: Record<string, number | string> };
|
|
28
|
-
type ReadTween = { keyframes: Pct[]; easeEach?: string; arcPath?: ArcPathConfig };
|
|
31
|
+
export type ReadTween = { keyframes: Pct[]; easeEach?: string; arcPath?: ArcPathConfig };
|
|
29
32
|
|
|
30
33
|
export interface RuntimeKeyframeEntry {
|
|
31
34
|
keyframes: Pct[];
|
|
@@ -71,6 +74,17 @@ function isXY(p: unknown): p is { x: number; y: number } {
|
|
|
71
74
|
return !!p && typeof (p as any).x === "number" && typeof (p as any).y === "number";
|
|
72
75
|
}
|
|
73
76
|
|
|
77
|
+
/**
|
|
78
|
+
* A tween we must skip when reading keyframes: a zero-duration `set`/hold (incl.
|
|
79
|
+
* the studio pre-keyframe position hold, tagged `data: STUDIO_HOLD_MARKER`).
|
|
80
|
+
* These sit before the real keyframed tween and otherwise shadow it — `readTween`
|
|
81
|
+
* would fall back to a degenerate 2-point flat path from the set's values, hiding
|
|
82
|
+
* the actual multi-keyframe motion. `!(duration > 0)` also rejects NaN durations.
|
|
83
|
+
*/
|
|
84
|
+
function isZeroDurationSet(duration: number): boolean {
|
|
85
|
+
return !(duration > 0);
|
|
86
|
+
}
|
|
87
|
+
|
|
74
88
|
/** Coordinates + curviness from a live `vars.motionPath` value (object or array form), or null. */
|
|
75
89
|
function coordsFromMotionPath(mp: unknown): {
|
|
76
90
|
coords: Array<{ x: number; y: number }>;
|
|
@@ -149,6 +163,95 @@ function tweenTiming(tween: RuntimeTween): { start: number; duration: number } {
|
|
|
149
163
|
};
|
|
150
164
|
}
|
|
151
165
|
|
|
166
|
+
export interface ResolvedRuntimeTween {
|
|
167
|
+
/** The live GSAP tween targeting the selector. */
|
|
168
|
+
tween: RuntimeTween;
|
|
169
|
+
/** The composition timeline that owns it. */
|
|
170
|
+
timeline: RuntimeTimeline;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* Whether a tween's `vars` carry at least one of `channels` as an OWN property.
|
|
175
|
+
* Used to disambiguate co-located `set`s: an element can have separate
|
|
176
|
+
* `tl.set("#el",{x,y})` and `tl.set("#el",{rotation})` tweens, and a position
|
|
177
|
+
* patch must land on the {x,y} set — never the rotation-only one.
|
|
178
|
+
*/
|
|
179
|
+
function varsCarryChannel(vars: Record<string, unknown> | undefined, channels: string[]): boolean {
|
|
180
|
+
if (!vars) return false;
|
|
181
|
+
for (const ch of channels) {
|
|
182
|
+
if (Object.prototype.hasOwnProperty.call(vars, ch)) return true;
|
|
183
|
+
}
|
|
184
|
+
return false;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* Resolve the live tween targeting `selector` using the SAME all-timelines scan
|
|
189
|
+
* `readRuntimeKeyframes` uses, so read and write agree on "which tween". With
|
|
190
|
+
* `kind: "keyframe"` it skips zero-duration `set`s and prefers the tween whose
|
|
191
|
+
* range contains the playhead (matching the reader). With `kind: "set"` it picks
|
|
192
|
+
* the zero-duration `set`/hold instead. Returns null when none matches.
|
|
193
|
+
*
|
|
194
|
+
* `channels` disambiguates co-located `set`s (CHANNEL-BLIND otherwise): when
|
|
195
|
+
* provided with `kind: "set"`, a set carrying ONE of those channels wins, and a
|
|
196
|
+
* set carrying ONLY disjoint channels is skipped (so patching {x,y} never lands
|
|
197
|
+
* on a rotation-only set). With no channel-matching set, it falls back to the
|
|
198
|
+
* first matching set (back-compat). `channels` is ignored for `kind: "keyframe"`.
|
|
199
|
+
*/
|
|
200
|
+
export function resolveRuntimeTween(
|
|
201
|
+
iframe: HTMLIFrameElement | null,
|
|
202
|
+
selector: string,
|
|
203
|
+
kind: "keyframe" | "set",
|
|
204
|
+
compositionId?: string,
|
|
205
|
+
channels?: string[],
|
|
206
|
+
): ResolvedRuntimeTween | null {
|
|
207
|
+
const timelines = timelinesOf(iframe);
|
|
208
|
+
if (!timelines) return null;
|
|
209
|
+
|
|
210
|
+
let targetEl: Element | null = null;
|
|
211
|
+
try {
|
|
212
|
+
targetEl = iframe?.contentDocument?.querySelector(selector) ?? null;
|
|
213
|
+
} catch {
|
|
214
|
+
return null;
|
|
215
|
+
}
|
|
216
|
+
if (!targetEl) return null;
|
|
217
|
+
|
|
218
|
+
const tlIds = compositionId
|
|
219
|
+
? [compositionId]
|
|
220
|
+
: Object.keys(timelines).filter((k) => typeof timelines[k]?.getChildren === "function");
|
|
221
|
+
|
|
222
|
+
const wantChannels = kind === "set" && channels && channels.length > 0 ? channels : null;
|
|
223
|
+
|
|
224
|
+
let first: ResolvedRuntimeTween | null = null;
|
|
225
|
+
let channelMatch: ResolvedRuntimeTween | null = null;
|
|
226
|
+
for (const tlId of tlIds) {
|
|
227
|
+
const timeline = timelines[tlId];
|
|
228
|
+
if (!timeline?.getChildren) continue;
|
|
229
|
+
const now = typeof timeline.time === "function" ? timeline.time() : null;
|
|
230
|
+
for (const tween of timeline.getChildren(true)) {
|
|
231
|
+
if (!tween.vars || !matchesElement(tween, targetEl)) continue;
|
|
232
|
+
const dur = typeof tween.duration === "function" ? tween.duration() : 0;
|
|
233
|
+
const isSet = !(dur > 0);
|
|
234
|
+
if (kind === "set" ? !isSet : isSet) continue;
|
|
235
|
+
if (wantChannels) {
|
|
236
|
+
if (varsCarryChannel(tween.vars, wantChannels)) {
|
|
237
|
+
if (channelMatch === null) channelMatch = { tween, timeline };
|
|
238
|
+
} else if (first === null) {
|
|
239
|
+
// A set carrying only disjoint channels: remember as last-resort
|
|
240
|
+
// fallback, but never prefer it over a channel-matching set.
|
|
241
|
+
first = { tween, timeline };
|
|
242
|
+
}
|
|
243
|
+
continue;
|
|
244
|
+
}
|
|
245
|
+
if (first === null) first = { tween, timeline };
|
|
246
|
+
if (kind === "keyframe" && now != null) {
|
|
247
|
+
const start = typeof tween.startTime === "function" ? tween.startTime() : 0;
|
|
248
|
+
if (now >= start - 1e-3 && now <= start + dur + 1e-3) return { tween, timeline };
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
return channelMatch ?? first;
|
|
253
|
+
}
|
|
254
|
+
|
|
152
255
|
/**
|
|
153
256
|
* Read keyframes (incl. motionPath arcs) for one selector from the live timeline.
|
|
154
257
|
* Returns tween-relative percentages; callers convert to clip-relative.
|
|
@@ -160,10 +263,6 @@ export function readRuntimeKeyframes(
|
|
|
160
263
|
): ReadTween | null {
|
|
161
264
|
const timelines = timelinesOf(iframe);
|
|
162
265
|
if (!timelines) return null;
|
|
163
|
-
const tlId = compositionId || Object.keys(timelines)[0];
|
|
164
|
-
if (!tlId) return null;
|
|
165
|
-
const timeline = timelines[tlId];
|
|
166
|
-
if (!timeline?.getChildren) return null;
|
|
167
266
|
|
|
168
267
|
let targetEl: Element | null = null;
|
|
169
268
|
try {
|
|
@@ -173,12 +272,84 @@ export function readRuntimeKeyframes(
|
|
|
173
272
|
}
|
|
174
273
|
if (!targetEl) return null;
|
|
175
274
|
|
|
275
|
+
// Search the element's OWN composition timeline. With inlined subcompositions the
|
|
276
|
+
// preview has multiple timelines (one per composition), and the element belongs to
|
|
277
|
+
// exactly one — so we can't assume the first key (order isn't stable across soft
|
|
278
|
+
// reloads, which delete+re-add the rebuilt key). Scan every timeline for tweens
|
|
279
|
+
// targeting this element; only its composition's timeline matches. An explicit
|
|
280
|
+
// compositionId still pins the search. (`__proxied` and other non-timeline markers
|
|
281
|
+
// are skipped by the getChildren guard.)
|
|
282
|
+
const tlIds = compositionId
|
|
283
|
+
? [compositionId]
|
|
284
|
+
: Object.keys(timelines).filter((k) => typeof timelines[k]?.getChildren === "function");
|
|
285
|
+
if (tlIds.length === 0) return null;
|
|
286
|
+
|
|
287
|
+
// The element can have MORE THAN ONE keyframed tween at disjoint time ranges
|
|
288
|
+
// (e.g. two non-overlapping gesture recordings → two separate `to()`s). The
|
|
289
|
+
// overlay must draw the segment under the PLAYHEAD, not blindly the first one
|
|
290
|
+
// — otherwise recording a second gesture leaves the path stuck on the first.
|
|
291
|
+
let firstRead: ReadTween | null = null;
|
|
292
|
+
for (const tlId of tlIds) {
|
|
293
|
+
const timeline = timelines[tlId];
|
|
294
|
+
if (!timeline?.getChildren) continue;
|
|
295
|
+
const now = typeof timeline.time === "function" ? timeline.time() : null;
|
|
296
|
+
for (const tween of timeline.getChildren(true)) {
|
|
297
|
+
if (!tween.vars || !matchesElement(tween, targetEl)) continue;
|
|
298
|
+
const dur = typeof tween.duration === "function" ? tween.duration() : 0;
|
|
299
|
+
if (isZeroDurationSet(dur)) continue; // skip hold/set tweens (see isZeroDurationSet)
|
|
300
|
+
const read = readTween(tween.vars);
|
|
301
|
+
if (!read) continue;
|
|
302
|
+
if (firstRead === null) firstRead = read;
|
|
303
|
+
// Prefer the tween whose [start, start+dur] contains the playhead.
|
|
304
|
+
if (now != null) {
|
|
305
|
+
const start = typeof tween.startTime === "function" ? tween.startTime() : 0;
|
|
306
|
+
if (now >= start - 1e-3 && now <= start + dur + 1e-3) return read;
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
// Playhead outside every tween's range (or timeline has no clock): the element
|
|
311
|
+
// still has motion, so fall back to the first keyframed tween.
|
|
312
|
+
return firstRead;
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
/**
|
|
316
|
+
* Whether the live timeline has at least one NON-HOLD tween (non-zero duration,
|
|
317
|
+
* not the studio position-hold `set`) targeting `selector`. Stricter than a
|
|
318
|
+
* truthy `readRuntimeKeyframes`: that returns a flat read for any property-bearing
|
|
319
|
+
* tween, so it can't distinguish a real animation from a leftover hold/marker.
|
|
320
|
+
* The drag's stale-parse guard needs this exact distinction — after a delete-all
|
|
321
|
+
* only a hold may remain, and resurrecting the deleted tween from the stale parse
|
|
322
|
+
* must be avoided.
|
|
323
|
+
*/
|
|
324
|
+
export function hasNonHoldTweenForElement(
|
|
325
|
+
iframe: HTMLIFrameElement | null,
|
|
326
|
+
selector: string,
|
|
327
|
+
compositionId?: string,
|
|
328
|
+
): boolean {
|
|
329
|
+
const timelines = timelinesOf(iframe);
|
|
330
|
+
if (!timelines) return false;
|
|
331
|
+
const tlId =
|
|
332
|
+
compositionId ||
|
|
333
|
+
Object.keys(timelines).find((k) => typeof timelines[k]?.getChildren === "function");
|
|
334
|
+
if (!tlId) return false;
|
|
335
|
+
const timeline = timelines[tlId];
|
|
336
|
+
if (!timeline?.getChildren) return false;
|
|
337
|
+
|
|
338
|
+
let targetEl: Element | null = null;
|
|
339
|
+
try {
|
|
340
|
+
targetEl = iframe?.contentDocument?.querySelector(selector) ?? null;
|
|
341
|
+
} catch {
|
|
342
|
+
return false;
|
|
343
|
+
}
|
|
344
|
+
if (!targetEl) return false;
|
|
345
|
+
|
|
176
346
|
for (const tween of timeline.getChildren(true)) {
|
|
177
347
|
if (!tween.vars || !matchesElement(tween, targetEl)) continue;
|
|
178
|
-
const
|
|
179
|
-
if (
|
|
348
|
+
const dur = typeof tween.duration === "function" ? tween.duration() : 0;
|
|
349
|
+
if (isZeroDurationSet(dur)) continue; // skip hold/set tweens (see isZeroDurationSet)
|
|
350
|
+
if (readTween(tween.vars)) return true;
|
|
180
351
|
}
|
|
181
|
-
return
|
|
352
|
+
return false;
|
|
182
353
|
}
|
|
183
354
|
|
|
184
355
|
/** Convert tween-relative keyframes to clip-relative % using the element's clip dims. */
|
|
@@ -217,9 +388,10 @@ function addScanEntry(
|
|
|
217
388
|
clipById?: ClipDims,
|
|
218
389
|
): void {
|
|
219
390
|
if (!tween.targets || !tween.vars) return;
|
|
391
|
+
const { start, duration } = tweenTiming(tween);
|
|
392
|
+
if (isZeroDurationSet(duration)) return; // skip hold/set tweens (see isZeroDurationSet)
|
|
220
393
|
const read = readTween(tween.vars);
|
|
221
394
|
if (!read) return;
|
|
222
|
-
const { start, duration } = tweenTiming(tween);
|
|
223
395
|
for (const target of tween.targets()) {
|
|
224
396
|
const id = (target as HTMLElement).id;
|
|
225
397
|
if (id && !result.has(id)) result.set(id, buildEntry(read, start, duration, clipById?.get(id)));
|