@hyperframes/studio 0.7.76 → 0.7.78

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 (138) hide show
  1. package/dist/assets/{hyperframes-player-DsRdxz7A.js → hyperframes-player-uiAAPKvw.js} +1 -1
  2. package/dist/assets/index-BSZrK0bx.js +428 -0
  3. package/dist/assets/{index-Du1RoLiB.js → index-CDSTMtUs.js} +1 -1
  4. package/dist/assets/index-DpkO2Hvw.css +1 -0
  5. package/dist/assets/{index-BLICKger.js → index-r1tKKlU7.js} +1 -1
  6. package/dist/index.d.ts +64 -17
  7. package/dist/index.html +2 -2
  8. package/dist/index.js +25030 -23214
  9. package/dist/index.js.map +1 -1
  10. package/package.json +7 -7
  11. package/src/App.tsx +16 -17
  12. package/src/components/EditorShell.tsx +3 -0
  13. package/src/components/TimelineToolbar.test.tsx +48 -3
  14. package/src/components/TimelineToolbar.tsx +89 -36
  15. package/src/components/editor/AnimationCard.test.tsx +18 -75
  16. package/src/components/editor/AnimationCard.tsx +26 -2
  17. package/src/components/editor/GsapAddAnimationControl.tsx +68 -0
  18. package/src/components/editor/GsapAnimationSection.tsx +18 -157
  19. package/src/components/editor/KeyframeEaseList.tsx +5 -1
  20. package/src/components/editor/KeyframeNavigation.tsx +46 -14
  21. package/src/components/editor/KeyframeNavigationDiamond.test.tsx +59 -0
  22. package/src/components/editor/MotionPathOverlay.tsx +32 -10
  23. package/src/components/editor/PropertyPanel.tsx +15 -5
  24. package/src/components/editor/PropertyPanelFlat.tsx +48 -23
  25. package/src/components/editor/gsapAnimationCallbacks.test.ts +122 -0
  26. package/src/components/editor/gsapAnimationCallbacks.ts +108 -4
  27. package/src/components/editor/keyframeRetime.test.ts +25 -22
  28. package/src/components/editor/keyframeRetime.ts +1 -2
  29. package/src/components/editor/propertyPanelFlatLayoutSection.tsx +2 -2
  30. package/src/components/editor/propertyPanelFlatMotionSection.tsx +25 -139
  31. package/src/components/editor/propertyPanelFlatTextSection.test.tsx +32 -16
  32. package/src/components/editor/propertyPanelFlatTextSection.tsx +21 -4
  33. package/src/components/nle/PreviewOverlays.tsx +3 -1
  34. package/src/components/nle/useTimelineEditCallbacks.test.ts +30 -0
  35. package/src/components/nle/useTimelineEditCallbacks.test.tsx +693 -0
  36. package/src/components/nle/useTimelineEditCallbacks.ts +197 -57
  37. package/src/components/sidebar/CompositionsTab.drag.test.tsx +41 -0
  38. package/src/components/sidebar/CompositionsTab.tsx +66 -32
  39. package/src/contexts/TimelineEditContext.tsx +1 -1
  40. package/src/hooks/deleteSelectedKeyframes.ts +24 -8
  41. package/src/hooks/gsapDragCommit.ts +2 -2
  42. package/src/hooks/gsapDragPositionCommit.ts +84 -5
  43. package/src/hooks/gsapKeyframeCacheHelpers.test.ts +176 -1
  44. package/src/hooks/gsapKeyframeCacheHelpers.ts +93 -52
  45. package/src/hooks/gsapRuntimeBridge.test.ts +100 -0
  46. package/src/hooks/gsapScriptCommitHelpers.ts +3 -2
  47. package/src/hooks/gsapScriptCommitTypes.ts +2 -0
  48. package/src/hooks/gsapShared.test.ts +109 -1
  49. package/src/hooks/gsapShared.ts +142 -17
  50. package/src/hooks/gsapTweenSynth.test.ts +30 -1
  51. package/src/hooks/gsapTweenSynth.ts +61 -14
  52. package/src/hooks/keyframeCacheAstLoad.ts +181 -0
  53. package/src/hooks/timelineEditingHelpers.test.ts +80 -0
  54. package/src/hooks/timelineMoveAdapter.ts +9 -2
  55. package/src/hooks/useDomEditWiring.ts +2 -2
  56. package/src/hooks/useDomSelection.ts +4 -0
  57. package/src/hooks/useEnableKeyframes.test.ts +122 -0
  58. package/src/hooks/useEnableKeyframes.ts +116 -56
  59. package/src/hooks/useGestureCommit.ts +2 -2
  60. package/src/hooks/useGsapAnimationFetchFallback.ts +1 -1
  61. package/src/hooks/useGsapKeyframeOps.test.tsx +92 -13
  62. package/src/hooks/useGsapKeyframeOps.ts +30 -13
  63. package/src/hooks/useGsapScriptCommits.test.tsx +21 -0
  64. package/src/hooks/useGsapScriptCommits.ts +2 -0
  65. package/src/hooks/useGsapSelectionHandlers.test.tsx +69 -3
  66. package/src/hooks/useGsapSelectionHandlers.ts +90 -65
  67. package/src/hooks/useGsapTweenCache.test.ts +16 -0
  68. package/src/hooks/useGsapTweenCache.ts +64 -178
  69. package/src/hooks/usePopulateKeyframeCacheForFile.test.tsx +68 -0
  70. package/src/hooks/useStudioContextValue.test.ts +93 -0
  71. package/src/hooks/useStudioContextValue.ts +17 -3
  72. package/src/hooks/useStudioTestHooks.ts +63 -0
  73. package/src/hooks/useTimelineEditing.test.tsx +187 -22
  74. package/src/hooks/useTimelineEditing.ts +43 -32
  75. package/src/hooks/useTimelineEditingTypes.ts +2 -0
  76. package/src/hooks/useTimelineGroupEditing.ts +106 -82
  77. package/src/player/components/KeyframeDiamondContextMenu.test.tsx +69 -0
  78. package/src/player/components/KeyframeDiamondContextMenu.tsx +19 -8
  79. package/src/player/components/LayerDisclosureRow.tsx +76 -0
  80. package/src/player/components/Player.test.ts +143 -12
  81. package/src/player/components/Player.tsx +84 -43
  82. package/src/player/components/Timeline.test.ts +420 -90
  83. package/src/player/components/Timeline.tsx +80 -78
  84. package/src/player/components/TimelineCanvas.tsx +33 -21
  85. package/src/player/components/TimelineClip.tsx +3 -1
  86. package/src/player/components/TimelineClipDiamonds.test.tsx +540 -16
  87. package/src/player/components/TimelineClipDiamonds.tsx +320 -117
  88. package/src/player/components/TimelineDiamondConnectors.tsx +145 -0
  89. package/src/player/components/TimelineLanes.tsx +153 -79
  90. package/src/player/components/TimelineOverlays.tsx +4 -18
  91. package/src/player/components/TimelinePropertyLanes.test.tsx +450 -0
  92. package/src/player/components/TimelinePropertyLanes.tsx +191 -0
  93. package/src/player/components/TimelineRazorInteraction.tsx +60 -0
  94. package/src/player/components/TimelineRuler.tsx +11 -21
  95. package/src/player/components/TimelineTrackHeader.test.tsx +346 -0
  96. package/src/player/components/TimelineTrackHeader.tsx +363 -0
  97. package/src/player/components/TrackClipCount.tsx +17 -0
  98. package/src/player/components/timelineCallbacks.ts +21 -6
  99. package/src/player/components/timelineClipDragPreview.test.ts +50 -3
  100. package/src/player/components/timelineClipDragPreview.ts +33 -15
  101. package/src/player/components/timelineCollision.ts +4 -13
  102. package/src/player/components/timelineDiamondTypes.ts +120 -0
  103. package/src/player/components/timelineDragDrop.ts +8 -3
  104. package/src/player/components/timelineEditing.test.ts +19 -27
  105. package/src/player/components/timelineEditing.ts +7 -8
  106. package/src/player/components/timelineKeyframeIdentity.test.ts +49 -0
  107. package/src/player/components/timelineKeyframeIdentity.ts +66 -0
  108. package/src/player/components/timelineLayout.test.ts +162 -9
  109. package/src/player/components/timelineLayout.ts +198 -38
  110. package/src/player/components/timelineMarquee.test.ts +76 -21
  111. package/src/player/components/timelineMarquee.ts +17 -14
  112. package/src/player/components/trackHeaderLaneState.ts +121 -0
  113. package/src/player/components/trackHeaderLaneValues.ts +139 -0
  114. package/src/player/components/useAutoExpandKeyframedClips.test.tsx +75 -0
  115. package/src/player/components/useAutoExpandKeyframedClips.ts +50 -0
  116. package/src/player/components/useTimelineClipDrag.ts +4 -1
  117. package/src/player/components/useTimelineGeometry.ts +4 -1
  118. package/src/player/components/useTimelineKeyframeHandlers.test.tsx +108 -0
  119. package/src/player/components/useTimelineKeyframeHandlers.ts +50 -15
  120. package/src/player/components/useTimelinePlayhead.ts +21 -12
  121. package/src/player/components/useTimelineRangeSelection.ts +35 -14
  122. package/src/player/components/useTimelineTrackLayout.test.ts +53 -0
  123. package/src/player/components/useTimelineTrackLayout.ts +162 -0
  124. package/src/player/hooks/useExpandedTimelineElements.test.ts +43 -0
  125. package/src/player/hooks/useExpandedTimelineElements.ts +21 -12
  126. package/src/player/lib/timelineElementHelpers.ts +26 -11
  127. package/src/player/store/keyframeSlice.ts +122 -0
  128. package/src/player/store/playerStore.test.ts +25 -0
  129. package/src/player/store/playerStore.ts +7 -43
  130. package/src/telemetry/events.test.ts +9 -3
  131. package/src/telemetry/events.ts +16 -7
  132. package/src/utils/globalTimeCompiler.test.ts +10 -0
  133. package/src/utils/globalTimeCompiler.ts +3 -3
  134. package/dist/assets/index-B37rWXo4.css +0 -1
  135. package/dist/assets/index-BdOfFsR8.js +0 -428
  136. package/src/player/components/TimelineDragGhost.tsx +0 -59
  137. package/src/utils/keyframeSelection.test.ts +0 -45
  138. package/src/utils/keyframeSelection.ts +0 -29
