@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.
Files changed (134) hide show
  1. package/dist/assets/{hyperframes-player-BrfzYqX3.js → hyperframes-player-CEggaaxR.js} +1 -1
  2. package/dist/assets/{index-BvGKYVrH.js → index-B1INQNmj.js} +1 -1
  3. package/dist/assets/{index-CjGZkNxS.js → index-B1Tjjdse.js} +1 -1
  4. package/dist/assets/index-Bp4jAYZG.js +428 -0
  5. package/dist/assets/index-CBAfprvx.css +1 -0
  6. package/dist/index.d.ts +64 -17
  7. package/dist/index.html +2 -2
  8. package/dist/index.js +18040 -16293
  9. package/dist/index.js.map +1 -1
  10. package/package.json +7 -7
  11. package/src/App.tsx +16 -17
  12. package/src/components/EditorShell.tsx +3 -0
  13. package/src/components/TimelineToolbar.test.tsx +48 -3
  14. package/src/components/TimelineToolbar.tsx +89 -36
  15. package/src/components/editor/AnimationCard.test.tsx +18 -75
  16. package/src/components/editor/AnimationCard.tsx +26 -2
  17. package/src/components/editor/GsapAddAnimationControl.tsx +68 -0
  18. package/src/components/editor/GsapAnimationSection.tsx +18 -157
  19. package/src/components/editor/KeyframeEaseList.tsx +5 -1
  20. package/src/components/editor/KeyframeNavigation.tsx +46 -14
  21. package/src/components/editor/KeyframeNavigationDiamond.test.tsx +59 -0
  22. package/src/components/editor/MotionPathOverlay.tsx +32 -10
  23. package/src/components/editor/PropertyPanel.tsx +15 -5
  24. package/src/components/editor/PropertyPanelFlat.tsx +48 -23
  25. package/src/components/editor/gsapAnimationCallbacks.test.ts +122 -0
  26. package/src/components/editor/gsapAnimationCallbacks.ts +108 -4
  27. package/src/components/editor/keyframeRetime.test.ts +25 -22
  28. package/src/components/editor/keyframeRetime.ts +1 -2
  29. package/src/components/editor/propertyPanelFlatLayoutSection.tsx +2 -2
  30. package/src/components/editor/propertyPanelFlatMotionSection.tsx +25 -139
  31. package/src/components/editor/propertyPanelFlatTextSection.test.tsx +32 -16
  32. package/src/components/editor/propertyPanelFlatTextSection.tsx +21 -4
  33. package/src/components/nle/PreviewOverlays.tsx +3 -1
  34. package/src/components/nle/useTimelineEditCallbacks.test.ts +30 -0
  35. package/src/components/nle/useTimelineEditCallbacks.test.tsx +693 -0
  36. package/src/components/nle/useTimelineEditCallbacks.ts +197 -57
  37. package/src/contexts/TimelineEditContext.tsx +1 -1
  38. package/src/hooks/deleteSelectedKeyframes.ts +24 -8
  39. package/src/hooks/gsapDragCommit.ts +2 -2
  40. package/src/hooks/gsapDragPositionCommit.ts +84 -5
  41. package/src/hooks/gsapKeyframeCacheHelpers.test.ts +176 -1
  42. package/src/hooks/gsapKeyframeCacheHelpers.ts +93 -52
  43. package/src/hooks/gsapRuntimeBridge.test.ts +100 -0
  44. package/src/hooks/gsapScriptCommitHelpers.ts +3 -2
  45. package/src/hooks/gsapScriptCommitTypes.ts +2 -0
  46. package/src/hooks/gsapShared.test.ts +109 -1
  47. package/src/hooks/gsapShared.ts +142 -17
  48. package/src/hooks/gsapTweenSynth.test.ts +30 -1
  49. package/src/hooks/gsapTweenSynth.ts +61 -14
  50. package/src/hooks/keyframeCacheAstLoad.ts +181 -0
  51. package/src/hooks/timelineEditingHelpers.test.ts +80 -0
  52. package/src/hooks/timelineMoveAdapter.ts +9 -2
  53. package/src/hooks/useDomEditWiring.ts +2 -2
  54. package/src/hooks/useDomSelection.ts +4 -0
  55. package/src/hooks/useEnableKeyframes.test.ts +122 -0
  56. package/src/hooks/useEnableKeyframes.ts +116 -56
  57. package/src/hooks/useGestureCommit.ts +2 -2
  58. package/src/hooks/useGsapAnimationFetchFallback.ts +1 -1
  59. package/src/hooks/useGsapKeyframeOps.test.tsx +92 -13
  60. package/src/hooks/useGsapKeyframeOps.ts +30 -13
  61. package/src/hooks/useGsapScriptCommits.test.tsx +21 -0
  62. package/src/hooks/useGsapScriptCommits.ts +2 -0
  63. package/src/hooks/useGsapSelectionHandlers.test.tsx +69 -3
  64. package/src/hooks/useGsapSelectionHandlers.ts +90 -65
  65. package/src/hooks/useGsapTweenCache.test.ts +16 -0
  66. package/src/hooks/useGsapTweenCache.ts +64 -178
  67. package/src/hooks/usePopulateKeyframeCacheForFile.test.tsx +68 -0
  68. package/src/hooks/useStudioContextValue.test.ts +93 -0
  69. package/src/hooks/useStudioContextValue.ts +17 -3
  70. package/src/hooks/useStudioTestHooks.ts +63 -0
  71. package/src/hooks/useTimelineEditing.test.tsx +187 -22
  72. package/src/hooks/useTimelineEditing.ts +43 -32
  73. package/src/hooks/useTimelineEditingTypes.ts +2 -0
  74. package/src/hooks/useTimelineGroupEditing.ts +106 -82
  75. package/src/player/components/KeyframeDiamondContextMenu.test.tsx +69 -0
  76. package/src/player/components/KeyframeDiamondContextMenu.tsx +19 -8
  77. package/src/player/components/LayerDisclosureRow.tsx +76 -0
  78. package/src/player/components/Timeline.test.ts +420 -90
  79. package/src/player/components/Timeline.tsx +80 -78
  80. package/src/player/components/TimelineCanvas.tsx +33 -21
  81. package/src/player/components/TimelineClip.tsx +3 -1
  82. package/src/player/components/TimelineClipDiamonds.test.tsx +540 -16
  83. package/src/player/components/TimelineClipDiamonds.tsx +320 -117
  84. package/src/player/components/TimelineDiamondConnectors.tsx +145 -0
  85. package/src/player/components/TimelineLanes.tsx +153 -79
  86. package/src/player/components/TimelineOverlays.tsx +4 -18
  87. package/src/player/components/TimelinePropertyLanes.test.tsx +450 -0
  88. package/src/player/components/TimelinePropertyLanes.tsx +191 -0
  89. package/src/player/components/TimelineRazorInteraction.tsx +60 -0
  90. package/src/player/components/TimelineRuler.tsx +11 -21
  91. package/src/player/components/TimelineTrackHeader.test.tsx +346 -0
  92. package/src/player/components/TimelineTrackHeader.tsx +363 -0
  93. package/src/player/components/TrackClipCount.tsx +17 -0
  94. package/src/player/components/timelineCallbacks.ts +21 -6
  95. package/src/player/components/timelineClipDragPreview.test.ts +50 -3
  96. package/src/player/components/timelineClipDragPreview.ts +33 -15
  97. package/src/player/components/timelineCollision.ts +4 -13
  98. package/src/player/components/timelineDiamondTypes.ts +120 -0
  99. package/src/player/components/timelineDragDrop.ts +8 -3
  100. package/src/player/components/timelineEditing.test.ts +19 -27
  101. package/src/player/components/timelineEditing.ts +7 -8
  102. package/src/player/components/timelineKeyframeIdentity.test.ts +49 -0
  103. package/src/player/components/timelineKeyframeIdentity.ts +66 -0
  104. package/src/player/components/timelineLayout.test.ts +162 -9
  105. package/src/player/components/timelineLayout.ts +198 -38
  106. package/src/player/components/timelineMarquee.test.ts +76 -21
  107. package/src/player/components/timelineMarquee.ts +17 -14
  108. package/src/player/components/trackHeaderLaneState.ts +121 -0
  109. package/src/player/components/trackHeaderLaneValues.ts +139 -0
  110. package/src/player/components/useAutoExpandKeyframedClips.test.tsx +75 -0
  111. package/src/player/components/useAutoExpandKeyframedClips.ts +50 -0
  112. package/src/player/components/useTimelineClipDrag.ts +4 -1
  113. package/src/player/components/useTimelineGeometry.ts +4 -1
  114. package/src/player/components/useTimelineKeyframeHandlers.test.tsx +108 -0
  115. package/src/player/components/useTimelineKeyframeHandlers.ts +50 -15
  116. package/src/player/components/useTimelinePlayhead.ts +21 -12
  117. package/src/player/components/useTimelineRangeSelection.ts +35 -14
  118. package/src/player/components/useTimelineTrackLayout.test.ts +53 -0
  119. package/src/player/components/useTimelineTrackLayout.ts +162 -0
  120. package/src/player/hooks/useExpandedTimelineElements.test.ts +43 -0
  121. package/src/player/hooks/useExpandedTimelineElements.ts +21 -12
  122. package/src/player/lib/timelineElementHelpers.ts +26 -11
  123. package/src/player/store/keyframeSlice.ts +122 -0
  124. package/src/player/store/playerStore.test.ts +25 -0
  125. package/src/player/store/playerStore.ts +7 -43
  126. package/src/telemetry/events.test.ts +9 -3
  127. package/src/telemetry/events.ts +16 -7
  128. package/src/utils/globalTimeCompiler.test.ts +10 -0
  129. package/src/utils/globalTimeCompiler.ts +3 -3
  130. package/dist/assets/index-B37rWXo4.css +0 -1
  131. package/dist/assets/index-DzDajONI.js +0 -428
  132. package/src/player/components/TimelineDragGhost.tsx +0 -59
  133. package/src/utils/keyframeSelection.test.ts +0 -45
  134. 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(anim: GsapAnimation, currentTime: number): boolean {
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
- return isTimeWithinTween(currentTime, start, resolveTweenDuration(anim));
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 = resolveTweenDuration(anim);
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
- if (!isPlayheadWithinTween(kfAnim, t)) {
241
- const position = readElementPosition(iframe, sel, kfAnim);
242
- const selector = selectorFromSelection(sel);
243
- if (selector && Object.keys(position).length > 0 && session.commitMutation) {
244
- const extended = buildExtendedKeyframes(kfAnim, t, position);
245
- await session.commitMutation(
246
- {
247
- type: "replace-with-keyframes",
248
- animationId: kfAnim.id,
249
- targetSelector: selector,
250
- position: extended.position,
251
- duration: extended.duration,
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 = computeElementPercentage(t, sel, kfAnim);
265
- const existing = kfAnim.keyframes?.keyframes.find((k) => Math.abs(k.percentage - pct) <= 1);
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
- * An arc (motionPath) tween its waypoints are reconstructed onto `keyframes`, so
336
- * it must be edited as waypoints (not x/y keyframes, which would break the curve).
337
- * "Add keyframe at playhead" drops a waypoint where the element currently sits on
338
- * the path, inserted at the matching segment so the curve is preserved. Outside the
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 applyArcWaypointAtPlayhead(
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
- if (!isPlayheadWithinTween(arcAnim, t)) {
351
- const start = resolveTweenStart(arcAnim) ?? 0;
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: "add-motion-path-point",
436
+ type: "replace-with-keyframes",
382
437
  animationId: arcAnim.id,
383
- index: proj.segIndex + 1,
384
- x: liveX,
385
- y: liveY,
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 waypoint", softReload: true },
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 applyArcWaypointAtPlayhead(session, sel, arcAnim, t, iframe);
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 ? `#${sel.id}` : sel.selector;
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 = vi.fn<(...args: unknown[]) => Promise<unknown>>(async () => ({
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, [{ from: 100, to: 100 }]);
91
- // let the rejected commit promise settle inside act
92
- await Promise.resolve();
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 = vi.fn<(...args: unknown[]) => Promise<unknown>>(async () => ({
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 = vi.fn<(...args: unknown[]) => Promise<unknown>>(async () => ({
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