@hyperframes/studio 0.7.79 → 0.7.80

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 (50) hide show
  1. package/dist/assets/{hyperframes-player-mFah2TZE.js → hyperframes-player-Csai0_vh.js} +1 -1
  2. package/dist/assets/{index-Bqj3h_1a.js → index-B5aYTg-4.js} +1 -1
  3. package/dist/assets/{index-DlZMDyYs.js → index-BhdJN49y.js} +200 -200
  4. package/dist/assets/{index-hmnoiSEV.js → index-By-YiRxq.js} +1 -1
  5. package/dist/assets/index-D379YOKT.css +1 -0
  6. package/dist/index.d.ts +45 -3
  7. package/dist/index.html +2 -2
  8. package/dist/index.js +652 -465
  9. package/dist/index.js.map +1 -1
  10. package/package.json +7 -7
  11. package/src/components/StudioRightPanel.tsx +2 -1
  12. package/src/components/editor/AnimationCard.test.tsx +225 -18
  13. package/src/components/editor/AnimationCard.tsx +24 -3
  14. package/src/components/editor/EaseCurveSection.test.tsx +147 -2
  15. package/src/components/editor/EaseCurveSection.tsx +77 -15
  16. package/src/components/editor/GsapAnimationSection.test.tsx +102 -0
  17. package/src/components/editor/GsapAnimationSection.tsx +6 -1
  18. package/src/components/editor/KeyframeEaseList.tsx +4 -0
  19. package/src/components/editor/MotionPathOverlay.tsx +54 -19
  20. package/src/components/editor/PropertyPanel.tsx +4 -0
  21. package/src/components/editor/PropertyPanelFlat.tsx +6 -91
  22. package/src/components/editor/gsapAnimationCallbacks.test.ts +8 -1
  23. package/src/components/editor/gsapAnimationCallbacks.ts +8 -0
  24. package/src/components/editor/holdEaseSeek.test.ts +37 -0
  25. package/src/components/editor/propertyPanelFlatMotionSection.test.tsx +69 -0
  26. package/src/components/editor/propertyPanelFlatMotionSection.tsx +2 -1
  27. package/src/components/editor/propertyPanelFlatProps.ts +91 -0
  28. package/src/components/editor/propertyPanelTypes.ts +2 -0
  29. package/src/contexts/DomEditContext.tsx +4 -0
  30. package/src/hooks/gsapKeyframeCacheHelpers.test.ts +132 -43
  31. package/src/hooks/gsapKeyframeCacheHelpers.ts +150 -50
  32. package/src/hooks/gsapTweenSynth.test.ts +70 -15
  33. package/src/hooks/gsapTweenSynth.ts +50 -23
  34. package/src/hooks/keyframeCacheAstLoad.ts +4 -12
  35. package/src/hooks/useDomEditSession.test.tsx +152 -3
  36. package/src/hooks/useDomEditSession.ts +4 -53
  37. package/src/hooks/useGsapTweenCache.ts +40 -34
  38. package/src/hooks/useKeyframeEaseCommits.ts +78 -0
  39. package/src/player/components/KeyframeDiamondContextMenu.test.tsx +21 -0
  40. package/src/player/components/KeyframeDiamondContextMenu.tsx +19 -12
  41. package/src/player/components/TimelineClipDiamonds.test.tsx +18 -6
  42. package/src/player/components/TimelineDiamondConnectors.tsx +118 -85
  43. package/src/player/components/timelineDiamondTypes.ts +4 -3
  44. package/src/player/components/timelineKeyframeIdentity.ts +3 -0
  45. package/src/player/components/useTimelineKeyframeHandlers.test.tsx +52 -32
  46. package/src/player/components/useTimelineKeyframeHandlers.ts +1 -0
  47. package/src/player/hooks/useExpandedTimelineElements.test.ts +29 -0
  48. package/src/player/hooks/useExpandedTimelineElements.ts +24 -0
  49. package/src/player/store/keyframeSlice.ts +15 -5
  50. package/dist/assets/index-gGVKuFg5.css +0 -1
@@ -10,6 +10,7 @@ import { holdCurvePath, MiniCurveSvg, sampledPath } from "./easeCurveSvg";
10
10
  import { EaseBezierField, SpringBounceField, WiggleField } from "./EaseParamFields";
11
11
  import { EASE_CURVES, EASE_LABELS, resolveEaseCurveTuple } from "./gsapAnimationConstants";
12
12
  import { roundToCenti } from "../../utils/rounding";
13
+ import type { AnimationKeyframeTarget } from "../../hooks/gsapTweenSynth";
13
14
 
14
15
  export { MiniCurveSvg } from "./easeCurveSvg";
15
16
 