@@ -1,72 +1,40 @@
1
- import { memo, useRef, useState } from "react";
1
+ import { memo, useEffect, useRef, useState } from "react";
2
2
  import { BEAT_BAND_H } from "./BeatStrip";
3
3
  import {
4
4
  KEYFRAME_DRAG_THRESHOLD_PX,
5
5
  previewClipPct,
6
6
  resolveKeyframeDrag,
7
7
  } from "../../components/editor/keyframeDrag";
8
+ import { TimelineDiamondConnectors } from "./TimelineDiamondConnectors";
9
+ import { clipToTweenPercentage } from "../../components/editor/KeyframeNavigation";
10
+ import { LANE_H } from "./timelineLayout";
11
+ import { timelineKeyframeSelectionKey } from "./timelineKeyframeIdentity";
12
+ import {
13
+ DIAMOND_RATIO,
14
+ KF_MAX_PCT,
15
+ KF_MIN_PCT,
16
+ keyframeTarget,
17
+ type DragState,
18
+ type TimelineClipDiamondsProps,
19
+ type TimelineDiamondKeyframe,
20
+ type TimelineDiamondLaneProps,
21
+ } from "./timelineDiamondTypes";
8
22
 
9
- interface KeyframeEntry {
10
- percentage: number;
11
- /** Tween-relative percentage (the retime mutation keys on this, not clip %). */
12
- tweenPercentage?: number;
13
- properties: Record<string, number | string>;
14
- ease?: string;
15
- }
23
+ export type { TimelineDiamondKeyframe } from "./timelineDiamondTypes";
16
24
 
