@hyperframes/studio 0.7.78 → 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 (103) hide show
  1. package/dist/assets/{hyperframes-player-uiAAPKvw.js → hyperframes-player-Csai0_vh.js} +1 -1
  2. package/dist/assets/{index-CDSTMtUs.js → index-B5aYTg-4.js} +1 -1
  3. package/dist/assets/{index-BSZrK0bx.js → index-BhdJN49y.js} +202 -202
  4. package/dist/assets/{index-r1tKKlU7.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 +1891 -1522
  9. package/dist/index.js.map +1 -1
  10. package/package.json +7 -7
  11. package/src/components/StudioLeftSidebar.tsx +10 -5
  12. package/src/components/StudioRightPanel.tsx +7 -5
  13. package/src/components/TimelineToolbar.tsx +3 -1
  14. package/src/components/editor/AnimationCard.test.tsx +225 -18
  15. package/src/components/editor/AnimationCard.tsx +24 -3
  16. package/src/components/editor/EaseCurveSection.test.tsx +147 -2
  17. package/src/components/editor/EaseCurveSection.tsx +77 -15
  18. package/src/components/editor/GsapAnimationSection.test.tsx +102 -0
  19. package/src/components/editor/GsapAnimationSection.tsx +6 -1
  20. package/src/components/editor/KeyframeEaseList.tsx +4 -0
  21. package/src/components/editor/KeyframeNavigation.tsx +5 -0
  22. package/src/components/editor/MotionPathOverlay.tsx +58 -20
  23. package/src/components/editor/PropertyPanel.tsx +7 -7
  24. package/src/components/editor/PropertyPanelFlat.tsx +6 -91
  25. package/src/components/editor/gsapAnimationCallbacks.test.ts +8 -1
  26. package/src/components/editor/gsapAnimationCallbacks.ts +8 -0
  27. package/src/components/editor/holdEaseSeek.test.ts +37 -0
  28. package/src/components/editor/keyframeRetime.test.ts +42 -0
  29. package/src/components/editor/keyframeRetime.ts +4 -1
  30. package/src/components/editor/motionPathSelection.test.ts +65 -0
  31. package/src/components/editor/motionPathSelection.ts +13 -3
  32. package/src/components/editor/propertyPanelColor.test.tsx +181 -12
  33. package/src/components/editor/propertyPanelColor.tsx +32 -26
  34. package/src/components/editor/propertyPanelFlatMotionSection.test.tsx +69 -0
  35. package/src/components/editor/propertyPanelFlatMotionSection.tsx +2 -1
  36. package/src/components/editor/propertyPanelFlatProps.ts +91 -0
  37. package/src/components/editor/propertyPanelPrimitives.tsx +3 -1
  38. package/src/components/editor/propertyPanelTypes.ts +2 -0
  39. package/src/components/nle/TimelineResizeDivider.tsx +9 -4
  40. package/src/components/pointerTargetSize.test.tsx +65 -0
  41. package/src/components/sidebar/CompositionsTab.tsx +4 -1
  42. package/src/contexts/DomEditContext.tsx +4 -0
  43. package/src/hooks/gsapDragCommit.ts +43 -10
  44. package/src/hooks/gsapDragStaticSetHelpers.ts +16 -6
  45. package/src/hooks/gsapKeyframeCacheHelpers.test.ts +195 -43
  46. package/src/hooks/gsapKeyframeCacheHelpers.ts +190 -83
  47. package/src/hooks/gsapKeyframeCommit.ts +11 -2
  48. package/src/hooks/gsapResizeIntercept.ts +2 -2
  49. package/src/hooks/gsapRuntimeBridge.ts +3 -2
  50. package/src/hooks/gsapScriptCommitHelpers.ts +17 -2
  51. package/src/hooks/gsapShared.test.ts +144 -0
  52. package/src/hooks/gsapShared.ts +264 -0
  53. package/src/hooks/gsapShared.writeTarget.test.ts +289 -0
  54. package/src/hooks/gsapTweenSynth.test.ts +70 -15
  55. package/src/hooks/gsapTweenSynth.ts +50 -23
  56. package/src/hooks/keyframeCacheAstLoad.ts +9 -83
  57. package/src/hooks/newTweenTarget.test.ts +364 -0
  58. package/src/hooks/newTweenTargetHooks.test.tsx +255 -0
  59. package/src/hooks/timelineTrackVisibility.test.ts +61 -1
  60. package/src/hooks/timelineTrackVisibility.ts +11 -1
  61. package/src/hooks/useAnimatedPropertyCommit.ts +24 -8
  62. package/src/hooks/useDomEditSession.test.tsx +152 -3
  63. package/src/hooks/useDomEditSession.ts +4 -53
  64. package/src/hooks/useEnableKeyframes.test.ts +44 -0
  65. package/src/hooks/useEnableKeyframes.ts +18 -5
  66. package/src/hooks/useGestureCommit.ts +21 -4
  67. package/src/hooks/useGsapScriptCommits.ts +3 -0
  68. package/src/hooks/useGsapTweenCache.test.ts +11 -3
  69. package/src/hooks/useGsapTweenCache.ts +44 -45
  70. package/src/hooks/useKeyframeEaseCommits.ts +78 -0
  71. package/src/player/components/KeyframeDiamondContextMenu.test.tsx +21 -0
  72. package/src/player/components/KeyframeDiamondContextMenu.tsx +19 -12
  73. package/src/player/components/LayerDisclosureRow.tsx +4 -1
  74. package/src/player/components/ShortcutsPanel.test.tsx +158 -0
  75. package/src/player/components/ShortcutsPanel.tsx +11 -15
  76. package/src/player/components/Timeline.test.ts +3 -1
  77. package/src/player/components/TimelineCanvas.tsx +2 -1
  78. package/src/player/components/TimelineClipDiamonds.test.tsx +35 -8
  79. package/src/player/components/TimelineClipDiamonds.tsx +11 -1
  80. package/src/player/components/TimelineDiamondConnectors.tsx +118 -72
  81. package/src/player/components/TimelineLanes.test.tsx +281 -0
  82. package/src/player/components/TimelineLanes.tsx +43 -97
  83. package/src/player/components/TimelinePropertyLanes.test.tsx +217 -0
  84. package/src/player/components/TimelinePropertyLanes.tsx +80 -11
  85. package/src/player/components/TimelineTrackHeader.test.tsx +75 -5
  86. package/src/player/components/TimelineTrackHeader.tsx +52 -22
  87. package/src/player/components/timelineDiamondTypes.ts +4 -3
  88. package/src/player/components/timelineKeyframeIdentity.ts +3 -0
  89. package/src/player/components/timelineLaneProps.ts +86 -0
  90. package/src/player/components/timelineTrackDisplay.test.ts +35 -0
  91. package/src/player/components/timelineTrackDisplay.ts +38 -0
  92. package/src/player/components/useTimelineKeyframeHandlers.test.tsx +52 -32
  93. package/src/player/components/useTimelineKeyframeHandlers.ts +1 -0
  94. package/src/player/components/useTimelineRangeSelection.ts +5 -0
  95. package/src/player/components/useTimelineRangeSelectionScrub.test.tsx +127 -0
  96. package/src/player/components/useTimelineTrackLayout.test.ts +35 -0
  97. package/src/player/components/useTimelineTrackLayout.ts +4 -4
  98. package/src/player/hooks/useExpandedTimelineElements.test.ts +180 -2
  99. package/src/player/hooks/useExpandedTimelineElements.ts +70 -7
  100. package/src/player/store/keyframeSlice.ts +15 -5
  101. package/src/telemetry/client.test.ts +12 -3
  102. package/src/telemetry/client.ts +3 -2
  103. package/dist/assets/index-DpkO2Hvw.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>
@@ -167,6 +167,11 @@ export const KeyframeNavigation = memo(function KeyframeNavigation({
167
167
  };
168
168
 
169
169
  return (
170
+ // The two 12x20 steppers sit gap-0.5 apart with a 9px diamond between them,
171
+ // so they stay below the 24px WCAG 2.2 (2.5.8) minimum under that criterion's
172
+ // own spacing/inline exception: centred 24px targets here would overlap each
173
+ // other AND the diamond, and one control swallowing its neighbour's clicks is
174
+ // a worse 2.5.8 failure than a small target. Do not "fix" these to 24.
170
175
  <div className="flex h-5 items-center gap-0.5">
171
176
  <button
172
177
  type="button"
@@ -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
  );
@@ -132,7 +138,10 @@ export const MotionPathOverlay = memo(function MotionPathOverlay({
132
138
  const createMode = geometryResolved && !geometry && Boolean(selection?.element) && !isPlaying;
133
139
  const createSelector = createMode ? selectorFor(selection) : null;
134
140
  const compW = compositionSize?.width ?? null;
135
- const canCreate = createMode && hasMotionPathPlugin(iframeRef.current);
141
+ // No one-element selector means the path could only be authored onto the
142
+ // element's class siblings, so the toolbar toggle stays hidden instead of
143
+ // arming a press that the effect below would silently drop.
144
+ const canCreate = createMode && !!createSelector && hasMotionPathPlugin(iframeRef.current);
136
145
 
137
146
  // Publish whether the selected element can take a path so the preview toolbar
138
147
  // shows its "set destination" toggle. Drops to false when this overlay unmounts
@@ -432,21 +441,47 @@ export const MotionPathOverlay = memo(function MotionPathOverlay({
432
441
  };
433
442
 
434
443
  const elementId = selection?.id ?? null;
435
- // Right-click a keyframe node → the timeline's keyframe context menu (delete
436
- // 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.
437
448
  const onNodeContextMenu = (e: React.MouseEvent, ref: MotionNodeRef) => {
438
- if (ref.type !== "keyframe" || !animId || !elementId || !timelineElement) return;
449
+ if (!animId || !elementId || !timelineElement) return;
439
450
  e.preventDefault();
440
451
  e.stopPropagation();
441
452
  setKfMenu({
442
- x: e.clientX,
443
- y: e.clientY,
444
- element: timelineElement,
445
- elementId,
446
- percentage: ref.pct,
447
- 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
+ },
448
466
  });
449
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;
450
485
 
451
486
  return (
452
487
  <>
@@ -528,14 +563,17 @@ export const MotionPathOverlay = memo(function MotionPathOverlay({
528
563
  </svg>
529
564
  {kfMenu && (
530
565
  <KeyframeDiamondContextMenu
531
- state={kfMenu}
566
+ state={kfMenu.state}
532
567
  onClose={() => setKfMenu(null)}
533
- onDelete={(_elId, keyframe) =>
534
- animId && handleGsapRemoveKeyframe(animId, keyframe.percentage)
535
- }
568
+ onDelete={onMenuDelete}
536
569
  onDeleteAll={() => animId && handleGsapRemoveAllKeyframes(animId)}
537
- onMoveToPlayhead={(_element, keyframe) =>
538
- 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
539
577
  }
540
578
  />
541
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";
@@ -16,7 +17,7 @@ import {
16
17
  } from "./propertyPanelHelpers";
17
18
  import { MetricField, Section } from "./propertyPanelPrimitives";
18
19
  import { createTransformCommitHandlers } from "./propertyPanelTransformCommit";
19
- import { classifyPropertyGroup } from "@hyperframes/core/gsap-parser";
20
+ import { resolveAnimIdForProperty } from "../../player/components/TimelinePropertyLanes";
20
21
  import { resolveEditingSections } from "@hyperframes/core/editing";
21
22
  import { MediaSection } from "./propertyPanelMediaSection";
22
23
  import { ColorGradingSection } from "./propertyPanelColorGradingSection";
@@ -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,
@@ -241,12 +243,8 @@ export const PropertyPanel = memo(function PropertyPanel(props: PropertyPanelPro
241
243
  const navKeyframes = cacheEntry?.keyframes ?? gsapKeyframes;
242
244
  const seekFromKfPct = (pct: number) => onSeekToTime?.(elStart + (pct / 100) * elDuration);
243
245
 
244
- const animIdForProp = (prop: string): string => {
245
- const group = classifyPropertyGroup(prop);
246
- const groupAnim = gsapAnimations?.find((a) => a.propertyGroup === group);
247
- if (groupAnim) return groupAnim.id;
248
- return gsapAnimId ?? "";
249
- };
246
+ const animIdForProp = (prop: string): string =>
247
+ resolveAnimIdForProperty(prop, gsapAnimations, gsapAnimId);
250
248
 
251
249
  const displayX = gsapRuntimeValues?.x ?? manualOffset.x;
252
250
  const displayY = gsapRuntimeValues?.y ?? manualOffset.y;
@@ -560,6 +558,7 @@ export const PropertyPanel = memo(function PropertyPanel(props: PropertyPanelPro
560
558
  onAddGsapProperty &&
561
559
  onAddGsapAnimation && (
562
560
  <GsapAnimationSection
561
+ elementId={scopedElementKey(element)}
563
562
  animations={gsapAnimations}
564
563
  multipleTimelines={gsapMultipleTimelines}
565
564
  unsupportedTimelinePattern={gsapUnsupportedTimelinePattern}
@@ -577,6 +576,7 @@ export const PropertyPanel = memo(function PropertyPanel(props: PropertyPanelPro
577
576
  onUnroll={onUnroll}
578
577
  onUpdateKeyframeEase={onUpdateKeyframeEase}
579
578
  onSetAllKeyframeEases={onSetAllKeyframeEases}
579
+ onUpdateSegmentEase={onUpdateSegmentEase}
580
580
  />
581
581
  )}
582
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;