@@ -320,19 +321,36 @@ function EaseParameterField({
320
321
  return <EaseBezierField tuple={tuple} onCommit={onCommit} />;
321
322
  }
322
323
 
324
+ /**
325
+ * How long an optimistically painted ease may outlive its commit. Long enough
326
+ * for a normal write-reparse-rerender round trip, short enough that a dropped
327
+ * write self-corrects while the author is still looking at the panel.
328
+ */
329
+ const PENDING_EASE_TIMEOUT_MS = 2000;
330
+
323
331
  export function EaseCurveSection({
324
332
  ease,
325
333
  onCustomEaseCommit,
334
+ collidingAnimationTargets,
326
335
  }: {
327
336
  ease: string;
328
337
  onCustomEaseCommit: (ease: string) => void;
338
+ collidingAnimationTargets?: AnimationKeyframeTarget[];
329
339
  }) {
330
- const springBounce = parseSpringBounce(ease);
340
+ // The ease this section painted optimistically, still waiting for its commit
341
+ // to round-trip back through the `ease` prop.
342
+ const [pendingEase, setPendingEase] = useState<string | null>(null);
343
+ // Every value committed and not yet seen coming back, oldest first. It takes
344
+ // the whole queue, not just the latest, to tell an older commit echoing back
345
+ // apart from an edit made somewhere else.
346
+ const inFlightEasesRef = useRef<string[]>([]);
347
+ const displayedEase = pendingEase ?? ease;
348
+ const springBounce = parseSpringBounce(displayedEase);
331
349
  const isSpring = springBounce !== null;
332
- const wiggleConfig = parseWiggleEase(ease);
350
+ const wiggleConfig = parseWiggleEase(displayedEase);
333
351
  const isWiggle = wiggleConfig !== null;
334
352
  const mode: EaseMode = isSpring ? "spring" : isWiggle ? "wiggle" : "curve";
335
- const curve = resolveEditableCurve(ease, springBounce);
353
+ const curve = resolveEditableCurve(displayedEase, springBounce);
336
354
 
337
355
  const [draft, setDraft] = useState<Pts | null>(null);
338
356
  const [hover, setHover] = useState<"p1" | "p2" | null>(null);
@@ -346,8 +364,43 @@ export function EaseCurveSection({
346
364
  // `ease` changes, `curve` already equals the draft, so the handoff is seamless.
347
365
  useEffect(() => {
348
366
  setDraft(null);
367
+ const inFlight = inFlightEasesRef.current;
368
+ const landed = inFlight.indexOf(ease);
369
+ if (landed < 0) {
370
+ // A value this section never sent: someone else edited the ease, so the
371
+ // real value wins over anything optimistic still on screen.
372
+ inFlight.length = 0;
373
+ setPendingEase(null);
374
+ return;
375
+ }
376
+ // One of this section's own commits came back. Everything sent before it
377
+ // is settled with it, but a NEWER commit may still be in flight, and
378
+ // repainting this older value while waiting for that one is the
379
+ // wiggle-then-spring-then-wiggle flicker of a fast double switch.
380
+ inFlight.splice(0, landed + 1);
381
+ if (inFlight.length === 0) setPendingEase(null);
349
382
  }, [ease]);
350
383
 
384
+ // A commit is fire-and-forget, so a write that is rejected or lands as a
385
+ // no-op never changes `ease`, and the optimistic value would sit on screen
386
+ // claiming a curve the composition does not have. Nothing downstream reports
387
+ // that failure, so the display is time-bounded instead: fall back to the
388
+ // committed truth when the round trip does not arrive.
389
+ useEffect(() => {
390
+ if (pendingEase === null) return;
391
+ const timer = setTimeout(() => {
392
+ inFlightEasesRef.current.length = 0;
393
+ setPendingEase(null);
394
+ }, PENDING_EASE_TIMEOUT_MS);
395
+ return () => clearTimeout(timer);
396
+ }, [pendingEase]);
397
+
398
+ const commitEase = (nextEase: string) => {
399
+ inFlightEasesRef.current.push(nextEase);
400
+ setPendingEase(nextEase);
401
+ onCustomEaseCommit(nextEase);
402
+ };
403
+
351
404
  const activeTuple = draft ?? curve;
352
405
  const displayTuple = activeTuple ?? DEFAULT_CURVE;
353
406
  const [x1, y1, x2, y2] = displayTuple;
@@ -358,8 +411,12 @@ export function EaseCurveSection({
358
411
  const a1 = { x: xToSvg(1), y: yToSvg(1) };
359
412
  const p1 = { x: xToSvg(x1), y: yToSvg(clampView(y1)) };
360
413
  const p2 = { x: xToSvg(x2), y: yToSvg(clampView(y2)) };
361
- const curvePath = curvePathFor(ease, springBounce, wiggleConfig, displayTuple);
362
- const showGraph = activeTuple !== null || isWiggle || ease === "hold";
414
+ // Read the OPTIMISTIC ease everywhere the graph is derived, so a mode switch
415
+ // paints immediately instead of waiting for the committed prop to come back.
416
+ const curvePath = curvePathFor(displayedEase, springBounce, wiggleConfig, displayTuple);
417
+ const showGraph = activeTuple !== null || isWiggle || displayedEase === "hold";
418
+ // `curve !== null` is what keeps Hold handle-free: it draws a graph (a flat
419
+ // step) but has no editable control points to drag.
363
420
  const showHandles = curve !== null && !isSpring && !isWiggle;
364
421
 
365
422
  const handlePointerDown = (handle: "p1" | "p2", e: React.PointerEvent) => {
@@ -394,10 +451,9 @@ export function EaseCurveSection({
394
451
  if (!draggingRef.current || !draft) return;
395
452
  draggingRef.current = null;
396
453
  const path = `M0,0 C${draft[0]},${draft[1]} ${draft[2]},${draft[3]} 1,1`;
397
- // Clear after the synchronous parent commit settles. This also clears a
398
- // same-string commit, where the `ease` dependency effect would not run.
399
- onCustomEaseCommit(`custom(${path})`);
400
- queueMicrotask(() => setDraft(null));
454
+ // Commit only the draft stays on screen and is cleared by the effect above
455
+ // once the committed `ease` prop comes back, so the curve never flickers.
456
+ commitEase(`custom(${path})`);
401
457
  };
402
458
 
403
459
  const handleKeyDown = (handle: "p1" | "p2", event: React.KeyboardEvent<SVGCircleElement>) => {
@@ -406,20 +462,26 @@ export function EaseCurveSection({
406
462
  event.preventDefault();
407
463
  event.stopPropagation();
408
464
  setDraft(next);
409
- onCustomEaseCommit(`custom(M0,0 C${next[0]},${next[1]} ${next[2]},${next[3]} 1,1)`);
410
- queueMicrotask(() => setDraft(null));
465
+ // Same no-flicker contract as the pointer path: commit and let the effect
466
+ // clear the draft, rather than dropping it on the next microtask.
467
+ commitEase(`custom(M0,0 C${next[0]},${next[1]} ${next[2]},${next[3]} 1,1)`);
411
468
  };
412
469
 
413
470
  const top = yToSvg(1);
414
471
  const bottom = yToSvg(0);
415
472
  const left = xToSvg(0);
416
473
  const right = xToSvg(1);
417
- const label = resolveEditorLabel(ease, springBounce, isWiggle);
474
+ const label = resolveEditorLabel(displayedEase, springBounce, isWiggle);
418
475
 
419
476
  return (
420
477
  <div className="rounded-lg bg-neutral-900/50 p-2">
421
- <EaseTypeDropdown kind={mode} ease={ease} label={label} onSelect={onCustomEaseCommit} />
422
- <EaseModeToggle mode={mode} onCommit={onCustomEaseCommit} />
478
+ <EaseTypeDropdown kind={mode} ease={displayedEase} label={label} onSelect={commitEase} />
479
+ {collidingAnimationTargets && collidingAnimationTargets.length > 1 && (
480
+ <p className="mb-1 text-[9px] text-neutral-500">
481
+ Applies to {collidingAnimationTargets.length} animations
482
+ </p>
483
+ )}
484
+ <EaseModeToggle mode={mode} onCommit={commitEase} />
423
485
  <span className="sr-only" aria-live="polite">
424
486
  {MODE_LABELS[mode]} ease editor selected
425
487
  </span>
@@ -560,7 +622,7 @@ export function EaseCurveSection({
560
622
  springBounce={springBounce}
561
623
  wiggleConfig={wiggleConfig}
562
624
  tuple={displayTuple}
563
- onCommit={onCustomEaseCommit}
625
+ onCommit={commitEase}
564
626
  />
565
627
  </>
566
628
  ) : (
@@ -0,0 +1,102 @@
1
+ // @vitest-environment happy-dom
2
+
3
+ import React, { act } from "react";
4
+ import { createRoot } from "react-dom/client";
5
+ import { afterEach, describe, expect, it, vi } from "vitest";
6
+ import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
7
+ import { DesignPanelInputProvider } from "../../contexts/DesignPanelInputContext";
8
+ import { usePlayerStore } from "../../player";
9
+ import { GsapAnimationSection } from "./GsapAnimationSection";
10
+
11
+ (globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
12
+
13
+ vi.mock("./AnimationCard", () => ({
14
+ AnimationCard: ({
15
+ animation,
16
+ focusedSegment,
17
+ onFocusSegmentConsumed,
18
+ }: {
19
+ animation: GsapAnimation;
20
+ focusedSegment: { tweenPercentage: number } | null;
21
+ onFocusSegmentConsumed: () => void;
22
+ }) => (
23
+ <button
24
+ type="button"
25
+ data-testid={`animation-${animation.id}`}
26
+ data-focused={focusedSegment ? String(focusedSegment.tweenPercentage) : ""}
27
+ // Mirrors the real card: the consume callback only fires from the effect
28
+ // that runs when this card actually received a focusedSegment.
29
+ onClick={() => focusedSegment && onFocusSegmentConsumed()}
30
+ />
31
+ ),
32
+ }));
33
+
34
+ vi.mock("./GsapAddAnimationControl", () => ({ GsapAddAnimationControl: () => null }));
35
+
36
+ afterEach(() => {
37
+ document.body.innerHTML = "";
38
+ usePlayerStore.getState().reset();
39
+ });
40
+
41
+ const sharedAnimation: GsapAnimation = {
42
+ id: "shared-animation",
43
+ targetSelector: ".shared",
44
+ method: "to",
45
+ position: 0,
46
+ properties: { x: 100 },
47
+ };
48
+
49
+ const requiredCallbacks = {
50
+ onAddAnimation: vi.fn(),
51
+ onUpdateProperty: vi.fn(),
52
+ onUpdateMeta: vi.fn(),
53
+ onDeleteAnimation: vi.fn(),
54
+ onAddProperty: vi.fn(),
55
+ onRemoveProperty: vi.fn(),
56
+ };
57
+
58
+ function renderSection(elementId: string) {
59
+ const host = document.createElement("div");
60
+ document.body.append(host);
61
+ const root = createRoot(host);
62
+ const render = (nextElementId: string) => {
63
+ act(() => {
64
+ root.render(
65
+ <DesignPanelInputProvider section="test">
66
+ <GsapAnimationSection
67
+ {...requiredCallbacks}
68
+ elementId={nextElementId}
69
+ animations={[sharedAnimation]}
70
+ />
71
+ </DesignPanelInputProvider>,
72
+ );
73
+ });
74
+ };
75
+ render(elementId);
76
+ return { host, root, render };
77
+ }
78
+
79
+ describe("GsapAnimationSection", () => {
80
+ it("consumes a shared animation id only for the focused element", () => {
81
+ usePlayerStore.getState().setFocusedEaseSegment({
82
+ elementId: "index.html#second",
83
+ animationId: sharedAnimation.id,
84
+ tweenPercentage: 50,
85
+ });
86
+ const view = renderSection("index.html#first");
87
+ const card = view.host.querySelector<HTMLButtonElement>(
88
+ "[data-testid='animation-shared-animation']",
89
+ );
90
+ if (!card) throw new Error("expected animation card");
91
+
92
+ expect(card.dataset.focused).toBe("");
93
+ act(() => card.click());
94
+ expect(usePlayerStore.getState().focusedEaseSegment?.elementId).toBe("index.html#second");
95
+
96
+ view.render("index.html#second");
97
+ expect(card.dataset.focused).toBe("50");
98
+ act(() => card.click());
99
+ expect(usePlayerStore.getState().focusedEaseSegment).toBeNull();
100
+ act(() => view.root.unmount());
101
+ });
102
+ });
@@ -13,6 +13,7 @@ import { usePlayerStore } from "../../player";
13
13
  import { GsapAddAnimationControl } from "./GsapAddAnimationControl";
14
14
 
15
15
  interface GsapAnimationSectionProps extends GsapAnimationEditCallbacks {
16
+ elementId: string;
16
17
  animations: GsapAnimation[];
17
18
  multipleTimelines?: boolean;
18
19
  unsupportedTimelinePattern?: boolean;
@@ -21,6 +22,7 @@ interface GsapAnimationSectionProps extends GsapAnimationEditCallbacks {
21
22
 
22
23
  export const GsapAnimationSection = memo(function GsapAnimationSection({
23
24
  animations,
25
+ elementId,
24
26
  multipleTimelines,
25
27
  unsupportedTimelinePattern,
26
28
  onAddAnimation,
@@ -55,7 +57,10 @@ export const GsapAnimationSection = memo(function GsapAnimationSection({
55
57
  animation={anim}
56
58
  defaultExpanded={index === 0}
57
59
  focusedSegment={
58
- focusedEaseSegment?.animationId === anim.id ? focusedEaseSegment : null
60
+ focusedEaseSegment?.elementId === elementId &&
61
+ focusedEaseSegment.animationId === anim.id
62
+ ? focusedEaseSegment
63
+ : null
59
64
  }
60
65
  onFocusSegmentConsumed={clearFocusedEaseSegment}
61
66
  />
@@ -1,6 +1,7 @@
1
1
  import type { GsapPercentageKeyframe } from "@hyperframes/core/gsap-parser";
2
2
  import { EASE_LABELS } from "./gsapAnimationConstants";
3
3
  import { EaseCurveSection } from "./EaseCurveSection";
4
+ import type { AnimationKeyframeTarget } from "../../hooks/gsapTweenSynth";
4
5
 
5
6
  // The full GSAP easing vocabulary offered by the "Set all…" bulk control —
6
7
  // every standard family in in/out/inOut, so authors aren't limited to a curated
@@ -44,6 +45,7 @@ export function KeyframeEaseList({
44
45
  keyframes,
45
46
  globalEase,
46
47
  expandedPct,
48
+ collidingAnimationTargets,
47
49
  onToggle,
48
50
  onEaseCommit,
49
51
  onApplyAll,
@@ -51,6 +53,7 @@ export function KeyframeEaseList({
51
53
  keyframes: GsapPercentageKeyframe[];
52
54
  globalEase: string;
53
55
  expandedPct: number | null;
56
+ collidingAnimationTargets?: AnimationKeyframeTarget[];
54
57
  onToggle: (pct: number | null) => void;
55
58
  onEaseCommit: (pct: number, ease: string) => void;
56
59
  /** Apply one ease to every segment at once (clears per-segment overrides). */
@@ -119,6 +122,7 @@ export function KeyframeEaseList({
119
122
  <div className="px-2 pb-2">
120
123
  <EaseCurveSection
121
124
  ease={segEase}
125
+ collidingAnimationTargets={collidingAnimationTargets}
122
126
  onCustomEaseCommit={(ease) => onEaseCommit(kf.percentage, ease)}
123
127
  />
124
128
  </div>
@@ -1,3 +1,4 @@
1
+ import { scopedElementKey } from "../../hooks/gsapKeyframeCacheHelpers";
1
2
  import { memo, useEffect, useRef, useState, type RefObject } from "react";
2
3
  import type { DomEditSelection } from "./domEditing";
3
4
  import { useDomEditContext } from "../../contexts/DomEditContext";
@@ -11,6 +12,7 @@ import {
11
12
  KeyframeDiamondContextMenu,
12
13
  type KeyframeDiamondContextMenuState,
13
14
  } from "../../player/components/KeyframeDiamondContextMenu";
15
+ import type { TimelineKeyframeTarget } from "../../player/components/timelineKeyframeIdentity";
14
16
  import {
15
17
  commitAddKeyframe,
16
18
  commitAddWaypoint,
@@ -95,15 +97,19 @@ export const MotionPathOverlay = memo(function MotionPathOverlay({
95
97
  const [draft, setDraft] = useState<Draft | null>(null);
96
98
  const [ghost, setGhost] = useState<{ x: number; y: number; segIndex: number } | null>(null);
97
99
  const [hoverNode, setHoverNode] = useState<number | null>(null);
98
- // Right-click context menu on a keyframe node — same delete actions as the
99
- // timeline keyframe diamond.
100
- const [kfMenu, setKfMenu] = useState<KeyframeDiamondContextMenuState | null>(null);
100
+ // Right-click context menu on a path node — same delete actions as the
101
+ // timeline keyframe diamond. The node it was opened on rides along, because
102
+ // which entries apply depends on whether it is a keyframe or a waypoint.
103
+ const [kfMenu, setKfMenu] = useState<{
104
+ state: KeyframeDiamondContextMenuState;
105
+ ref: MotionNodeRef;
106
+ } | null>(null);
101
107
  // The keyframe % selected by clicking its node — highlighted, and the next drag
102
108
  // modifies it rather than adding a keyframe.
103
109
  const activeKeyframePct = usePlayerStore((s) => s.activeKeyframePct);
104
110
  const timelineElement = usePlayerStore((state) => {
105
111
  if (!selection) return undefined;
106
- const sourceScopedId = `${selection.sourceFile || "index.html"}#${selection.id}`;
112
+ const sourceScopedId = scopedElementKey(selection);
107
113
  return state.elements.find(
108
114
  (element) => (element.key ?? element.id) === sourceScopedId || element.id === selection.id,
109
115
  );
@@ -435,21 +441,47 @@ export const MotionPathOverlay = memo(function MotionPathOverlay({
435
441
  };
436
442
 
437
443
  const elementId = selection?.id ?? null;
438
- // Right-click a keyframe node → the timeline's keyframe context menu (delete
439
- // this keyframe / delete all), so motion-path keyframes are removable in place.
444
+ // Right-click any path node → the timeline's keyframe context menu (delete this
445
+ // one / delete all), so path nodes are removable in place. Waypoints open it
446
+ // too: returning early for them let the browser's own context menu open over
447
+ // the editor overlay, which is never what a right-click on a node should do.
440
448
  const onNodeContextMenu = (e: React.MouseEvent, ref: MotionNodeRef) => {
441
- if (ref.type !== "keyframe" || !animId || !elementId || !timelineElement) return;
449
+ if (!animId || !elementId || !timelineElement) return;
442
450
  e.preventDefault();
443
451
  e.stopPropagation();
444
452
  setKfMenu({
445
- x: e.clientX,
446
- y: e.clientY,
447
- element: timelineElement,
448
- elementId,
449
- percentage: ref.pct,
450
- tweenPercentage: ref.pct,
453
+ ref,
454
+ state: {
455
+ x: e.clientX,
456
+ y: e.clientY,
457
+ element: timelineElement,
458
+ elementId,
459
+ // A waypoint carries no percentage of its own: one tween-level ease owns
460
+ // every segment, and its index is the identity the delete acts on. The
461
+ // menu never reads this for a waypoint, because the only entry that
462
+ // would (Move to Playhead) is hidden below.
463
+ percentage: ref.type === "keyframe" ? ref.pct : 0,
464
+ tweenPercentage: ref.type === "keyframe" ? ref.pct : 0,
465
+ },
451
466
  });
452
467
  };
468
+ const menuRef = kfMenu?.ref;
469
+ // Deleting one node: by tween-% for a keyframe, by path index for a waypoint.
470
+ // A waypoint delete is offered on the same condition as the hover x badge,
471
+ // because removeMotionPathPointInScript refuses to drop an arc below two
472
+ // anchors and the entry would silently do nothing.
473
+ const onMenuDelete =
474
+ menuRef === undefined || !animId
475
+ ? undefined
476
+ : menuRef.type === "keyframe"
477
+ ? (_elId: string, keyframe: TimelineKeyframeTarget) => {
478
+ handleGsapRemoveKeyframe(animId, keyframe.percentage);
479
+ }
480
+ : removable
481
+ ? () => {
482
+ void commitRemoveWaypoint(animId, menuRef.index, commitMutation);
483
+ }
484
+ : undefined;
453
485
 
454
486
  return (
455
487
  <>
@@ -531,14 +563,17 @@ export const MotionPathOverlay = memo(function MotionPathOverlay({
531
563
  </svg>
532
564
  {kfMenu && (
533
565
  <KeyframeDiamondContextMenu
534
- state={kfMenu}
566
+ state={kfMenu.state}
535
567
  onClose={() => setKfMenu(null)}
536
- onDelete={(_elId, keyframe) =>
537
- animId && handleGsapRemoveKeyframe(animId, keyframe.percentage)
538
- }
568
+ onDelete={onMenuDelete}
539
569
  onDeleteAll={() => animId && handleGsapRemoveAllKeyframes(animId)}
540
- onMoveToPlayhead={(_element, keyframe) =>
541
- animId && handleGsapMoveKeyframeToPlayhead(animId, keyframe.percentage)
570
+ // Retiming needs a percentage of this node's own, which a waypoint
571
+ // does not have.
572
+ onMoveToPlayhead={
573
+ kfMenu.ref.type === "keyframe"
574
+ ? (_element, keyframe) =>
575
+ animId && handleGsapMoveKeyframeToPlayhead(animId, keyframe.percentage)
576
+ : undefined
542
577
  }
543
578
  />
544
579
  )}
@@ -1,3 +1,4 @@
1
+ import { scopedElementKey } from "../../hooks/gsapKeyframeCacheHelpers";
1
2
  import { memo, useEffect, useMemo, useRef, useState } from "react";
2
3
  import { Move } from "../../icons/SystemIcons";
3
4
  import { InspectorHeaderActions } from "./InspectorHeaderActions";
@@ -101,6 +102,7 @@ export const PropertyPanel = memo(function PropertyPanel(props: PropertyPanelPro
101
102
  onUpdateArcSegment,
102
103
  onUnroll,
103
104
  onUpdateKeyframeEase,
105
+ onUpdateSegmentEase,
104
106
  onSetAllKeyframeEases,
105
107
  onAddKeyframe,
106
108
  onRemoveKeyframe,
@@ -556,6 +558,7 @@ export const PropertyPanel = memo(function PropertyPanel(props: PropertyPanelPro
556
558
  onAddGsapProperty &&
557
559
  onAddGsapAnimation && (
558
560
  <GsapAnimationSection
561
+ elementId={scopedElementKey(element)}
559
562
  animations={gsapAnimations}
560
563
  multipleTimelines={gsapMultipleTimelines}
561
564
  unsupportedTimelinePattern={gsapUnsupportedTimelinePattern}
@@ -573,6 +576,7 @@ export const PropertyPanel = memo(function PropertyPanel(props: PropertyPanelPro
573
576
  onUnroll={onUnroll}
574
577
  onUpdateKeyframeEase={onUpdateKeyframeEase}
575
578
  onSetAllKeyframeEases={onSetAllKeyframeEases}
579
+ onUpdateSegmentEase={onUpdateSegmentEase}
576
580
  />
577
581
  )}
578
582
 
@@ -1,10 +1,9 @@
1
+ import { scopedElementKey } from "../../hooks/gsapKeyframeCacheHelpers";
1
2
  import { type ReactNode, useEffect, useRef, useState } from "react";
2
- import { resolveEditingSections } from "@hyperframes/core/editing";
3
3
  import { DesignPanelInputProvider } from "../../contexts/DesignPanelInputContext";
4
4
  import { slugifyDesignInput } from "../../utils/designInputTracking";
5
- import type { DomEditSelection } from "./domEditing";
6
5
  import { isTextEditableSelection } from "./domEditing";
7
- import type { PropertyPanelProps } from "./propertyPanelHelpers";
6
+ import type { PropertyPanelFlatProps } from "./propertyPanelFlatProps";
8
7
  import { formatPxMetricValue } from "./propertyPanelHelpers";
9
8
  import { PropertyPanelFlatHeader } from "./PropertyPanelFlatHeader";
10
9
  import { PropertyPanelFlatFooter } from "./PropertyPanelFlatFooter";
@@ -34,8 +33,6 @@ import {
34
33
  FlatOverlaysSection,
35
34
  } from "./propertyPanelFlatOverlaysSection";
36
35
 
37
- type EditingSections = ReturnType<typeof resolveEditingSections>;
38
-
39
36
  type FlatGroupDescriptor = {
40
37
  id: string;
41
38
  title: string;
@@ -135,92 +132,9 @@ export function PropertyPanelFlat({
135
132
  onUpdateArcSegment,
136
133
  onUnroll,
137
134
  onUpdateKeyframeEase,
135
+ onUpdateSegmentEase,
138
136
  onSetAllKeyframeEases,
139
- }: Pick<
140
- PropertyPanelProps,
141
- | "projectId"
142
- | "projectDir"
143
- | "assets"
144
- | "previewIframeRef"
145
- | "onClearSelection"
146
- | "onUngroup"
147
- | "onSetStyle"
148
- | "onPreviewStyle"
149
- | "onSetAttribute"
150
- | "onSetAttributes"
151
- | "onSetAttributeLive"
152
- | "onApplyColorGradingScope"
153
- | "onSetHtmlAttribute"
154
- | "onRemoveBackground"
155
- | "onSetText"
156
- | "onSetTextFieldStyle"
157
- | "onPreviewTextFieldStyle"
158
- | "onAddTextField"
159
- | "onRemoveTextField"
160
- | "onAskAgent"
161
- | "onToggleElementHidden"
162
- | "onImportAssets"
163
- | "onAddMediaOverlay"
164
- | "onImportFonts"
165
- | "fontAssets"
166
- | "gsapAnimations"
167
- | "gsapMultipleTimelines"
168
- | "gsapUnsupportedTimelinePattern"
169
- | "onUpdateGsapProperty"
170
- | "onUpdateGsapMeta"
171
- | "onDeleteGsapAnimation"
172
- | "onAddGsapProperty"
173
- | "onRemoveGsapProperty"
174
- | "onUpdateGsapFromProperty"
175
- | "onAddGsapFromProperty"
176
- | "onRemoveGsapFromProperty"
177
- | "onAddGsapAnimation"
178
- | "onSetArcPath"
179
- | "onUpdateArcSegment"
180
- | "onUnroll"
181
- | "onUpdateKeyframeEase"
182
- | "onSetAllKeyframeEases"
183
- | "recordingState"
184
- | "recordingDuration"
185
- | "onToggleRecording"
186
- > &
187
- Pick<
188
- Parameters<typeof FlatLayoutSection>[0],
189
- | "displayX"
190
- | "displayY"
191
- | "displayW"
192
- | "displayH"
193
- | "displayR"
194
- | "manualOffsetEditingDisabled"
195
- | "manualSizeEditingDisabled"
196
- | "manualRotationEditingDisabled"
197
- | "commitManualOffset"
198
- | "commitManualSize"
199
- | "commitManualRotation"
200
- | "gsapAnimId"
201
- | "navKeyframes"
202
- | "animIdForProp"
203
- | "gsapRuntimeValues"
204
- | "elStart"
205
- | "elDuration"
206
- | "onCommitAnimatedProperty"
207
- | "onCommitAnimatedProperties"
208
- | "onSeekToTime"
209
- | "onRemoveKeyframe"
210
- | "onConvertToKeyframes"
211
- > & {
212
- element: DomEditSelection;
213
- styles: Record<string, string>;
214
- sections: EditingSections;
215
- sourceLabel: string;
216
- gsapBorderRadius: { tl: number; tr: number; br: number; bl: number } | null;
217
- showEditableSections: boolean;
218
- selectedElementHidden: boolean;
219
- selectedElementId: string | null;
220
- clipboardCopied: boolean;
221
- onCopyElementInfo: () => void;
222
- currentTime: number;
223
- }) {
137
+ }: PropertyPanelFlatProps) {
224
138
  // PropertyPanel keys this component by selection, so the default is per element.
225
139
  const [openGroupId, setOpenGroupId] = useState<string>(() =>
226
140
  isTextEditableSelection(element)
@@ -253,7 +167,7 @@ export function PropertyPanelFlat({
253
167
  // flips synchronously while the panel still renders its predecessor, so a
254
168
  // stale panel would consume a request meant for its successor whenever the
255
169
  // two share a class-selector animation id.
256
- const renderedElementId = `${element.sourceFile}#${element.id}`;
170
+ const renderedElementId = scopedElementKey(element);
257
171
  // Adjusted during render (not an effect) so the card mounts on the same
258
172
  // commit the request lands on. Keyed on request identity: a group the user
259
173
  // closes afterwards stays closed.
@@ -330,6 +244,7 @@ export function PropertyPanelFlat({
330
244
  onUpdateArcSegment,
331
245
  onUnroll,
332
246
  onUpdateKeyframeEase,
247
+ onUpdateSegmentEase,
333
248
  onSetAllKeyframeEases,
334
249
  }
335
250
  : null;
@@ -26,7 +26,6 @@ describe("withTrackedGsapAnimationCallbacks", () => {
26
26
  const onLivePreviewEnd = vi.fn();
27
27
  callbacks.onLivePreview = onLivePreview;
28
28
  callbacks.onLivePreviewEnd = onLivePreviewEnd;
29
-
30
29
  const tracked = withTrackedGsapAnimationCallbacks(callbacks, vi.fn());
31
30
 
32
31
  expect(tracked.onUpdateFromProperty).toBeUndefined();
@@ -37,6 +36,7 @@ describe("withTrackedGsapAnimationCallbacks", () => {
37
36
  expect(tracked.onUpdateKeyframeEase).toBeUndefined();
38
37
  expect(tracked.onSetAllKeyframeEases).toBeUndefined();
39
38
  expect(tracked.onUnroll).toBeUndefined();
39
+ expect(tracked.onUpdateSegmentEase).toBeUndefined();
40
40
  expect(tracked.onLivePreview).toBe(onLivePreview);
41
41
  expect(tracked.onLivePreviewEnd).toBe(onLivePreviewEnd);
42
42
  });
@@ -57,6 +57,7 @@ describe("withTrackedGsapAnimationCallbacks", () => {
57
57
  onUpdateArcSegment: mutation("arc-segment"),
58
58
  onUpdateKeyframeEase: mutation("keyframe-ease"),
59
59
  onSetAllKeyframeEases: mutation("all-eases"),
60
+ onUpdateSegmentEase: mutation("segment-ease"),
60
61
  onUnroll: mutation("unroll"),
61
62
  };
62
63
  const tracked = withTrackedGsapAnimationCallbacks(callbacks, (control, name) => {
@@ -79,6 +80,10 @@ describe("withTrackedGsapAnimationCallbacks", () => {
79
80
  requireCallback(tracked.onUpdateArcSegment)("a1", 1, { curviness: 0.5 });
80
81
  requireCallback(tracked.onUpdateKeyframeEase)("a1", 50, "power2.out");
81
82
  requireCallback(tracked.onSetAllKeyframeEases)("a1", "none");
83
+ requireCallback(tracked.onUpdateSegmentEase)(
84
+ [{ animationId: "a1", tweenPercentage: 50 }],
85
+ "none",
86
+ );
82
87
  requireCallback(tracked.onUnroll)("a1");
83
88
 
84
89
  expect(events).toEqual([
@@ -115,6 +120,8 @@ describe("withTrackedGsapAnimationCallbacks", () => {
115
120
  "mutate:keyframe-ease",
116
121
  "track:select:All keyframe eases",
117
122
  "mutate:all-eases",
123
+ "track:select:Segment ease",
124
+ "mutate:segment-ease",
118
125
  "track:button:Unroll animation",
119
126
  "mutate:unroll",
120
127
  ]);
@@ -1,5 +1,6 @@
1
1
  import type { ArcPathSegment } from "@hyperframes/parsers/gsap-parser";
2
2
  import { usePlayerStore } from "../../player";
3
+ import type { AnimationKeyframeTarget } from "../../hooks/gsapTweenSynth";
3
4
 
4
5
  /**
5
6
  * Edit callbacks shared by GsapAnimationSection and each AnimationCard it
@@ -30,6 +31,7 @@ export interface GsapAnimationEditCallbacks {
30
31
  update: Partial<ArcPathSegment>,
31
32
  ) => void;
32
33
  onUpdateKeyframeEase?: (animationId: string, percentage: number, ease: string) => void;
34
+ onUpdateSegmentEase?: (targets: AnimationKeyframeTarget[], ease: string) => void;
33
35
  /** Apply one ease to every keyframe segment at once (clears per-segment overrides). */
34
36
  onSetAllKeyframeEases?: (animationId: string, ease: string) => void;
35
37
  /** Unroll a computed (helper/loop) tween into literal tweens so it edits directly. */
@@ -122,6 +124,12 @@ export function withTrackedGsapAnimationCallbacks(
122
124
  : undefined,
123
125
  onLivePreview: callbacks.onLivePreview,
124
126
  onLivePreviewEnd: callbacks.onLivePreviewEnd,
127
+ onUpdateSegmentEase: callbacks.onUpdateSegmentEase
128
+ ? (targets, ease) => {
129
+ track("select", "Segment ease");
130
+ callbacks.onUpdateSegmentEase?.(targets, ease);
131
+ }
132
+ : undefined,
125
133
  onSetArcPath: callbacks.onSetArcPath
126
134
  ? (animationId, config) => {
127
135
  track("toggle", config.autoRotate !== undefined ? "Auto rotate" : "Arc motion");