17
- interface KeyframeCacheEntry {
18
- format: string;
19
- keyframes: KeyframeEntry[];
20
- ease?: string;
21
- easeEach?: string;
22
- }
25
+ // Floor for a diamond's clickable width. The visual size still narrows to the
26
+ // neighbour gap so packed diamonds stay individually readable, but the hit box
27
+ // stops there: at the zoom floor the gap alone left a ~7px target, which is
28
+ // neither hittable nor selectable with any accuracy. Boxes may overlap slightly
29
+ // below this width; each diamond still owns the half-gap around its own centre.
30
+ const KF_MIN_HIT_W = 12;
23
31
 
24
- interface TimelineClipDiamondsProps {
25
- keyframesData: KeyframeCacheEntry;
26
- clipWidthPx: number;
27
- clipHeightPx: number;
28
- /** Beat-dot strip is shown on this track → shrink diamonds + drop them into
29
- * the bottom half so they clear the strip at the top. */
30
- beatsActive?: boolean;
31
- accentColor: string;
32
- isSelected: boolean;
33
- currentPercentage: number;
34
- elementId: string;
35
- selectedKeyframes: Set<string>;
36
- onClickKeyframe?: (percentage: number) => void;
37
- onShiftClickKeyframe?: (elementId: string, percentage: number) => void;
38
- onContextMenuKeyframe?: (e: React.MouseEvent, elementId: string, percentage: number) => void;
39
- /** Drag-to-retime: move a keyframe to a new time, preserving its value + ease.
40
- * Both percentages are clip-relative: `fromClipPercentage` identifies the
41
- * dragged keyframe, `toClipPercentage` is the neighbour-clamped drop position.
42
- * The handler decides move (within the tween) vs resize (past its boundary). */
43
- onMoveKeyframe?: (
44
- elementId: string,
45
- fromClipPercentage: number,
46
- toClipPercentage: number,
47
- ) => void;
48
- /** Set while resolving a diamond press so the ancestor clip's onClick (which
49
- * toggles selection off when already selected) ignores the native "click"
50
- * the browser auto-synthesizes after this button's pointerdown+pointerup. */
51
- suppressClickRef?: React.RefObject<boolean>;
32
+ /** A clip-% is a float division, so a raw tooltip reads `25.032499999999995%`. */
33
+ function roundPct(percentage: number): number {
34
+ return Math.round(percentage * 1000) / 1000;
52
35
  }
53
36
 
54
- const DIAMOND_RATIO = 0.8;
55
- // Percentage tolerance for rendering keyframes near clip boundaries. Keyframes
56
- // slightly outside [0, 100] (from rounding or stale cache during the async
57
- // persist → reload cycle) are still rendered (the clip is overflow-visible) at
58
- // their true position rather than hidden.
59
- const KF_MIN_PCT = -5;
60
- const KF_MAX_PCT = 105;
61
-
62
- type DragState = {
63
- kfKey: string;
64
- startX: number;
65
- fromClipPct: number;
66
- moved: boolean;
67
- };
68
-
69
- export const TimelineClipDiamonds = memo(function TimelineClipDiamonds({
37
+ export const TimelineDiamondLane = memo(function TimelineDiamondLane({
70
38
  keyframesData,
71
39
  clipWidthPx,
72
40
  clipHeightPx,
@@ -80,14 +48,72 @@ export const TimelineClipDiamonds = memo(function TimelineClipDiamonds({
80
48
  onShiftClickKeyframe,
81
49
  onContextMenuKeyframe,
82
50
  onMoveKeyframe,
51
+ onSelectSegment,
83
52
  suppressClickRef,
84
- }: TimelineClipDiamondsProps) {
53
+ groupAware = false,
54
+ globalEase = "none",
55
+ }: TimelineDiamondLaneProps) {
85
56
  // Hooks must run before the early return below.
86
57
  const dragRef = useRef<DragState | null>(null);
58
+ // Pending retime destination (clip + tween %) per keyframe key, so a rapid
59
+ // second drag composes from where the first move left the keyframe (whose
60
+ // cache entry has not rebuilt yet) instead of the stale rendered value.
61
+ const pendingRetimeRef = useRef<Map<string, { clipPct: number; tweenPct: number }> | null>(null);
62
+ // Lazy: `useRef(new Map())` allocates a Map on every render and throws all but
63
+ // the first away, once per mounted lane.
64
+ pendingRetimeRef.current ??= new Map();
65
+ const pendingRetimes = pendingRetimeRef.current;
66
+ // The most recent retime dispatched from this lane, whichever diamond it came
67
+ // from. Selection is lane-wide, so "is my revert still relevant" is a lane-wide
68
+ // question, not a per-keyframe one.
69
+ const latestRetimeRef = useRef<{ clipPct: number; tweenPct: number } | null>(null);
70
+ useEffect(() => {
71
+ // Clear a pending entry once the authoritative cache reflects THAT keyframe
72
+ // at ~its destination. Match by tolerance, not equality: cache writers round
73
+ // clip %s, so an exact check would leak an entry after every successful
74
+ // retime. Match by identity too: a bare "some keyframe is near that %" test
75
+ // cleared the entry whenever an unrelated sibling happened to sit there,
76
+ // which is easy to hit on an evenly spaced row.
77
+ const pendingEntries = pendingRetimeRef.current;
78
+ if (!pendingEntries) return;
79
+ for (const [key, pending] of pendingEntries) {
80
+ const settled = keyframesData.keyframes.some(
81
+ (k) =>
82
+ timelineKeyframeSelectionKey(elementId, keyframeTarget(k)) === key &&
83
+ Math.abs(k.percentage - pending.clipPct) < 0.2,
84
+ );
85
+ if (settled) pendingEntries.delete(key);
86
+ }
87
+ }, [keyframesData.keyframes, elementId]);
87
88
  // Visual-only preview of the dragged diamond's clip-% — no runtime/GSAP hold
88
89
  // (that optimistic hold was the #1763 flake). The atomic move-keyframe commit
89
90
  // on drop re-keys the diamond from source.
90
91
  const [preview, setPreview] = useState<{ kfKey: string; clipPct: number } | null>(null);
92
+ // One preview render per frame: a 120Hz trackpad fires pointermove far faster
93
+ // than the lane can repaint, and every diamond in the row re-evaluates its
94
+ // memo on each of those renders.
95
+ const previewFrameRef = useRef<number | null>(null);
96
+ const cancelPreviewFrame = () => {
97
+ if (previewFrameRef.current === null) return;
98
+ cancelAnimationFrame(previewFrameRef.current);
99
+ previewFrameRef.current = null;
100
+ };
101
+ // Escape backs out of an in-flight retime, the way clip and element drags
102
+ // already do. Nothing was written yet (the commit happens on pointerup), so
103
+ // dropping the preview is the whole undo.
104
+ useEffect(() => {
105
+ const onKeyDown = (event: KeyboardEvent) => {
106
+ if (event.key !== "Escape" || !dragRef.current || dragRef.current.cancelled) return;
107
+ dragRef.current.cancelled = true;
108
+ cancelPreviewFrame();
109
+ setPreview(null);
110
+ };
111
+ document.addEventListener("keydown", onKeyDown);
112
+ return () => {
113
+ document.removeEventListener("keydown", onKeyDown);
114
+ cancelPreviewFrame();
115
+ };
116
+ }, []);
91
117
  // The button element can re-render (reposition/unmount) synchronously from
92
118
  // the state updates onClickKeyframe/onMoveKeyframe trigger, before the
93
119
  // browser gets to auto-synthesize the "click" event that normally follows
@@ -108,15 +134,67 @@ export const TimelineClipDiamonds = memo(function TimelineClipDiamonds({
108
134
 
109
135
  // When the beat strip occupies the top band, shrink the diamonds and center
110
136
  // them in the remaining bottom region so they don't collide with it.
111
- const diamondSize = Math.round(clipHeightPx * (beatsActive ? 0.45 : DIAMOND_RATIO));
112
- const half = diamondSize / 2;
137
+ // One consistent keyframe-diamond size everywhere (clip bars + property lanes),
138
+ // matching the property-lane size (LANE_H · ratio). Beat-strip tracks still
139
+ // shrink to fit under the strip.
140
+ const diamondSize = beatsActive
141
+ ? Math.round(clipHeightPx * 0.45)
142
+ : Math.round(LANE_H * DIAMOND_RATIO);
113
143
  const centerY = beatsActive ? BEAT_BAND_H + (clipHeightPx - BEAT_BAND_H) / 2 : clipHeightPx / 2;
114
144
  const sorted = keyframesData.keyframes
115
145
  .filter((kf) => kf.percentage >= KF_MIN_PCT && kf.percentage <= KF_MAX_PCT)
116
146
  .sort((a, b) => a.percentage - b.percentage);
117
- // Clip-%s of the sorted keyframes the neighbour clamp (preview + drop) needs
118
- // the whole row to bound the dragged diamond between its immediate siblings.
119
- const sortedClipPcts = sorted.map((k) => k.percentage);
147
+ // The neighbour clamp bounds a dragged diamond between its immediate siblings
148
+ // so a retime can't reorder the tween. Siblings means "keyframes of the SAME
149
+ // tween": a merged row interleaves several animations, and two of them
150
+ // colliding at one percentage would otherwise pin each other's diamonds in
151
+ // place — the drag clamped back onto its own position and resolved to a click.
152
+ const siblingRowOf = (keyframe: TimelineDiamondKeyframe) =>
153
+ keyframe.animationId === undefined
154
+ ? sorted
155
+ : sorted.filter((k) => k.animationId === keyframe.animationId);
156
+ // Compose each sibling's pending destination in first: clamping against
157
+ // cached positions while the dragged keyframe reads its pending one let a
158
+ // second drag cross a neighbour that had already moved past it. Built once
159
+ // per render, keyed by tween: every diamond of a row needs the same row, and
160
+ // rebuilding + re-sorting it inside the marker loop below made this
161
+ // O(keyframes squared) allocations on every playhead tick.
162
+ const pendingClipPctOf = (keyframe: TimelineDiamondKeyframe) =>
163
+ pendingRetimes.get(timelineKeyframeSelectionKey(elementId, keyframeTarget(keyframe)))
164
+ ?.clipPct ?? keyframe.percentage;
165
+ const siblingRows = new Map<
166
+ string | undefined,
167
+ { keyframes: TimelineDiamondKeyframe[]; clipPcts: number[] }
168
+ >();
169
+ for (const keyframe of sorted) {
170
+ if (siblingRows.has(keyframe.animationId)) continue;
171
+ const row = siblingRowOf(keyframe)
172
+ .map((k) => ({ keyframe: k, clipPct: pendingClipPctOf(k) }))
173
+ .sort((a, b) => a.clipPct - b.clipPct);
174
+ siblingRows.set(keyframe.animationId, {
175
+ keyframes: row.map((s) => s.keyframe),
176
+ clipPcts: row.map((s) => s.clipPct),
177
+ });
178
+ }
179
+ const centerXOf = (percentage: number) =>
180
+ Math.max(0, Math.min(clipWidthPx, (percentage / 100) * clipWidthPx));
181
+ // One record per diamond, carrying its own geometry, so the connector and
182
+ // button passes below read neighbours as values instead of index lookups.
183
+ const markers = sorted.map((keyframe, index) => {
184
+ const centerX = centerXOf(keyframe.percentage);
185
+ const previous = sorted[index - 1];
186
+ const next = sorted[index + 1];
187
+ const previousGap = previous ? centerX - centerXOf(previous.percentage) : Infinity;
188
+ const nextGap = next ? centerXOf(next.percentage) - centerX : Infinity;
189
+ const nearestGap = Math.max(1, Math.min(previousGap, nextGap));
190
+ const gapWidth = Math.min(diamondSize, nearestGap);
191
+ return {
192
+ keyframe,
193
+ centerX,
194
+ hitWidth: Math.max(KF_MIN_HIT_W, gapWidth),
195
+ visualSize: gapWidth === diamondSize ? diamondSize : Math.max(2, gapWidth - 2),
196
+ };
197
+ });
120
198
  const baseColor = isSelected ? accentColor : "#a3a3a3";
121
199
  const baseOpacity = isSelected ? 0.4 : 0.25;
122
200
  const canDrag = isSelected && !!onMoveKeyframe;
@@ -135,39 +213,33 @@ export const TimelineClipDiamonds = memo(function TimelineClipDiamonds({
135
213
  pointerEvents: "none",
136
214
  }}
137
215
  >
138
- {sorted.map((kf, i) => {
139
- if (i === 0) return null;
140
- const prev = sorted[i - 1]!;
141
- const x1 = Math.max(0, Math.min(clipWidthPx, (prev.percentage / 100) * clipWidthPx));
142
- const x2 = Math.max(0, Math.min(clipWidthPx, (kf.percentage / 100) * clipWidthPx));
143
- if (x2 - x1 < 1) return null;
144
- return (
145
- <div
146
- key={`line-${i}-${prev.percentage}-${kf.percentage}`}
147
- className="absolute"
148
- style={{
149
- left: x1,
150
- top: centerY,
151
- width: x2 - x1,
152
- height: 2,
153
- transform: "translateY(-1px)",
154
- background: baseColor,
155
- opacity: baseOpacity,
156
- borderRadius: 1,
157
- }}
158
- />
159
- );
160
- })}
216
+ <TimelineDiamondConnectors
217
+ markers={markers}
218
+ centerY={centerY}
219
+ baseColor={baseColor}
220
+ baseOpacity={baseOpacity}
221
+ groupAware={groupAware}
222
+ globalEase={globalEase}
223
+ keyframeTarget={keyframeTarget}
224
+ onSelectSegment={onSelectSegment}
225
+ />
161
226
 
162
- {sorted.map((kf, i) => {
163
- const kfKey = `${elementId}:${kf.percentage}`;
227
+ {markers.map((marker, i) => {
228
+ const kf = marker.keyframe;
229
+ const target = keyframeTarget(kf);
230
+ const kfKey = timelineKeyframeSelectionKey(elementId, target);
231
+ // Clamp against this keyframe's own tween, not the whole merged row.
232
+ const siblingRow = siblingRows.get(kf.animationId);
233
+ const siblingClipPcts = siblingRow?.clipPcts ?? [];
234
+ const siblingIndex = siblingRow?.keyframes.indexOf(kf) ?? -1;
164
235
  // While dragging this diamond, render it at the live preview clip-%.
165
236
  const renderPct = preview?.kfKey === kfKey ? preview.clipPct : kf.percentage;
166
- // Center the diamond ON its keyframe %: left = (% · width) − half so the
167
- // diamond's midpoint sits exactly at the percentage. At 0% the midpoint
168
- // is the clip's left edge (the left half overflows, which the
169
- // overflow-visible clip shows) NOT shifted fully inside.
170
- const leftPx = (renderPct / 100) * clipWidthPx - half;
237
+ // Center the marker's non-overlapping hit region ON its keyframe %, so
238
+ // the diamond's midpoint sits exactly on the playhead/ruler x for that time.
239
+ // The 0% diamond's left half lands in the reserved left gutter (the
240
+ // content origin is inset past the label column, Figma-style) so it stays
241
+ // fully visible instead of being clipped by the sticky label column.
242
+ const leftPx = (renderPct / 100) * clipWidthPx - marker.hitWidth / 2;
171
243
  const isKfSelected = selectedKeyframes.has(kfKey);
172
244
  const atPlayhead = isSelected && Math.abs(kf.percentage - currentPercentage) < 0.5;
173
245
  const isHighlighted = isKfSelected || atPlayhead;
@@ -181,53 +253,72 @@ export const TimelineClipDiamonds = memo(function TimelineClipDiamonds({
181
253
  dragRef.current = {
182
254
  kfKey,
183
255
  startX: e.clientX,
184
- fromClipPct: kf.percentage,
256
+ lastX: e.clientX,
257
+ index: siblingIndex,
258
+ fromClipPct: pendingRetimes.get(kfKey)?.clipPct ?? kf.percentage,
185
259
  moved: false,
186
260
  };
187
261
  }
188
262
  };
189
263
  const onPointerMove = (e: React.PointerEvent<HTMLButtonElement>) => {
190
264
  const d = dragRef.current;
191
- if (!d || d.kfKey !== kfKey) return;
265
+ if (!d || d.kfKey !== kfKey || d.cancelled) return;
266
+ d.lastX = e.clientX;
192
267
  if (!d.moved && Math.abs(e.clientX - d.startX) >= KEYFRAME_DRAG_THRESHOLD_PX) {
193
268
  d.moved = true;
194
269
  }
195
- if (d.moved) {
270
+ if (!d.moved || previewFrameRef.current !== null) return;
271
+ previewFrameRef.current = requestAnimationFrame(() => {
272
+ previewFrameRef.current = null;
273
+ const live = dragRef.current;
274
+ if (!live || live.kfKey !== kfKey || live.cancelled) return;
196
275
  setPreview({
197
276
  kfKey,
198
277
  clipPct: previewClipPct({
199
- pointerDownX: d.startX,
200
- pointerMoveX: e.clientX,
278
+ pointerDownX: live.startX,
279
+ pointerMoveX: live.lastX,
201
280
  clipWidthPx,
202
- draggedClipPct: d.fromClipPct,
203
- draggedIndex: i,
204
- sortedClipPcts,
281
+ draggedClipPct: live.fromClipPct,
282
+ draggedIndex: live.index,
283
+ sortedClipPcts: siblingClipPcts,
205
284
  }),
206
285
  });
207
- }
286
+ });
208
287
  };
209
288
  const onPointerUp = (e: React.PointerEvent<HTMLButtonElement>) => {
210
289
  const d = dragRef.current;
290
+ if (d?.kfKey === kfKey && d.cancelled) {
291
+ // Escape already ended this drag; the release is not a click.
292
+ dragRef.current = null;
293
+ e.currentTarget.releasePointerCapture?.(e.pointerId);
294
+ suppressNextClick();
295
+ return;
296
+ }
211
297
  // No drag armed (canDrag false / non-primary press) → treat as a click.
212
298
  if (!d || d.kfKey !== kfKey) {
213
299
  if (e.button !== 0) return;
214
300
  suppressNextClick();
215
- if (e.shiftKey) onShiftClickKeyframe?.(elementId, kf.percentage);
216
- else onClickKeyframe?.(kf.percentage);
301
+ if (e.shiftKey) onShiftClickKeyframe?.(target);
302
+ else onClickKeyframe?.(target);
217
303
  return;
218
304
  }
219
305
  e.stopPropagation();
220
306
  dragRef.current = null;
307
+ cancelPreviewFrame();
221
308
  setPreview(null);
222
309
  e.currentTarget.releasePointerCapture?.(e.pointerId);
223
310
  suppressNextClick();
311
+ // Single-diamond retime by design: a multi-select drag would have to
312
+ // move every selected keyframe as one mutation, which the script ops
313
+ // do not express yet. Selecting several and dragging one moves only
314
+ // the dragged one.
224
315
  const res = resolveKeyframeDrag({
225
316
  pointerDownX: d.startX,
226
317
  pointerUpX: e.clientX,
227
318
  clipWidthPx,
228
319
  draggedClipPct: d.fromClipPct,
229
- draggedIndex: i,
230
- sortedClipPcts,
320
+ draggedIndex: siblingIndex,
321
+ sortedClipPcts: siblingClipPcts,
231
322
  });
232
323
  if (res.kind === "click" || res.kind === "noop") {
233
324
  // "noop" is a press with enough pointer jitter to arm a drag (canDrag
@@ -235,27 +326,92 @@ export const TimelineClipDiamonds = memo(function TimelineClipDiamonds({
235
326
  // back onto ~the same position — no real retime, so treat it as the
236
327
  // click it was. Otherwise a normal click with a few px of mouse/
237
328
  // trackpad drift silently does nothing: no selection, no move.
238
- if (e.shiftKey) onShiftClickKeyframe?.(elementId, kf.percentage);
239
- else onClickKeyframe?.(kf.percentage);
329
+ if (e.shiftKey) onShiftClickKeyframe?.(target);
330
+ else onClickKeyframe?.(target);
240
331
  } else if (res.kind === "move" && res.toClipPct != null) {
241
- onMoveKeyframe?.(elementId, d.fromClipPct, res.toClipPct);
332
+ const animKfs =
333
+ target.animationId === undefined
334
+ ? keyframesData.keyframes
335
+ : keyframesData.keyframes.filter((k) => k.animationId === target.animationId);
336
+ // Clamp to the mapped tween range: clipToTweenPercentage extrapolates
337
+ // linearly, so a boundary drag past the range would otherwise reselect
338
+ // an out-of-range tween % (e.g. 150%) even though the mutation clamps
339
+ // the moved endpoint back to the boundary.
340
+ const tweenPcts = animKfs
341
+ .map((k) => k.tweenPercentage)
342
+ .filter((v): v is number => typeof v === "number");
343
+ const clampTween = (v: number) =>
344
+ tweenPcts.length
345
+ ? Math.max(Math.min(...tweenPcts), Math.min(Math.max(...tweenPcts), v))
346
+ : v;
347
+ const newTweenPct = clampTween(clipToTweenPercentage(animKfs, res.toClipPct));
348
+ // For a rapid second retime the diamond still renders the stale cache
349
+ // position, so identify the FROM keyframe by the pending (already-moved)
350
+ // position; the mutation locates the source keyframe by this identity.
351
+ const pendingBefore = pendingRetimes.get(kfKey);
352
+ const fromTarget = pendingBefore
353
+ ? {
354
+ ...target,
355
+ percentage: pendingBefore.clipPct,
356
+ tweenPercentage: pendingBefore.tweenPct,
357
+ }
358
+ : target;
359
+ const pending = { clipPct: res.toClipPct, tweenPct: newTweenPct };
360
+ pendingRetimes.set(kfKey, pending);
361
+ latestRetimeRef.current = pending;
362
+ const clearPending = () => {
363
+ if (pendingRetimes.get(kfKey) === pending) {
364
+ pendingRetimes.delete(kfKey);
365
+ }
366
+ };
367
+ // A rejected drop (the destination time is already occupied) snaps
368
+ // the diamond back to its source position, so the pending entry AND
369
+ // the selection have to revert with it — parking on the ghost drop
370
+ // position strands the playhead + selection on a keyframe that does
371
+ // not exist there.
372
+ const revertRetime = () => {
373
+ // Only the newest gesture owns the selection. A rejected first drag
374
+ // whose commit settles after a second one started would otherwise
375
+ // park the selection back on ITS source keyframe, undoing a retime
376
+ // the user has already made and moving the playhead with it.
377
+ const isLatest = latestRetimeRef.current === pending;
378
+ clearPending();
379
+ if (isLatest) onClickKeyframe?.(fromTarget);
380
+ };
381
+ void onMoveKeyframe?.(fromTarget, res.toClipPct).then((committed) => {
382
+ if (!committed) revertRetime();
383
+ }, revertRetime);
242
384
  // A retime still targeted this exact diamond — park/select it at its
243
385
  // new position, same as a plain click, or a drag that actually moved
244
- // something looks identical to one that silently did nothing.
245
- onClickKeyframe?.(res.toClipPct);
386
+ // something looks identical to one that silently did nothing. Done
387
+ // optimistically so the gesture stays responsive; revertRetime puts
388
+ // it back if the move is rejected.
389
+ onClickKeyframe?.({
390
+ ...target,
391
+ percentage: res.toClipPct,
392
+ tweenPercentage: newTweenPct,
393
+ });
246
394
  }
247
395
  };
248
396
 
249
397
  return (
250
398
  <button
251
- key={`${i}-${kf.percentage}`}
399
+ // Key on the authored identity (tween-%), not the rendered clip-% or
400
+ // the row index: a clip resize, a neighbour's retime, or a re-sort
401
+ // changes both of those without changing WHICH keyframe this is, and
402
+ // a key change remounts the button mid-drag (losing pointer capture).
403
+ key={`${kf.animationId ?? i}:${kf.propertyGroup ?? ""}:${kf.tweenPercentage ?? kf.percentage}`}
252
404
  type="button"
253
405
  className="absolute"
406
+ data-keyframe-group={groupAware ? kf.propertyGroup : undefined}
407
+ data-keyframe-percentage={
408
+ groupAware ? (kf.tweenPercentage ?? kf.percentage) : undefined
409
+ }
254
410
  style={{
255
411
  left: leftPx,
256
412
  top: centerY,
257
413
  transform: "translateY(-50%)",
258
- width: diamondSize,
414
+ width: marker.hitWidth,
259
415
  height: diamondSize,
260
416
  zIndex: isHighlighted ? 2 : 1,
261
417
  pointerEvents: "auto",
@@ -264,18 +420,37 @@ export const TimelineClipDiamonds = memo(function TimelineClipDiamonds({
264
420
  cursor: canDrag ? "ew-resize" : "pointer",
265
421
  padding: 0,
266
422
  touchAction: "none",
423
+ display: "flex",
424
+ alignItems: "center",
425
+ justifyContent: "center",
426
+ overflow: "visible",
267
427
  }}
268
428
  onPointerDown={onPointerDown}
269
429
  onPointerMove={onPointerMove}
270
430
  onPointerUp={onPointerUp}
431
+ onPointerCancel={(e) => {
432
+ // Browser/OS cancellation (or lost capture) ends the drag without a
433
+ // pointerup, so clear the armed drag and preview or a ghost diamond
434
+ // stays stuck at the last previewed position.
435
+ if (dragRef.current?.kfKey !== kfKey) return;
436
+ dragRef.current = null;
437
+ cancelPreviewFrame();
438
+ setPreview(null);
439
+ e.currentTarget.releasePointerCapture?.(e.pointerId);
440
+ }}
271
441
  onContextMenu={(e) => {
272
442
  e.preventDefault();
273
443
  e.stopPropagation();
274
- onContextMenuKeyframe?.(e, elementId, kf.percentage);
444
+ onContextMenuKeyframe?.(e, target);
275
445
  }}
276
- title={`${kf.percentage}%`}
446
+ title={`${roundPct(kf.percentage)}%`}
277
447
  >
278
- <svg width={diamondSize} height={diamondSize} viewBox="0 0 10 10">
448
+ <svg
449
+ width={marker.visualSize}
450
+ height={marker.visualSize}
451
+ viewBox="0 0 10 10"
452
+ style={{ flexShrink: 0, pointerEvents: "none" }}
453
+ >
279
454
  {isKfSelected && (
280
455
  <path
281
456
  d="M5 0L10 5L5 10L0 5Z"
@@ -297,3 +472,31 @@ export const TimelineClipDiamonds = memo(function TimelineClipDiamonds({
297
472
  </div>
298
473
  );
299
474
  });
475
+
476
+ export const TimelineClipDiamonds = memo(function TimelineClipDiamonds(
477
+ props: TimelineClipDiamondsProps,
478
+ ) {
479
+ return (
480
+ <TimelineDiamondLane
481
+ {...props}
482
+ globalEase={props.keyframesData.ease}
483
+ onClickKeyframe={(target) => props.onClickKeyframe?.(props.elementId, target)}
484
+ onShiftClickKeyframe={(target) => props.onShiftClickKeyframe?.(props.elementId, target)}
485
+ onContextMenuKeyframe={(e, target) =>
486
+ props.onContextMenuKeyframe?.(e, props.elementId, target)
487
+ }
488
+ onMoveKeyframe={
489
+ props.onMoveKeyframe
490
+ ? (target, toClipPercentage) =>
491
+ props.onMoveKeyframe?.(props.elementId, target, toClipPercentage) ??
492
+ Promise.resolve(false)
493
+ : undefined
494
+ }
495
+ onSelectSegment={
496
+ props.onSelectSegment
497
+ ? (target) => props.onSelectSegment?.(props.elementId, target)
498
+ : undefined
499
+ }
500
+ />
501
+ );
502
+ });