@hyperframes/studio 0.6.122-alpha.0 → 0.7.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (100) hide show
  1. package/dist/assets/{index-i-MLqhmm.js → index-CnYOrmoA.js} +1 -1
  2. package/dist/assets/index-DzWIinxk.css +1 -0
  3. package/dist/assets/index-Z4bA3Fi0.js +374 -0
  4. package/dist/assets/{index-DSLrl2tB.js → index-wWkKTkIq.js} +24 -24
  5. package/dist/index.d.ts +8 -0
  6. package/dist/index.html +3 -3
  7. package/dist/index.js +3768 -2245
  8. package/dist/index.js.map +1 -1
  9. package/package.json +5 -5
  10. package/src/App.tsx +1 -0
  11. package/src/components/StudioHeader.tsx +2 -3
  12. package/src/components/StudioPreviewArea.tsx +12 -0
  13. package/src/components/TimelineToolbar.tsx +29 -16
  14. package/src/components/editor/DomEditOverlay.test.ts +0 -13
  15. package/src/components/editor/DomEditOverlay.tsx +3 -10
  16. package/src/components/editor/KeyframeNavigation.test.ts +36 -0
  17. package/src/components/editor/KeyframeNavigation.tsx +34 -4
  18. package/src/components/editor/MotionPathNode.tsx +98 -0
  19. package/src/components/editor/MotionPathOverlay.tsx +481 -0
  20. package/src/components/editor/SnapToolbar.tsx +28 -3
  21. package/src/components/editor/SourceEditor.tsx +21 -9
  22. package/src/components/editor/domEditOverlayGeometry.ts +12 -0
  23. package/src/components/editor/domEditOverlayGestures.ts +5 -1
  24. package/src/components/editor/domEditOverlayStartGesture.ts +5 -1
  25. package/src/components/editor/domEditingDom.ts +24 -9
  26. package/src/components/editor/manualEditingAvailability.test.ts +0 -12
  27. package/src/components/editor/manualEditingAvailability.ts +1 -11
  28. package/src/components/editor/manualEdits.ts +1 -0
  29. package/src/components/editor/manualEditsDom.ts +68 -41
  30. package/src/components/editor/manualEditsDomGsap.test.ts +82 -0
  31. package/src/components/editor/manualOffsetDrag.test.ts +12 -0
  32. package/src/components/editor/manualOffsetDrag.ts +79 -4
  33. package/src/components/editor/motionPathCommit.test.ts +130 -0
  34. package/src/components/editor/motionPathCommit.ts +83 -0
  35. package/src/components/editor/motionPathGeometry.test.ts +127 -0
  36. package/src/components/editor/motionPathGeometry.ts +116 -0
  37. package/src/components/editor/motionPathSelection.ts +33 -0
  38. package/src/components/editor/useDomEditOverlayGestures.ts +36 -18
  39. package/src/components/editor/useDomEditOverlayRects.ts +6 -0
  40. package/src/components/editor/useMotionPathData.ts +135 -0
  41. package/src/components/nle/NLELayout.tsx +17 -6
  42. package/src/components/panels/SlideshowPanel.tsx +2 -1
  43. package/src/components/panels/SlideshowSubPanels.tsx +2 -1
  44. package/src/components/renders/RenderQueue.tsx +1 -1
  45. package/src/components/renders/useRenderQueue.ts +3 -2
  46. package/src/hooks/draggedGsapPosition.ts +47 -0
  47. package/src/hooks/gsapDragCommit.test.ts +435 -0
  48. package/src/hooks/gsapDragCommit.ts +278 -290
  49. package/src/hooks/gsapDragPositionCommit.ts +410 -0
  50. package/src/hooks/gsapRuntimeBridge.test.ts +147 -0
  51. package/src/hooks/gsapRuntimeBridge.ts +86 -69
  52. package/src/hooks/gsapRuntimeKeyframes.test.ts +129 -1
  53. package/src/hooks/gsapRuntimeKeyframes.ts +183 -11
  54. package/src/hooks/gsapRuntimePatch.test.ts +462 -0
  55. package/src/hooks/gsapRuntimePatch.ts +159 -0
  56. package/src/hooks/gsapScriptCommitTypes.ts +11 -1
  57. package/src/hooks/gsapShared.test.ts +58 -0
  58. package/src/hooks/gsapShared.ts +28 -10
  59. package/src/hooks/gsapTargetCache.ts +0 -3
  60. package/src/hooks/useDomEditPreviewSync.ts +3 -0
  61. package/src/hooks/useDomEditSession.ts +0 -4
  62. package/src/hooks/useDomEditWiring.ts +1 -0
  63. package/src/hooks/useDomSelection.ts +12 -5
  64. package/src/hooks/useEnableKeyframes.test.ts +130 -0
  65. package/src/hooks/useEnableKeyframes.ts +298 -45
  66. package/src/hooks/useGestureCommit.ts +121 -53
  67. package/src/hooks/useGestureRecording.ts +61 -42
  68. package/src/hooks/useGsapAnimationFetchFallback.test.ts +36 -0
  69. package/src/hooks/useGsapAnimationFetchFallback.ts +65 -9
  70. package/src/hooks/useGsapAnimationOps.ts +1 -1
  71. package/src/hooks/useGsapAwareEditing.ts +38 -40
  72. package/src/hooks/useGsapScriptCommits.test.tsx +366 -0
  73. package/src/hooks/useGsapScriptCommits.ts +102 -6
  74. package/src/hooks/useGsapTweenCache.ts +12 -11
  75. package/src/hooks/useRazorSplit.ts +21 -2
  76. package/src/hooks/useSafeGsapCommitMutation.test.tsx +86 -0
  77. package/src/hooks/useSafeGsapCommitMutation.ts +8 -4
  78. package/src/hooks/useServerConnection.ts +4 -1
  79. package/src/hooks/useStudioContextValue.ts +3 -2
  80. package/src/hooks/useStudioUrlState.ts +97 -37
  81. package/src/player/components/ShortcutsPanel.tsx +8 -0
  82. package/src/player/components/TimelineClipDiamonds.tsx +10 -12
  83. package/src/player/hooks/useExpandedTimelineElements.test.ts +34 -0
  84. package/src/player/hooks/useExpandedTimelineElements.ts +20 -2
  85. package/src/player/hooks/useTimelineSyncCallbacks.ts +9 -1
  86. package/src/player/lib/mediaProbe.ts +7 -1
  87. package/src/player/store/playerStore.ts +38 -1
  88. package/src/telemetry/config.ts +3 -2
  89. package/src/utils/editDebugLog.ts +16 -0
  90. package/src/utils/generateId.ts +7 -0
  91. package/src/utils/gsapSoftReload.test.ts +217 -13
  92. package/src/utils/gsapSoftReload.ts +228 -50
  93. package/src/utils/studioHelpers.test.ts +31 -1
  94. package/src/utils/studioHelpers.ts +24 -0
  95. package/src/utils/studioPreviewHelpers.test.ts +26 -1
  96. package/src/utils/studioPreviewHelpers.ts +30 -6
  97. package/src/utils/studioTelemetry.ts +3 -1
  98. package/dist/assets/index-Baz2T-9U.js +0 -269
  99. package/dist/assets/index-BvEhf7SS.css +0 -1
  100. package/src/player/components/TimelinePropertyRows.tsx +0 -126
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hyperframes/studio",
3
- "version": "0.6.122-alpha.0",
3
+ "version": "0.7.1",
4
4
  "description": "",
