@hyperframes/studio 0.7.75 → 0.7.77
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-BrfzYqX3.js → hyperframes-player-CEggaaxR.js} +1 -1
- package/dist/assets/{index-BvGKYVrH.js → index-B1INQNmj.js} +1 -1
- package/dist/assets/{index-CjGZkNxS.js → index-B1Tjjdse.js} +1 -1
- package/dist/assets/index-Bp4jAYZG.js +428 -0
- package/dist/assets/index-CBAfprvx.css +1 -0
- package/dist/index.d.ts +64 -17
- package/dist/index.html +2 -2
- package/dist/index.js +18040 -16293
- package/dist/index.js.map +1 -1
- package/package.json +7 -7
- package/src/App.tsx +16 -17
- package/src/components/EditorShell.tsx +3 -0
- package/src/components/TimelineToolbar.test.tsx +48 -3
- package/src/components/TimelineToolbar.tsx +89 -36
- package/src/components/editor/AnimationCard.test.tsx +18 -75
- package/src/components/editor/AnimationCard.tsx +26 -2
- package/src/components/editor/GsapAddAnimationControl.tsx +68 -0
- package/src/components/editor/GsapAnimationSection.tsx +18 -157
- package/src/components/editor/KeyframeEaseList.tsx +5 -1
- package/src/components/editor/KeyframeNavigation.tsx +46 -14
- package/src/components/editor/KeyframeNavigationDiamond.test.tsx +59 -0
- package/src/components/editor/MotionPathOverlay.tsx +32 -10
- package/src/components/editor/PropertyPanel.tsx +15 -5
- package/src/components/editor/PropertyPanelFlat.tsx +48 -23
- package/src/components/editor/gsapAnimationCallbacks.test.ts +122 -0
- package/src/components/editor/gsapAnimationCallbacks.ts +108 -4
- package/src/components/editor/keyframeRetime.test.ts +25 -22
- package/src/components/editor/keyframeRetime.ts +1 -2
- package/src/components/editor/propertyPanelFlatLayoutSection.tsx +2 -2
- package/src/components/editor/propertyPanelFlatMotionSection.tsx +25 -139
- package/src/components/editor/propertyPanelFlatTextSection.test.tsx +32 -16
- package/src/components/editor/propertyPanelFlatTextSection.tsx +21 -4
- package/src/components/nle/PreviewOverlays.tsx +3 -1
- package/src/components/nle/useTimelineEditCallbacks.test.ts +30 -0
- package/src/components/nle/useTimelineEditCallbacks.test.tsx +693 -0
- package/src/components/nle/useTimelineEditCallbacks.ts +197 -57
- package/src/contexts/TimelineEditContext.tsx +1 -1
- package/src/hooks/deleteSelectedKeyframes.ts +24 -8
- package/src/hooks/gsapDragCommit.ts +2 -2
- package/src/hooks/gsapDragPositionCommit.ts +84 -5
- package/src/hooks/gsapKeyframeCacheHelpers.test.ts +176 -1
- package/src/hooks/gsapKeyframeCacheHelpers.ts +93 -52
- package/src/hooks/gsapRuntimeBridge.test.ts +100 -0
- package/src/hooks/gsapScriptCommitHelpers.ts +3 -2
- package/src/hooks/gsapScriptCommitTypes.ts +2 -0
- package/src/hooks/gsapShared.test.ts +109 -1
- package/src/hooks/gsapShared.ts +142 -17
- package/src/hooks/gsapTweenSynth.test.ts +30 -1
- package/src/hooks/gsapTweenSynth.ts +61 -14
- package/src/hooks/keyframeCacheAstLoad.ts +181 -0
- package/src/hooks/timelineEditingHelpers.test.ts +80 -0
- package/src/hooks/timelineMoveAdapter.ts +9 -2
- package/src/hooks/useDomEditWiring.ts +2 -2
- package/src/hooks/useDomSelection.ts +4 -0
- package/src/hooks/useEnableKeyframes.test.ts +122 -0
- package/src/hooks/useEnableKeyframes.ts +116 -56
- package/src/hooks/useGestureCommit.ts +2 -2
- package/src/hooks/useGsapAnimationFetchFallback.ts +1 -1
- package/src/hooks/useGsapKeyframeOps.test.tsx +92 -13
- package/src/hooks/useGsapKeyframeOps.ts +30 -13
- package/src/hooks/useGsapScriptCommits.test.tsx +21 -0
- package/src/hooks/useGsapScriptCommits.ts +2 -0
- package/src/hooks/useGsapSelectionHandlers.test.tsx +69 -3
- package/src/hooks/useGsapSelectionHandlers.ts +90 -65
- package/src/hooks/useGsapTweenCache.test.ts +16 -0
- package/src/hooks/useGsapTweenCache.ts +64 -178
- package/src/hooks/usePopulateKeyframeCacheForFile.test.tsx +68 -0
- package/src/hooks/useStudioContextValue.test.ts +93 -0
- package/src/hooks/useStudioContextValue.ts +17 -3
- package/src/hooks/useStudioTestHooks.ts +63 -0
- package/src/hooks/useTimelineEditing.test.tsx +187 -22
- package/src/hooks/useTimelineEditing.ts +43 -32
- package/src/hooks/useTimelineEditingTypes.ts +2 -0
- package/src/hooks/useTimelineGroupEditing.ts +106 -82
- package/src/player/components/KeyframeDiamondContextMenu.test.tsx +69 -0
- package/src/player/components/KeyframeDiamondContextMenu.tsx +19 -8
- package/src/player/components/LayerDisclosureRow.tsx +76 -0
- package/src/player/components/Timeline.test.ts +420 -90
- package/src/player/components/Timeline.tsx +80 -78
- package/src/player/components/TimelineCanvas.tsx +33 -21
- package/src/player/components/TimelineClip.tsx +3 -1
- package/src/player/components/TimelineClipDiamonds.test.tsx +540 -16
- package/src/player/components/TimelineClipDiamonds.tsx +320 -117
- package/src/player/components/TimelineDiamondConnectors.tsx +145 -0
- package/src/player/components/TimelineLanes.tsx +153 -79
- package/src/player/components/TimelineOverlays.tsx +4 -18
- package/src/player/components/TimelinePropertyLanes.test.tsx +450 -0
- package/src/player/components/TimelinePropertyLanes.tsx +191 -0
- package/src/player/components/TimelineRazorInteraction.tsx +60 -0
- package/src/player/components/TimelineRuler.tsx +11 -21
- package/src/player/components/TimelineTrackHeader.test.tsx +346 -0
- package/src/player/components/TimelineTrackHeader.tsx +363 -0
- package/src/player/components/TrackClipCount.tsx +17 -0
- package/src/player/components/timelineCallbacks.ts +21 -6
- package/src/player/components/timelineClipDragPreview.test.ts +50 -3
- package/src/player/components/timelineClipDragPreview.ts +33 -15
- package/src/player/components/timelineCollision.ts +4 -13
- package/src/player/components/timelineDiamondTypes.ts +120 -0
- package/src/player/components/timelineDragDrop.ts +8 -3
- package/src/player/components/timelineEditing.test.ts +19 -27
- package/src/player/components/timelineEditing.ts +7 -8
- package/src/player/components/timelineKeyframeIdentity.test.ts +49 -0
- package/src/player/components/timelineKeyframeIdentity.ts +66 -0
- package/src/player/components/timelineLayout.test.ts +162 -9
- package/src/player/components/timelineLayout.ts +198 -38
- package/src/player/components/timelineMarquee.test.ts +76 -21
- package/src/player/components/timelineMarquee.ts +17 -14
- package/src/player/components/trackHeaderLaneState.ts +121 -0
- package/src/player/components/trackHeaderLaneValues.ts +139 -0
- package/src/player/components/useAutoExpandKeyframedClips.test.tsx +75 -0
- package/src/player/components/useAutoExpandKeyframedClips.ts +50 -0
- package/src/player/components/useTimelineClipDrag.ts +4 -1
- package/src/player/components/useTimelineGeometry.ts +4 -1
- package/src/player/components/useTimelineKeyframeHandlers.test.tsx +108 -0
- package/src/player/components/useTimelineKeyframeHandlers.ts +50 -15
- package/src/player/components/useTimelinePlayhead.ts +21 -12
- package/src/player/components/useTimelineRangeSelection.ts +35 -14
- package/src/player/components/useTimelineTrackLayout.test.ts +53 -0
- package/src/player/components/useTimelineTrackLayout.ts +162 -0
- package/src/player/hooks/useExpandedTimelineElements.test.ts +43 -0
- package/src/player/hooks/useExpandedTimelineElements.ts +21 -12
- package/src/player/lib/timelineElementHelpers.ts +26 -11
- package/src/player/store/keyframeSlice.ts +122 -0
- package/src/player/store/playerStore.test.ts +25 -0
- package/src/player/store/playerStore.ts +7 -43
- package/src/telemetry/events.test.ts +9 -3
- package/src/telemetry/events.ts +16 -7
- package/src/utils/globalTimeCompiler.test.ts +10 -0
- package/src/utils/globalTimeCompiler.ts +3 -3
- package/dist/assets/index-B37rWXo4.css +0 -1
- package/dist/assets/index-DzDajONI.js +0 -428
- package/src/player/components/TimelineDragGhost.tsx +0 -59
- package/src/utils/keyframeSelection.test.ts +0 -45
- package/src/utils/keyframeSelection.ts +0 -29
|
@@ -24,6 +24,7 @@ import {
|
|
|
24
24
|
type DomEditSelection,
|
|
25
25
|
} from "../components/editor/domEditing";
|
|
26
26
|
import { reapplyPositionEditsAfterSeek } from "../components/editor/manualEdits";
|
|
27
|
+
import { useStudioTestHooks } from "./useStudioTestHooks";
|
|
27
28
|
|
|
28
29
|
// ── Types ──
|
|
29
30
|
|
|
@@ -506,6 +507,9 @@ export function useDomSelection({
|
|
|
506
507
|
applyDomSelection(null, { revealPanel: false });
|
|
507
508
|
}, [applyDomSelection, captionEditMode]);
|
|
508
509
|
|
|
510
|
+
// Dev-only headless-QA shortcut (window.__studioTest.selectByDomId). No-op in prod.
|
|
511
|
+
useStudioTestHooks({ previewIframeRef, buildDomSelectionFromTarget, applyDomSelection });
|
|
512
|
+
|
|
509
513
|
const applyMarqueeSelection = useCallback(
|
|
510
514
|
// fallow-ignore-next-line complexity
|
|
511
515
|
(selections: DomEditSelection[], additive: boolean) => {
|
|
@@ -5,6 +5,7 @@ import { afterEach, describe, expect, it, vi } from "vitest";
|
|
|
5
5
|
import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
|
|
6
6
|
import type { DomEditSelection } from "../components/editor/domEditingTypes";
|
|
7
7
|
import {
|
|
8
|
+
applyArcKeyframeAtPlayhead,
|
|
8
9
|
animatedProps,
|
|
9
10
|
buildExtendedKeyframes,
|
|
10
11
|
isPlayheadWithinTween,
|
|
@@ -107,6 +108,19 @@ describe("isPlayheadWithinTween", () => {
|
|
|
107
108
|
it("does not block when the start can't be resolved", () => {
|
|
108
109
|
expect(isPlayheadWithinTween(anim({ position: "+=1" }), 99)).toBe(true);
|
|
109
110
|
});
|
|
111
|
+
|
|
112
|
+
// The toolbar's "extends animation" tooltip has to agree with what the edit
|
|
113
|
+
// paths do. Those span a duration-less tween across its clip, so answering
|
|
114
|
+
// from GSAP's 0.5s default reported the playhead outside a window the click
|
|
115
|
+
// then treated as clip-wide.
|
|
116
|
+
it("spans the clip for a duration-less tween when given the selection", () => {
|
|
117
|
+
const durationless = anim({ position: 0 });
|
|
118
|
+
const selection = { dataAttributes: { duration: "16" } } as unknown as DomEditSelection;
|
|
119
|
+
|
|
120
|
+
expect(isPlayheadWithinTween(durationless, 5)).toBe(false);
|
|
121
|
+
expect(isPlayheadWithinTween(durationless, 5, selection)).toBe(true);
|
|
122
|
+
expect(isPlayheadWithinTween(durationless, 20, selection)).toBe(false);
|
|
123
|
+
});
|
|
110
124
|
});
|
|
111
125
|
|
|
112
126
|
describe("buildExtendedKeyframes", () => {
|
|
@@ -219,6 +233,114 @@ describe("promoteSetToKeyframes — auto endpoint", () => {
|
|
|
219
233
|
});
|
|
220
234
|
});
|
|
221
235
|
|
|
236
|
+
describe("applyArcKeyframeAtPlayhead", () => {
|
|
237
|
+
const arcAnim = anim({
|
|
238
|
+
id: "#el-to-0-position",
|
|
239
|
+
position: 0,
|
|
240
|
+
duration: 10,
|
|
241
|
+
keyframes: {
|
|
242
|
+
format: "object-array",
|
|
243
|
+
keyframes: [
|
|
244
|
+
{ percentage: 0, properties: { x: 0, y: 0 } },
|
|
245
|
+
{ percentage: 50, properties: { x: 50, y: 50 } },
|
|
246
|
+
{ percentage: 100, properties: { x: 100, y: 0 } },
|
|
247
|
+
],
|
|
248
|
+
},
|
|
249
|
+
arcPath: {
|
|
250
|
+
enabled: true,
|
|
251
|
+
autoRotate: false,
|
|
252
|
+
segments: [{ curviness: 1 }, { curviness: 1 }],
|
|
253
|
+
},
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
function arcFixture(x: number, y: number) {
|
|
257
|
+
const commitMutation = vi.fn(async () => undefined);
|
|
258
|
+
const session = { commitMutation } as unknown as EnableKeyframesSession;
|
|
259
|
+
const sel = {
|
|
260
|
+
id: "el",
|
|
261
|
+
selector: "#el",
|
|
262
|
+
element: { isConnected: true } as HTMLElement,
|
|
263
|
+
dataAttributes: { duration: "10" },
|
|
264
|
+
} as DomEditSelection;
|
|
265
|
+
const iframe = {
|
|
266
|
+
contentWindow: {
|
|
267
|
+
gsap: { getProperty: (_element: Element, property: string) => (property === "x" ? x : y) },
|
|
268
|
+
},
|
|
269
|
+
} as unknown as HTMLIFrameElement;
|
|
270
|
+
return { commitMutation, iframe, sel, session };
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
it("removes an existing interior stop without redistributing the remaining times", async () => {
|
|
274
|
+
const fixture = arcFixture(50, 50);
|
|
275
|
+
await applyArcKeyframeAtPlayhead(fixture.session, fixture.sel, arcAnim, 5, fixture.iframe);
|
|
276
|
+
expect(fixture.commitMutation).toHaveBeenCalledWith(
|
|
277
|
+
{
|
|
278
|
+
type: "replace-with-keyframes",
|
|
279
|
+
animationId: arcAnim.id,
|
|
280
|
+
targetSelector: "#el",
|
|
281
|
+
position: 0,
|
|
282
|
+
duration: 10,
|
|
283
|
+
keyframes: [
|
|
284
|
+
{ percentage: 0, properties: { x: 0, y: 0 } },
|
|
285
|
+
{ percentage: 100, properties: { x: 100, y: 0 } },
|
|
286
|
+
],
|
|
287
|
+
ease: "none",
|
|
288
|
+
},
|
|
289
|
+
{ label: "Remove keyframe", softReload: true },
|
|
290
|
+
);
|
|
291
|
+
});
|
|
292
|
+
|
|
293
|
+
it("preserves the path endpoints", async () => {
|
|
294
|
+
const fixture = arcFixture(0, 0);
|
|
295
|
+
await applyArcKeyframeAtPlayhead(fixture.session, fixture.sel, arcAnim, 0, fixture.iframe);
|
|
296
|
+
expect(fixture.commitMutation).not.toHaveBeenCalled();
|
|
297
|
+
});
|
|
298
|
+
|
|
299
|
+
it("adds a temporal keyframe at the exact playhead while preserving authored times", async () => {
|
|
300
|
+
const fixture = arcFixture(25, 25);
|
|
301
|
+
await applyArcKeyframeAtPlayhead(fixture.session, fixture.sel, arcAnim, 2.5, fixture.iframe);
|
|
302
|
+
expect(fixture.commitMutation).toHaveBeenCalledWith(
|
|
303
|
+
{
|
|
304
|
+
type: "replace-with-keyframes",
|
|
305
|
+
animationId: arcAnim.id,
|
|
306
|
+
targetSelector: "#el",
|
|
307
|
+
position: 0,
|
|
308
|
+
duration: 10,
|
|
309
|
+
keyframes: [
|
|
310
|
+
{ percentage: 0, properties: { x: 0, y: 0 } },
|
|
311
|
+
{ percentage: 25, properties: { x: 25, y: 25 } },
|
|
312
|
+
{ percentage: 50, properties: { x: 50, y: 50 } },
|
|
313
|
+
{ percentage: 100, properties: { x: 100, y: 0 } },
|
|
314
|
+
],
|
|
315
|
+
ease: "none",
|
|
316
|
+
},
|
|
317
|
+
{ label: "Add keyframe", softReload: true },
|
|
318
|
+
);
|
|
319
|
+
});
|
|
320
|
+
|
|
321
|
+
it("uses the owning clip duration when an arc omits its outer duration", async () => {
|
|
322
|
+
const fixture = arcFixture(25, 25);
|
|
323
|
+
const durationlessArc = { ...arcAnim, duration: undefined };
|
|
324
|
+
|
|
325
|
+
await applyArcKeyframeAtPlayhead(
|
|
326
|
+
fixture.session,
|
|
327
|
+
fixture.sel,
|
|
328
|
+
durationlessArc,
|
|
329
|
+
2.5,
|
|
330
|
+
fixture.iframe,
|
|
331
|
+
);
|
|
332
|
+
|
|
333
|
+
expect(fixture.commitMutation).toHaveBeenCalledWith(
|
|
334
|
+
expect.objectContaining({
|
|
335
|
+
type: "replace-with-keyframes",
|
|
336
|
+
duration: 10,
|
|
337
|
+
keyframes: expect.arrayContaining([{ percentage: 25, properties: { x: 25, y: 25 } }]),
|
|
338
|
+
}),
|
|
339
|
+
{ label: "Add keyframe", softReload: true },
|
|
340
|
+
);
|
|
341
|
+
});
|
|
342
|
+
});
|
|
343
|
+
|
|
222
344
|
function renderEnableKeyframes(session: EnableKeyframesSession): () => Promise<void> {
|
|
223
345
|
let enable: (() => Promise<void>) | null = null;
|
|
224
346
|
function Probe() {
|
|
@@ -12,16 +12,23 @@ import type { GsapAnimation, GsapPercentageKeyframe } from "@hyperframes/core/gs
|
|
|
12
12
|
import type { DomEditSelection } from "../components/editor/domEditingTypes";
|
|
13
13
|
import { usePlayerStore } from "../player/store/playerStore";
|
|
14
14
|
import { fetchParsedAnimations, getAnimationsForElement } from "./useGsapTweenCache";
|
|
15
|
-
import { selectorFromSelection, computeElementPercentage, isInstantHold } from "./gsapShared";
|
|
16
15
|
import {
|
|
16
|
+
selectorFromSelection,
|
|
17
|
+
computeElementPercentage,
|
|
18
|
+
KEYFRAME_PCT_MATCH,
|
|
19
|
+
isInstantHold,
|
|
20
|
+
resolveEditableTweenDuration,
|
|
21
|
+
} from "./gsapShared";
|
|
22
|
+
import {
|
|
23
|
+
absoluteToPercentage,
|
|
17
24
|
resolveTweenStart,
|
|
18
25
|
resolveTweenDuration,
|
|
19
26
|
isTimeWithinTween,
|
|
20
27
|
} from "../utils/globalTimeCompiler";
|
|
21
28
|
import { POSITION_PROPS } from "./gsapRuntimeReaders";
|
|
22
29
|
import { roundTo3 } from "../utils/rounding";
|
|
23
|
-
import { nearestPointOnPath } from "../components/editor/motionPathGeometry";
|
|
24
30
|
import type { CommitMutationOptions } from "./gsapScriptCommitTypes";
|
|
31
|
+
import { buildTemporalArcKeyframes } from "./gsapDragPositionCommit";
|
|
25
32
|
|
|
26
33
|
let enableKeyframesTransactionCounter = 0;
|
|
27
34
|
|
|
@@ -71,11 +78,22 @@ export function animatedProps(anim: GsapAnimation | null): string[] {
|
|
|
71
78
|
* Whether the playhead sits inside an animation's tween range. When the tween's
|
|
72
79
|
* start can't be resolved we don't block (the percentage falls back to clip range,
|
|
73
80
|
* preserving prior behavior for elements without explicit timing).
|
|
81
|
+
*
|
|
82
|
+
* Pass the selection whenever the caller has one: a duration-less tween spans
|
|
83
|
+
* its clip, and answering from GSAP's 0.5s default reports the playhead outside
|
|
84
|
+
* a window the edit paths treat as clip-wide.
|
|
74
85
|
*/
|
|
75
|
-
export function isPlayheadWithinTween(
|
|
86
|
+
export function isPlayheadWithinTween(
|
|
87
|
+
anim: GsapAnimation,
|
|
88
|
+
currentTime: number,
|
|
89
|
+
selection?: DomEditSelection | null,
|
|
90
|
+
): boolean {
|
|
76
91
|
const start = resolveTweenStart(anim);
|
|
77
92
|
if (start === null) return true;
|
|
78
|
-
|
|
93
|
+
const duration = selection
|
|
94
|
+
? resolveEditableTweenDuration(anim, selection)
|
|
95
|
+
: resolveTweenDuration(anim);
|
|
96
|
+
return isTimeWithinTween(currentTime, start, duration);
|
|
79
97
|
}
|
|
80
98
|
|
|
81
99
|
/**
|
|
@@ -89,9 +107,10 @@ export function buildExtendedKeyframes(
|
|
|
89
107
|
anim: GsapAnimation,
|
|
90
108
|
currentTime: number,
|
|
91
109
|
position: Record<string, number>,
|
|
110
|
+
sourceDuration = resolveTweenDuration(anim),
|
|
92
111
|
): { position: number; duration: number; keyframes: GsapPercentageKeyframe[] } {
|
|
93
112
|
const oldStart = resolveTweenStart(anim) ?? 0;
|
|
94
|
-
const oldDuration =
|
|
113
|
+
const oldDuration = sourceDuration;
|
|
95
114
|
const newStart = Math.min(oldStart, currentTime);
|
|
96
115
|
const newEnd = Math.max(oldStart + oldDuration, currentTime);
|
|
97
116
|
const newDuration = roundTo3(newEnd - newStart);
|
|
@@ -222,6 +241,37 @@ async function fetchAnimationsForElement(sel: DomEditSelection): Promise<GsapAni
|
|
|
222
241
|
return (await tryFetchAnimationsForElement(sel)) ?? [];
|
|
223
242
|
}
|
|
224
243
|
|
|
244
|
+
async function extendKeyframedTweenToPlayhead(
|
|
245
|
+
session: EnableKeyframesSession,
|
|
246
|
+
sel: DomEditSelection,
|
|
247
|
+
anim: GsapAnimation,
|
|
248
|
+
currentTime: number,
|
|
249
|
+
duration: number,
|
|
250
|
+
iframe: HTMLIFrameElement | null,
|
|
251
|
+
commitOverrides?: Partial<CommitMutationOptions>,
|
|
252
|
+
): Promise<void> {
|
|
253
|
+
const selector = selectorFromSelection(sel);
|
|
254
|
+
const position = readElementPosition(iframe, sel, anim);
|
|
255
|
+
if (!selector || Object.keys(position).length === 0 || !session.commitMutation) return;
|
|
256
|
+
const extended = buildExtendedKeyframes(anim, currentTime, position, duration);
|
|
257
|
+
await session.commitMutation(
|
|
258
|
+
{
|
|
259
|
+
type: "replace-with-keyframes",
|
|
260
|
+
animationId: anim.id,
|
|
261
|
+
targetSelector: selector,
|
|
262
|
+
position: extended.position,
|
|
263
|
+
duration: extended.duration,
|
|
264
|
+
keyframes: extended.keyframes,
|
|
265
|
+
ease: anim.ease,
|
|
266
|
+
},
|
|
267
|
+
{
|
|
268
|
+
label: "Add keyframe",
|
|
269
|
+
softReload: true,
|
|
270
|
+
...commitOverrides,
|
|
271
|
+
},
|
|
272
|
+
);
|
|
273
|
+
}
|
|
274
|
+
|
|
225
275
|
/**
|
|
226
276
|
* Apply "add keyframe at playhead" to a tween that already has x/y keyframes:
|
|
227
277
|
* toggle off an existing stop, add one at the playhead's tween-relative %, or —
|
|
@@ -237,32 +287,25 @@ async function applyKeyframeAtPlayhead(
|
|
|
237
287
|
iframe: HTMLIFrameElement | null,
|
|
238
288
|
commitOverrides?: Partial<CommitMutationOptions>,
|
|
239
289
|
): Promise<void> {
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
keyframes: extended.keyframes,
|
|
253
|
-
ease: kfAnim.ease,
|
|
254
|
-
},
|
|
255
|
-
{
|
|
256
|
-
label: "Add keyframe",
|
|
257
|
-
softReload: true,
|
|
258
|
-
...commitOverrides,
|
|
259
|
-
},
|
|
260
|
-
);
|
|
261
|
-
}
|
|
290
|
+
const duration = resolveEditableTweenDuration(kfAnim, sel);
|
|
291
|
+
const start = resolveTweenStart(kfAnim);
|
|
292
|
+
if (start !== null && !isTimeWithinTween(t, start, duration)) {
|
|
293
|
+
await extendKeyframedTweenToPlayhead(
|
|
294
|
+
session,
|
|
295
|
+
sel,
|
|
296
|
+
kfAnim,
|
|
297
|
+
t,
|
|
298
|
+
duration,
|
|
299
|
+
iframe,
|
|
300
|
+
commitOverrides,
|
|
301
|
+
);
|
|
262
302
|
return;
|
|
263
303
|
}
|
|
264
|
-
const pct =
|
|
265
|
-
|
|
304
|
+
const pct =
|
|
305
|
+
start === null ? computeElementPercentage(t, sel) : absoluteToPercentage(t, start, duration);
|
|
306
|
+
const existing = kfAnim.keyframes?.keyframes.find(
|
|
307
|
+
(k) => Math.abs(k.percentage - pct) <= KEYFRAME_PCT_MATCH,
|
|
308
|
+
);
|
|
266
309
|
if (existing) {
|
|
267
310
|
session.handleGsapRemoveKeyframe(kfAnim.id, existing.percentage);
|
|
268
311
|
return;
|
|
@@ -332,14 +375,13 @@ export async function promoteSetToKeyframes(
|
|
|
332
375
|
}
|
|
333
376
|
|
|
334
377
|
/**
|
|
335
|
-
*
|
|
336
|
-
*
|
|
337
|
-
*
|
|
338
|
-
* the path
|
|
339
|
-
* range, extend the duration so the motion reaches the playhead.
|
|
378
|
+
* Convert an arc (motionPath) tween to temporal x/y keyframes before toggling the
|
|
379
|
+
* playhead stop. A toolbar command named "Add keyframe at playhead" must preserve
|
|
380
|
+
* every authored stop's time; inserting a spatial waypoint instead redistributes
|
|
381
|
+
* the path and can silently compress the animation.
|
|
340
382
|
*/
|
|
341
383
|
// fallow-ignore-next-line complexity
|
|
342
|
-
async function
|
|
384
|
+
export async function applyArcKeyframeAtPlayhead(
|
|
343
385
|
session: EnableKeyframesSession,
|
|
344
386
|
sel: DomEditSelection,
|
|
345
387
|
arcAnim: GsapAnimation,
|
|
@@ -347,8 +389,11 @@ async function applyArcWaypointAtPlayhead(
|
|
|
347
389
|
iframe: HTMLIFrameElement | null,
|
|
348
390
|
): Promise<void> {
|
|
349
391
|
if (!session.commitMutation) return;
|
|
350
|
-
|
|
351
|
-
|
|
392
|
+
const targetSelector = selectorFromSelection(sel);
|
|
393
|
+
if (!targetSelector) return;
|
|
394
|
+
const start = resolveTweenStart(arcAnim) ?? 0;
|
|
395
|
+
const duration = resolveEditableTweenDuration(arcAnim, sel);
|
|
396
|
+
if (!isTimeWithinTween(t, start, duration)) {
|
|
352
397
|
if (t > start) {
|
|
353
398
|
await session.commitMutation(
|
|
354
399
|
{
|
|
@@ -361,30 +406,45 @@ async function applyArcWaypointAtPlayhead(
|
|
|
361
406
|
}
|
|
362
407
|
return;
|
|
363
408
|
}
|
|
409
|
+
const nodes = arcAnim.keyframes?.keyframes ?? [];
|
|
410
|
+
const playheadPercentage = absoluteToPercentage(t, start, duration);
|
|
411
|
+
const timedNodeIndex = nodes.findIndex(
|
|
412
|
+
(node) => Math.abs(node.percentage - playheadPercentage) <= KEYFRAME_PCT_MATCH,
|
|
413
|
+
);
|
|
414
|
+
if (timedNodeIndex !== -1) {
|
|
415
|
+
if (timedNodeIndex > 0 && timedNodeIndex < nodes.length - 1) {
|
|
416
|
+
await session.commitMutation(
|
|
417
|
+
{
|
|
418
|
+
type: "replace-with-keyframes",
|
|
419
|
+
animationId: arcAnim.id,
|
|
420
|
+
targetSelector,
|
|
421
|
+
position: roundTo3(start),
|
|
422
|
+
duration: roundTo3(duration),
|
|
423
|
+
keyframes: nodes.filter((_, index) => index !== timedNodeIndex),
|
|
424
|
+
ease: "none",
|
|
425
|
+
},
|
|
426
|
+
{ label: "Remove keyframe", softReload: true },
|
|
427
|
+
);
|
|
428
|
+
}
|
|
429
|
+
return;
|
|
430
|
+
}
|
|
431
|
+
|
|
364
432
|
const live = readElementPosition(iframe, sel, arcAnim);
|
|
365
433
|
if (typeof live.x !== "number" || typeof live.y !== "number") return;
|
|
366
|
-
const liveX = live.x;
|
|
367
|
-
const liveY = live.y;
|
|
368
|
-
const nodes = (arcAnim.keyframes?.keyframes ?? [])
|
|
369
|
-
.map((k) => ({ x: k.properties.x, y: k.properties.y }))
|
|
370
|
-
.filter(
|
|
371
|
-
(p): p is { x: number; y: number } => typeof p.x === "number" && typeof p.y === "number",
|
|
372
|
-
);
|
|
373
|
-
// Don't duplicate a waypoint that already sits where the element is (e.g. at the
|
|
374
|
-
// path endpoints).
|
|
375
|
-
const WAYPOINT_MERGE_PX = 6;
|
|
376
|
-
if (nodes.some((n) => Math.hypot(n.x - liveX, n.y - liveY) <= WAYPOINT_MERGE_PX)) return;
|
|
377
|
-
const proj = nearestPointOnPath(liveX, liveY, nodes);
|
|
378
|
-
if (!proj) return;
|
|
379
434
|
await session.commitMutation(
|
|
380
435
|
{
|
|
381
|
-
type: "
|
|
436
|
+
type: "replace-with-keyframes",
|
|
382
437
|
animationId: arcAnim.id,
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
438
|
+
targetSelector,
|
|
439
|
+
position: roundTo3(start),
|
|
440
|
+
duration: roundTo3(duration),
|
|
441
|
+
keyframes: buildTemporalArcKeyframes(arcAnim, playheadPercentage, {
|
|
442
|
+
x: live.x,
|
|
443
|
+
y: live.y,
|
|
444
|
+
}),
|
|
445
|
+
ease: "none",
|
|
386
446
|
},
|
|
387
|
-
{ label: "Add
|
|
447
|
+
{ label: "Add keyframe", softReload: true },
|
|
388
448
|
);
|
|
389
449
|
}
|
|
390
450
|
|
|
@@ -420,7 +480,7 @@ export function useEnableKeyframes(
|
|
|
420
480
|
const flatAnim = anims.find((a) => !a.keyframes && !a.arcPath && !isInstantHold(a));
|
|
421
481
|
|
|
422
482
|
if (arcAnim) {
|
|
423
|
-
await
|
|
483
|
+
await applyArcKeyframeAtPlayhead(session, sel, arcAnim, t, iframe);
|
|
424
484
|
} else if (kfAnim) {
|
|
425
485
|
await applyKeyframeAtPlayhead(session, sel, kfAnim, t, iframe);
|
|
426
486
|
} else if (setAnim) {
|
|
@@ -13,7 +13,7 @@ import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
|
|
|
13
13
|
import type { CommitMutationOptions } from "./gsapScriptCommitTypes";
|
|
14
14
|
import { roundTo3 } from "../utils/rounding";
|
|
15
15
|
import { classifyPropertyGroup } from "@hyperframes/core/gsap-parser";
|
|
16
|
-
import { isInstantHold } from "./gsapShared";
|
|
16
|
+
import { isInstantHold, idSelector } from "./gsapShared";
|
|
17
17
|
|
|
18
18
|
type RecordedKeyframe = {
|
|
19
19
|
percentage: number;
|
|
@@ -168,7 +168,7 @@ export function useGestureCommit({
|
|
|
168
168
|
if (!sortedPcts.includes(0)) sortedPcts.unshift(0);
|
|
169
169
|
}
|
|
170
170
|
|
|
171
|
-
const selector = sel.id ?
|
|
171
|
+
const selector = sel.id ? idSelector(sel.id) : sel.selector;
|
|
172
172
|
if (!selector) {
|
|
173
173
|
showToast("Cannot save — element has no selector", "error");
|
|
174
174
|
return;
|
|
@@ -36,7 +36,7 @@ export type ElementAnimationsOutcome =
|
|
|
36
36
|
* so the caller can apply the right retry budget to each.
|
|
37
37
|
*/
|
|
38
38
|
export function selectElementAnimationsOrRetry(
|
|
39
|
-
parsed: ParsedGsap | null,
|
|
39
|
+
parsed: Pick<ParsedGsap, "animations"> | null,
|
|
40
40
|
target: { id: string | null; selector: string | null },
|
|
41
41
|
): ElementAnimationsOutcome {
|
|
42
42
|
if (!parsed) return { kind: "fetch-error" };
|
|
@@ -19,11 +19,21 @@ afterEach(() => {
|
|
|
19
19
|
|
|
20
20
|
const selection: DomEditSelection = { id: "box", selector: "#box" } as DomEditSelection;
|
|
21
21
|
|
|
22
|
+
function successfulCommitMutation() {
|
|
23
|
+
return vi.fn<(...args: unknown[]) => Promise<unknown>>(async (...args) => {
|
|
24
|
+
const options = args[2] as {
|
|
25
|
+
onResult?: (result: { ok: boolean; changed: boolean }) => void;
|
|
26
|
+
};
|
|
27
|
+
options.onResult?.({ ok: true, changed: true });
|
|
28
|
+
});
|
|
29
|
+
}
|
|
30
|
+
|
|
22
31
|
function renderKeyframeOps(over: {
|
|
23
32
|
commitMutation: (...args: unknown[]) => Promise<unknown>;
|
|
24
33
|
trackGsapSaveFailure: (...args: unknown[]) => void;
|
|
25
34
|
}) {
|
|
26
35
|
const captured: { api: HookApi | null } = { api: null };
|
|
36
|
+
// This hook harness intentionally mirrors the separate script-commit harness.
|
|
27
37
|
function Probe() {
|
|
28
38
|
// fallow-ignore-next-line code-duplication
|
|
29
39
|
captured.api = useGsapKeyframeOps({
|
|
@@ -49,11 +59,21 @@ function renderKeyframeOps(over: {
|
|
|
49
59
|
return captured.api;
|
|
50
60
|
}
|
|
51
61
|
|
|
62
|
+
async function moveKeyframeWith(
|
|
63
|
+
commitMutation: (...args: unknown[]) => Promise<unknown>,
|
|
64
|
+
): Promise<{ committed: boolean; trackGsapSaveFailure: ReturnType<typeof vi.fn> }> {
|
|
65
|
+
const trackGsapSaveFailure = vi.fn();
|
|
66
|
+
const api = renderKeyframeOps({ commitMutation, trackGsapSaveFailure });
|
|
67
|
+
let committed = true;
|
|
68
|
+
await act(async () => {
|
|
69
|
+
committed = await api.moveKeyframe(selection, "box-to-0-position", 50, 75);
|
|
70
|
+
});
|
|
71
|
+
return { committed, trackGsapSaveFailure };
|
|
72
|
+
}
|
|
73
|
+
|
|
52
74
|
describe("useGsapKeyframeOps — resizeKeyframedTween", () => {
|
|
53
75
|
it("issues a resize-keyframed-tween mutation with the remap + window", async () => {
|
|
54
|
-
const commitMutation =
|
|
55
|
-
ok: true,
|
|
56
|
-
}));
|
|
76
|
+
const commitMutation = successfulCommitMutation();
|
|
57
77
|
const trackGsapSaveFailure = vi.fn<(...args: unknown[]) => void>();
|
|
58
78
|
const api = renderKeyframeOps({ commitMutation, trackGsapSaveFailure });
|
|
59
79
|
|
|
@@ -61,8 +81,9 @@ describe("useGsapKeyframeOps — resizeKeyframedTween", () => {
|
|
|
61
81
|
{ from: 0, to: 0 },
|
|
62
82
|
{ from: 100, to: 100 },
|
|
63
83
|
];
|
|
84
|
+
let committed = false;
|
|
64
85
|
await act(async () => {
|
|
65
|
-
api.resizeKeyframedTween(selection, "box-to-0-opacity", 0.2, 2, pctRemap);
|
|
86
|
+
committed = await api.resizeKeyframedTween(selection, "box-to-0-opacity", 0.2, 2, pctRemap);
|
|
66
87
|
});
|
|
67
88
|
|
|
68
89
|
expect(commitMutation).toHaveBeenCalledTimes(1);
|
|
@@ -76,6 +97,7 @@ describe("useGsapKeyframeOps — resizeKeyframedTween", () => {
|
|
|
76
97
|
pctRemap,
|
|
77
98
|
});
|
|
78
99
|
expect(trackGsapSaveFailure).not.toHaveBeenCalled();
|
|
100
|
+
expect(committed).toBe(true);
|
|
79
101
|
});
|
|
80
102
|
|
|
81
103
|
it("routes a rejected commit to trackGsapSaveFailure (no unhandled rejection)", async () => {
|
|
@@ -86,10 +108,11 @@ describe("useGsapKeyframeOps — resizeKeyframedTween", () => {
|
|
|
86
108
|
const trackGsapSaveFailure = vi.fn<(...args: unknown[]) => void>();
|
|
87
109
|
const api = renderKeyframeOps({ commitMutation, trackGsapSaveFailure });
|
|
88
110
|
|
|
111
|
+
let committed = true;
|
|
89
112
|
await act(async () => {
|
|
90
|
-
api.resizeKeyframedTween(selection, "box-to-0-opacity", 0.2, 2, [
|
|
91
|
-
|
|
92
|
-
|
|
113
|
+
committed = await api.resizeKeyframedTween(selection, "box-to-0-opacity", 0.2, 2, [
|
|
114
|
+
{ from: 100, to: 100 },
|
|
115
|
+
]);
|
|
93
116
|
});
|
|
94
117
|
|
|
95
118
|
expect(trackGsapSaveFailure).toHaveBeenCalledTimes(1);
|
|
@@ -98,14 +121,72 @@ describe("useGsapKeyframeOps — resizeKeyframedTween", () => {
|
|
|
98
121
|
expect(selArg).toBe(selection);
|
|
99
122
|
expect((mutationArg as { type: string }).type).toBe("resize-keyframed-tween");
|
|
100
123
|
expect(labelArg).toBe("Retime keyframe (resize tween)");
|
|
124
|
+
expect(committed).toBe(false);
|
|
125
|
+
});
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
describe("useGsapKeyframeOps — moveKeyframe settlement", () => {
|
|
129
|
+
it("returns false when the commit settles without a durable writer result", async () => {
|
|
130
|
+
const { committed, trackGsapSaveFailure } = await moveKeyframeWith(vi.fn(async () => {}));
|
|
131
|
+
|
|
132
|
+
expect(committed).toBe(false);
|
|
133
|
+
expect(trackGsapSaveFailure).not.toHaveBeenCalled();
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
it("returns false when the writer accepts but does not change the keyframe", async () => {
|
|
137
|
+
const commitMutation = vi.fn(async (...args: unknown[]) => {
|
|
138
|
+
const options = args[2] as { onResult?: (result: { ok: boolean; changed: boolean }) => void };
|
|
139
|
+
options.onResult?.({ ok: true, changed: false });
|
|
140
|
+
});
|
|
141
|
+
const { committed, trackGsapSaveFailure } = await moveKeyframeWith(commitMutation);
|
|
142
|
+
|
|
143
|
+
expect(committed).toBe(false);
|
|
144
|
+
expect(trackGsapSaveFailure).not.toHaveBeenCalled();
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
it("returns false and tracks a rejected move", async () => {
|
|
148
|
+
const error = new Error("write failed");
|
|
149
|
+
const commitMutation = vi.fn().mockRejectedValue(error);
|
|
150
|
+
const { committed, trackGsapSaveFailure } = await moveKeyframeWith(commitMutation);
|
|
151
|
+
|
|
152
|
+
expect(committed).toBe(false);
|
|
153
|
+
expect(trackGsapSaveFailure).toHaveBeenCalledExactlyOnceWith(
|
|
154
|
+
error,
|
|
155
|
+
selection,
|
|
156
|
+
{
|
|
157
|
+
type: "move-keyframe",
|
|
158
|
+
animationId: "box-to-0-position",
|
|
159
|
+
fromPercentage: 50,
|
|
160
|
+
toPercentage: 75,
|
|
161
|
+
},
|
|
162
|
+
"Move keyframe to 75%",
|
|
163
|
+
);
|
|
101
164
|
});
|
|
102
165
|
});
|
|
103
166
|
|
|
104
167
|
describe("useGsapKeyframeOps — keyframe transaction options", () => {
|
|
168
|
+
it("routes a flat-lane add through the add-keyframe writer mutation", async () => {
|
|
169
|
+
const commitMutation = successfulCommitMutation();
|
|
170
|
+
const api = renderKeyframeOps({ commitMutation, trackGsapSaveFailure: vi.fn() });
|
|
171
|
+
|
|
172
|
+
await act(async () => {
|
|
173
|
+
await api.addKeyframeBatch(selection, "box-to-0-position", 50, { x: 210 });
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
expect(commitMutation).toHaveBeenCalledWith(
|
|
177
|
+
selection,
|
|
178
|
+
{
|
|
179
|
+
type: "add-keyframe",
|
|
180
|
+
animationId: "box-to-0-position",
|
|
181
|
+
percentage: 50,
|
|
182
|
+
properties: { x: 210 },
|
|
183
|
+
},
|
|
184
|
+
{ label: "Add keyframe at 50%", softReload: true },
|
|
185
|
+
);
|
|
186
|
+
});
|
|
187
|
+
|
|
105
188
|
it("soft-reloads a standalone convert when the SDK path is unavailable", async () => {
|
|
106
|
-
const commitMutation =
|
|
107
|
-
ok: true,
|
|
108
|
-
}));
|
|
189
|
+
const commitMutation = successfulCommitMutation();
|
|
109
190
|
const api = renderKeyframeOps({ commitMutation, trackGsapSaveFailure: vi.fn() });
|
|
110
191
|
|
|
111
192
|
await act(async () => {
|
|
@@ -123,9 +204,7 @@ describe("useGsapKeyframeOps — keyframe transaction options", () => {
|
|
|
123
204
|
});
|
|
124
205
|
|
|
125
206
|
it("threads one coalesce key through skipped convert reload and terminal batch edit", async () => {
|
|
126
|
-
const commitMutation =
|
|
127
|
-
ok: true,
|
|
128
|
-
}));
|
|
207
|
+
const commitMutation = successfulCommitMutation();
|
|
129
208
|
const api = renderKeyframeOps({ commitMutation, trackGsapSaveFailure: vi.fn() });
|
|
130
209
|
const coalesceKey = "enable-keyframes:box-to-0-opacity:1";
|
|
131
210
|
|