5
5
  "repository": {
6
6
  "type": "git",
@@ -45,9 +45,9 @@
45
45
  "dompurify": "^3.2.4",
46
46
  "marked": "^14.1.4",
47
47
  "mediabunny": "^1.45.3",
48
- "@hyperframes/core": "0.6.122-alpha.0",
49
- "@hyperframes/player": "0.6.122-alpha.0",
50
- "@hyperframes/sdk": "0.6.122-alpha.0"
48
+ "@hyperframes/core": "0.7.1",
49
+ "@hyperframes/player": "0.7.1",
50
+ "@hyperframes/sdk": "0.7.1"
51
51
  },
52
52
  "devDependencies": {
53
53
  "@types/react": "19",
@@ -62,7 +62,7 @@
62
62
  "vite": "^6.4.2",
63
63
  "vitest": "^3.2.4",
64
64
  "zustand": "^5.0.0",
65
- "@hyperframes/producer": "0.6.122-alpha.0"
65
+ "@hyperframes/producer": "0.7.1"
66
66
  },
67
67
  "peerDependencies": {
68
68
  "react": "19",
package/src/App.tsx CHANGED
@@ -428,6 +428,7 @@ export function StudioApp() {
428
428
  domEditSelection: domEditSession.domEditSelection,
429
429
  buildDomSelectionFromTarget: domEditSession.buildDomSelectionFromTarget,
430
430
  applyDomSelection: domEditSession.applyDomSelection,
431
+ setRightPanelTab: panelLayout.setRightPanelTab,
431
432
  initialState: initialUrlStateRef.current,
432
433
  });
433
434
  const studioCtxValue = buildStudioContextValue({
@@ -8,7 +8,6 @@ import {
8
8
  import { getHistoryShortcutLabel } from "../utils/studioHelpers";
9
9
  import { useStudioShellContext } from "../contexts/StudioContext";
10
10
  import { usePanelLayoutContext } from "../contexts/PanelLayoutContext";
11
- import { useDomEditActionsContext } from "../contexts/DomEditContext";
12
11
  import { useViewMode, type StudioViewMode } from "../contexts/ViewModeContext";
13
12
  import { trackStudioEvent } from "../utils/studioTelemetry";
14
13
 
@@ -194,7 +193,6 @@ export function StudioHeader({
194
193
  }: StudioHeaderProps) {
195
194
  const { projectId, editHistory, handleUndo, handleRedo } = useStudioShellContext();
196
195
  const { rightCollapsed, setRightCollapsed, setRightPanelTab } = usePanelLayoutContext();
197
- const { clearDomSelection } = useDomEditActionsContext();
198
196
 
199
197
  return (
200
198
  <div className="flex items-center justify-between h-10 px-3 bg-neutral-900 border-b border-neutral-800 flex-shrink-0">
@@ -279,7 +277,8 @@ export function StudioHeader({
279
277
  return;
280
278
  }
281
279
  trackStudioEvent("panel_toggle", { panel: "inspector", collapsed: true });
282
- clearDomSelection();
280
+ // Keep the current selection when collapsing the Inspector — closing
281
+ // the panel shouldn't deselect the element.
283
282
  setRightCollapsed(true);
284
283
  }}
285
284
  disabled={!STUDIO_INSPECTOR_PANELS_ENABLED}
@@ -3,6 +3,8 @@ import { NLELayout } from "./nle/NLELayout";
3
3
  import { CaptionOverlay } from "../captions/components/CaptionOverlay";
4
4
  import { CaptionTimeline } from "../captions/components/CaptionTimeline";
5
5
  import { DomEditOverlay } from "./editor/DomEditOverlay";
6
+ import { MotionPathOverlay } from "./editor/MotionPathOverlay";
7
+ import { useCompositionDimensions } from "../hooks/useCompositionDimensions";
6
8
  import { SnapToolbar } from "./editor/SnapToolbar";
7
9
  import { StudioFeedbackBar } from "./StudioFeedbackBar";
8
10
  import type { TimelineElement } from "../player";
@@ -10,6 +12,7 @@ import { usePlayerStore } from "../player/store/playerStore";
10
12
  import type { BlockedTimelineEditIntent } from "../player/components/timelineEditing";
11
13
  import {
12
14
  STUDIO_INSPECTOR_PANELS_ENABLED,
15
+ STUDIO_KEYFRAMES_ENABLED,
13
16
  STUDIO_PREVIEW_MANUAL_EDITING_ENABLED,
14
17
  STUDIO_PREVIEW_SELECTION_ENABLED,
15
18
  } from "./editor/manualEditingAvailability";
@@ -108,6 +111,7 @@ export function StudioPreviewArea({
108
111
  isPlaying,
109
112
  refreshPreviewDocumentVersion,
110
113
  } = useStudioPlaybackContext();
114
+ const compositionDimensions = useCompositionDimensions();
111
115
 
112
116
  const {
113
117
  domEditHoverSelection,
@@ -337,6 +341,14 @@ export function StudioPreviewArea({
337
341
  onToggleRecording={onToggleRecording}
338
342
  />
339
343
  <SnapToolbar onSnapChange={setSnapPrefs} />
344
+ {STUDIO_KEYFRAMES_ENABLED && (
345
+ <MotionPathOverlay
346
+ iframeRef={previewIframeRef}
347
+ selection={shouldShowSelectedDomBounds ? domEditSelection : null}
348
+ compositionSize={compositionDimensions}
349
+ isPlaying={isPlaying}
350
+ />
351
+ )}
340
352
  {gestureOverlay}
341
353
  </>
342
354
  ) : null
@@ -1,5 +1,10 @@
1
1
  import { useRef } from "react";
2
- import { useEnableKeyframes, type EnableKeyframesSession } from "../hooks/useEnableKeyframes";
2
+ import {
3
+ useEnableKeyframes,
4
+ isPlayheadWithinTween,
5
+ type EnableKeyframesSession,
6
+ } from "../hooks/useEnableKeyframes";
7
+ import { computeElementPercentage } from "../hooks/gsapShared";
3
8
  import {
4
9
  getNextTimelineZoomPercent,
5
10
  getTimelineZoomPercent,
@@ -44,23 +49,25 @@ function useKeyframeToggle(session?: DomEditSessionSlice) {
44
49
  const anims = session.selectedGsapAnimations;
45
50
  const kfAnim = anims.find((a) => a.keyframes);
46
51
 
47
- const computePct = (time: number) => {
48
- const elStart = Number.parseFloat(sel?.dataAttributes?.start ?? "0") || 0;
49
- const elDuration = Number.parseFloat(sel?.dataAttributes?.duration ?? "1") || 1;
50
- return elDuration > 0
51
- ? Math.max(0, Math.min(100, Math.round(((time - elStart) / elDuration) * 1000) / 10))
52
- : 0;
53
- };
54
-
55
52
  let state: "active" | "inactive" | "none" = "none";
53
+ // Outside the tween, clicking extends the animation to the playhead rather than
54
+ // toggling a (clamped) edge keyframe — so the button stays an "add" affordance.
55
+ let willExtend = false;
56
56
  if (kfAnim?.keyframes && sel) {
57
- const pct = computePct(currentTime);
58
- state = kfAnim.keyframes.keyframes.some((k) => Math.abs(k.percentage - pct) <= 1)
59
- ? "active"
60
- : "inactive";
57
+ if (!isPlayheadWithinTween(kfAnim, currentTime)) {
58
+ state = "inactive";
59
+ willExtend = true;
60
+ } else {
61
+ // Tween-relative percentage (not the clip range) so the button state matches
62
+ // where the keyframe would actually land.
63
+ const pct = computeElementPercentage(currentTime, sel, kfAnim);
64
+ state = kfAnim.keyframes.keyframes.some((k) => Math.abs(k.percentage - pct) <= 1)
65
+ ? "active"
66
+ : "inactive";
67
+ }
61
68
  }
62
69
 
63
- return { state, onToggle: sel ? onToggle : undefined };
70
+ return { state, willExtend, onToggle: sel ? onToggle : undefined };
64
71
  }
65
72
 
66
73
  // fallow-ignore-next-line complexity
@@ -76,7 +83,11 @@ export function TimelineToolbar({
76
83
  const beatAnalysisReady = usePlayerStore((s) => s.beatAnalysis !== null);
77
84
  const { zoomMode, manualZoomPercent, setZoomMode, setManualZoomPercent } = useTimelineZoom();
78
85
  const displayedTimelineZoomPercent = getTimelineZoomPercent(zoomMode, manualZoomPercent);
79
- const { state: keyframeState, onToggle: onToggleKeyframe } = useKeyframeToggle(domEditSession);
86
+ const {
87
+ state: keyframeState,
88
+ willExtend: keyframeWillExtend,
89
+ onToggle: onToggleKeyframe,
90
+ } = useKeyframeToggle(domEditSession);
80
91
 
81
92
  return (
82
93
  <div className="border-b border-neutral-800/40 bg-neutral-950/96">
@@ -124,7 +135,9 @@ export function TimelineToolbar({
124
135
  keyframeState === "active"
125
136
  ? "Remove keyframe at playhead"
126
137
  : keyframeState === "inactive"
127
- ? "Add keyframe at playhead"
138
+ ? keyframeWillExtend
139
+ ? "Add keyframe at playhead (extends animation)"
140
+ : "Add keyframe at playhead"
128
141
  : "Enable keyframes"
129
142
  }
130
143
  >
@@ -282,7 +282,6 @@ describe("DomEditOverlay", () => {
282
282
  };
283
283
 
284
284
  let currentSelection: DomEditSelection | null = selection;
285
- const onToggleRecording = vi.fn();
286
285
  const iframeRef = { current: document.createElement("iframe") as HTMLIFrameElement | null };
287
286
  const originalPointerCapture = HTMLDivElement.prototype.setPointerCapture;
288
287
  HTMLDivElement.prototype.setPointerCapture = () => {};
@@ -298,8 +297,6 @@ describe("DomEditOverlay", () => {
298
297
  hoverSelection: null,
299
298
  onSelectionChange: (next: DomEditSelection) => setSelected(next),
300
299
  }),
301
- recordingState: "idle",
302
- onToggleRecording,
303
300
  });
304
301
  }
305
302
 
@@ -340,16 +337,6 @@ describe("DomEditOverlay", () => {
340
337
  "drag",
341
338
  expect.objectContaining({ button: 0 }),
342
339
  );
343
- const recordButton = host.querySelector(
344
- '[aria-label="Record gesture (R)"]',
345
- ) as HTMLButtonElement;
346
- expect(recordButton).toBeTruthy();
347
-
348
- act(() => {
349
- recordButton.click();
350
- });
351
-
352
- expect(onToggleRecording).toHaveBeenCalledTimes(1);
353
340
 
354
341
  act(() => {
355
342
  root.unmount();
@@ -14,7 +14,7 @@ import { useDomEditOverlayRects } from "./useDomEditOverlayRects";
14
14
  import { createDomEditOverlayGestureHandlers } from "./useDomEditOverlayGestures";
15
15
  import { SnapGuideOverlay, type SnapGuidesState } from "./SnapGuideOverlay";
16
16
  import { GridOverlay } from "./GridOverlay";
17
- import { GestureRecordBadge, type GestureRecordingState } from "./GestureRecordControl";
17
+ import type { GestureRecordingState } from "./GestureRecordControl";
18
18
 
19
19
  // Re-exports for external consumers — preserving existing import paths.
20
20
  export {
@@ -55,6 +55,7 @@ interface DomEditOverlayProps {
55
55
  onPathOffsetCommit: (
56
56
  selection: DomEditSelection,
57
57
  next: { x: number; y: number },
58
+ modifiers?: { altKey?: boolean },
58
59
  ) => Promise<void> | void;
59
60
  onGroupPathOffsetCommit: (updates: DomEditGroupPathOffsetCommit[]) => Promise<void> | void;
60
61
  onBoxSizeCommit: (
@@ -87,8 +88,6 @@ export const DomEditOverlay = memo(function DomEditOverlay({
87
88
  onGroupPathOffsetCommit,
88
89
  onBoxSizeCommit,
89
90
  onRotationCommit,
90
- recordingState,
91
- onToggleRecording,
92
91
  }: DomEditOverlayProps) {
93
92
  const overlayRef = useRef<HTMLDivElement | null>(null);
94
93
  const boxRef = useRef<HTMLDivElement | null>(null);
@@ -243,6 +242,7 @@ export const DomEditOverlay = memo(function DomEditOverlay({
243
242
  if (!selection) return "none";
244
243
  return `${selection.sourceFile}:${selection.id ?? selection.selector ?? selection.label}:${selection.selectorIndex ?? 0}`;
245
244
  }, [selection]);
245
+
246
246
  const groupBounds = useMemo(
247
247
  () => resolveDomEditGroupOverlayRect(groupOverlayItems.map((item) => item.rect)),
248
248
  [groupOverlayItems],
@@ -433,13 +433,6 @@ export const DomEditOverlay = memo(function DomEditOverlay({
433
433
  />
434
434
  </div>
435
435
  )}
436
- {onToggleRecording && (
437
- <GestureRecordBadge
438
- rect={overlayRect}
439
- recordingState={recordingState}
440
- onToggleRecording={onToggleRecording}
441
- />
442
- )}
443
436
  <div
444
437
  key={selectionKey}
445
438
  ref={boxRef}
@@ -0,0 +1,36 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { clipToTweenPercentage } from "./KeyframeNavigation";
3
+
4
+ /**
5
+ * Regression: keyframe add/remove are keyed by TWEEN-relative percentage (what the
6
+ * GSAP writer + runtime use), NOT the clip-relative playhead used for display/seek.
7
+ * The Layout-panel diamond used to emit clip-relative %, so the mutation missed
8
+ * every keyframe (off by the tween's offset/scale) → a silent no-op on disk that
9
+ * the optimistic cache hid, so the motion path never refreshed.
10
+ */
11
+
12
+ // A tween that starts partway through the element's lifetime and is shorter than
13
+ // it: the clip→tween map is linear with tween% = (clip% - 20) * 2.5 over [20, 60].
14
+ const KEYFRAMES = [
15
+ { percentage: 20, tweenPercentage: 0, properties: { x: 0 } },
16
+ { percentage: 30, tweenPercentage: 25, properties: { x: -180 } },
17
+ { percentage: 50, tweenPercentage: 75, properties: { x: -320 } },
18
+ { percentage: 60, tweenPercentage: 100, properties: { x: -460 } },
19
+ ];
20
+
21
+ describe("clipToTweenPercentage", () => {
22
+ it("maps anchor keyframes to their tween-relative percentages", () => {
23
+ expect(clipToTweenPercentage(KEYFRAMES, 20)).toBeCloseTo(0, 5);
24
+ expect(clipToTweenPercentage(KEYFRAMES, 60)).toBeCloseTo(100, 5);
25
+ });
26
+
27
+ it("linearly interpolates a clip-relative playhead into tween space", () => {
28
+ // clip 40% is the midpoint of the tween's clip span [20, 60] → tween 50%.
29
+ expect(clipToTweenPercentage(KEYFRAMES, 40)).toBeCloseTo(50, 5);
30
+ });
31
+
32
+ it("falls back to the input when there's no usable mapping", () => {
33
+ expect(clipToTweenPercentage([], 40)).toBe(40);
34
+ expect(clipToTweenPercentage([{ percentage: 10 }], 40)).toBe(40);
35
+ });
36
+ });
@@ -3,9 +3,12 @@ import { KeyframeDiamond, type DiamondState } from "./KeyframeDiamond";
3
3
 
4
4
  interface KeyframeNavigationProps {
5
5
  property: string;
6
- /** All keyframes for this element's tween, or null if no keyframes exist */
6
+ /** All keyframes for this element's tween, or null if no keyframes exist.
7
+ * `percentage` is clip-relative (element lifetime) for display/seek;
8
+ * `tweenPercentage` is the tween-relative value the writer/runtime key on. */
7
9
  keyframes: Array<{
8
10
  percentage: number;
11
+ tweenPercentage?: number;
9
12
  properties: Record<string, number | string>;
10
13
  ease?: string;
11
14
  }> | null;
@@ -19,6 +22,26 @@ interface KeyframeNavigationProps {
19
22
 
20
23
  const TOLERANCE = 0.5;
21
24
 
25
+ /**
26
+ * Convert a clip-relative percentage (element lifetime, used for display/seek) to
27
+ * the TWEEN-relative percentage the GSAP writer/runtime key on. The clip→tween
28
+ * map is linear, recovered from the keyframes' own (percentage, tweenPercentage)
29
+ * pairs. Falls back to the input when there's no usable mapping (e.g. parser
30
+ * keyframes that are already tween-relative, or fewer than two anchors).
31
+ */
32
+ export function clipToTweenPercentage(
33
+ keyframes: ReadonlyArray<{ percentage: number; tweenPercentage?: number }>,
34
+ clipPct: number,
35
+ ): number {
36
+ const mapped = keyframes.filter((kf) => typeof kf.tweenPercentage === "number");
37
+ if (mapped.length < 2) return clipPct;
38
+ const a = mapped[0]!;
39
+ const b = mapped[mapped.length - 1]!;
40
+ if (b.percentage === a.percentage) return a.tweenPercentage!;
41
+ const slope = (b.tweenPercentage! - a.tweenPercentage!) / (b.percentage - a.percentage);
42
+ return a.tweenPercentage! + (clipPct - a.percentage) * slope;
43
+ }
44
+
22
45
  function ArrowLeft({ disabled }: { disabled: boolean }) {
23
46
  return (
24
47
  <svg
@@ -94,13 +117,20 @@ export const KeyframeNavigation = memo(function KeyframeNavigation({
94
117
  diamondState = "ghost";
95
118
  }
96
119
 
120
+ // Keyframe add/remove are keyed by TWEEN-relative percentage (what the GSAP
121
+ // writer + runtime use), not the clip-relative `currentPercentage` used for
122
+ // display/seek. Removing on an existing keyframe uses its own tweenPercentage;
123
+ // adding converts the clip-relative playhead through the keyframes' own
124
+ // clip→tween linear mapping. Passing clip-relative % made the mutation miss
125
+ // every keyframe (off by the tween's offset/scale) → a silent no-op on disk
126
+ // while the optimistic cache hid it, so the motion path never refreshed.
97
127
  const handleDiamondClick = () => {
98
128
  if (diamondState === "ghost") {
99
129
  onConvertToKeyframes();
100
- } else if (diamondState === "active") {
101
- onRemoveKeyframe(currentPercentage);
130
+ } else if (diamondState === "active" && atCurrent) {
131
+ onRemoveKeyframe(atCurrent.tweenPercentage ?? atCurrent.percentage);
102
132
  } else {
103
- onAddKeyframe(currentPercentage);
133
+ onAddKeyframe(clipToTweenPercentage(propertyKeyframes, currentPercentage));
104
134
  }
105
135
  };
106
136
 
@@ -0,0 +1,98 @@
1
+ import type React from "react";
2
+
3
+ // Editor primary color (themeable via --hf-accent). Applied through inline
4
+ // style because CSS var() isn't valid in SVG presentation attributes.
5
+ export const ACCENT = "var(--hf-accent, #3CE6AC)";
6
+
7
+ /** One path node: a diamond (matching the timeline keyframe), a wider transparent
8
+ * grab target (when editable), and a hover-revealed × delete badge (when removable). */
9
+ export function MotionPathNode(props: {
10
+ cx: number;
11
+ cy: number;
12
+ r: number;
13
+ interactive: boolean;
14
+ removable: boolean;
15
+ grabbing: boolean;
16
+ selected: boolean;
17
+ onEnter: () => void;
18
+ onLeave: () => void;
19
+ onPointerDown: (e: React.PointerEvent) => void;
20
+ onPointerMove: (e: React.PointerEvent) => void;
21
+ onPointerUp: (e: React.PointerEvent) => void;
22
+ onRemove: (e: React.PointerEvent) => void;
23
+ onContextMenu?: (e: React.MouseEvent) => void;
24
+ }) {
25
+ const { cx, cy, r, interactive, removable, grabbing, selected } = props;
26
+ const bx = cx + r * 1.8;
27
+ const by = cy - r * 1.8;
28
+ const k = r * 0.55;
29
+ // Diamond matching the timeline keyframe (a 45°-rotated rounded square).
30
+ // `side` is chosen so the diamond's points reach ~`r` from center, matching the
31
+ // old dot's footprint; selection is shown by enlarging it (no extra shape).
32
+ const side = (selected ? r * 1.5 : r) * 1.414;
33
+ return (
34
+ <g onPointerEnter={props.onEnter} onPointerLeave={props.onLeave}>
35
+ <rect
36
+ x={cx - side / 2}
37
+ y={cy - side / 2}
38
+ width={side}
39
+ height={side}
40
+ rx={side * 0.17}
41
+ transform={`rotate(45 ${cx} ${cy})`}
42
+ stroke="#0b0f1a"
43
+ strokeWidth={1.5}
44
+ vectorEffect="non-scaling-stroke"
45
+ style={{ fill: ACCENT }}
46
+ />
47
+ {interactive && (
48
+ <circle
49
+ cx={cx}
50
+ cy={cy}
51
+ r={r * 2.4}
52
+ fill="transparent"
53
+ className="pointer-events-auto"
54
+ style={{ cursor: grabbing ? "grabbing" : "grab" }}
55
+ onPointerDown={props.onPointerDown}
56
+ onPointerMove={props.onPointerMove}
57
+ onPointerUp={props.onPointerUp}
58
+ onContextMenu={props.onContextMenu}
59
+ />
60
+ )}
61
+ {removable && (
62
+ <g
63
+ className="pointer-events-auto"
64
+ style={{ cursor: "pointer" }}
65
+ onPointerDown={props.onRemove}
66
+ >
67
+ <circle
68
+ cx={bx}
69
+ cy={by}
70
+ r={r * 1.3}
71
+ stroke="#0b0f1a"
72
+ strokeWidth={1}
73
+ vectorEffect="non-scaling-stroke"
74
+ style={{ fill: ACCENT }}
75
+ />
76
+ <line
77
+ x1={bx - k}
78
+ y1={by - k}
79
+ x2={bx + k}
80
+ y2={by + k}
81
+ stroke="#0b0f1a"
82
+ strokeWidth={1.5}
83
+ vectorEffect="non-scaling-stroke"
84
+ />
85
+ <line
86
+ x1={bx + k}
87
+ y1={by - k}
88
+ x2={bx - k}
89
+ y2={by + k}
90
+ stroke="#0b0f1a"
91
+ strokeWidth={1.5}
92
+ vectorEffect="non-scaling-stroke"
93
+ />
94
+ </g>
95
+ )}
96
+ </g>
97
+ );
98
+ }