@hyperframes/studio 0.7.75 → 0.7.77

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (134) hide show
  1. package/dist/assets/{hyperframes-player-BrfzYqX3.js → hyperframes-player-CEggaaxR.js} +1 -1
  2. package/dist/assets/{index-BvGKYVrH.js → index-B1INQNmj.js} +1 -1
  3. package/dist/assets/{index-CjGZkNxS.js → index-B1Tjjdse.js} +1 -1
  4. package/dist/assets/index-Bp4jAYZG.js +428 -0
  5. package/dist/assets/index-CBAfprvx.css +1 -0
  6. package/dist/index.d.ts +64 -17
  7. package/dist/index.html +2 -2
  8. package/dist/index.js +18040 -16293
  9. package/dist/index.js.map +1 -1
  10. package/package.json +7 -7
  11. package/src/App.tsx +16 -17
  12. package/src/components/EditorShell.tsx +3 -0
  13. package/src/components/TimelineToolbar.test.tsx +48 -3
  14. package/src/components/TimelineToolbar.tsx +89 -36
  15. package/src/components/editor/AnimationCard.test.tsx +18 -75
  16. package/src/components/editor/AnimationCard.tsx +26 -2
  17. package/src/components/editor/GsapAddAnimationControl.tsx +68 -0
  18. package/src/components/editor/GsapAnimationSection.tsx +18 -157
  19. package/src/components/editor/KeyframeEaseList.tsx +5 -1
  20. package/src/components/editor/KeyframeNavigation.tsx +46 -14
  21. package/src/components/editor/KeyframeNavigationDiamond.test.tsx +59 -0
  22. package/src/components/editor/MotionPathOverlay.tsx +32 -10
  23. package/src/components/editor/PropertyPanel.tsx +15 -5
  24. package/src/components/editor/PropertyPanelFlat.tsx +48 -23
  25. package/src/components/editor/gsapAnimationCallbacks.test.ts +122 -0
  26. package/src/components/editor/gsapAnimationCallbacks.ts +108 -4
  27. package/src/components/editor/keyframeRetime.test.ts +25 -22
  28. package/src/components/editor/keyframeRetime.ts +1 -2
  29. package/src/components/editor/propertyPanelFlatLayoutSection.tsx +2 -2
  30. package/src/components/editor/propertyPanelFlatMotionSection.tsx +25 -139
  31. package/src/components/editor/propertyPanelFlatTextSection.test.tsx +32 -16
  32. package/src/components/editor/propertyPanelFlatTextSection.tsx +21 -4
  33. package/src/components/nle/PreviewOverlays.tsx +3 -1
  34. package/src/components/nle/useTimelineEditCallbacks.test.ts +30 -0
  35. package/src/components/nle/useTimelineEditCallbacks.test.tsx +693 -0
  36. package/src/components/nle/useTimelineEditCallbacks.ts +197 -57
  37. package/src/contexts/TimelineEditContext.tsx +1 -1
  38. package/src/hooks/deleteSelectedKeyframes.ts +24 -8
  39. package/src/hooks/gsapDragCommit.ts +2 -2
  40. package/src/hooks/gsapDragPositionCommit.ts +84 -5
  41. package/src/hooks/gsapKeyframeCacheHelpers.test.ts +176 -1
  42. package/src/hooks/gsapKeyframeCacheHelpers.ts +93 -52
  43. package/src/hooks/gsapRuntimeBridge.test.ts +100 -0
  44. package/src/hooks/gsapScriptCommitHelpers.ts +3 -2
  45. package/src/hooks/gsapScriptCommitTypes.ts +2 -0
  46. package/src/hooks/gsapShared.test.ts +109 -1
  47. package/src/hooks/gsapShared.ts +142 -17
  48. package/src/hooks/gsapTweenSynth.test.ts +30 -1
  49. package/src/hooks/gsapTweenSynth.ts +61 -14
  50. package/src/hooks/keyframeCacheAstLoad.ts +181 -0
  51. package/src/hooks/timelineEditingHelpers.test.ts +80 -0
  52. package/src/hooks/timelineMoveAdapter.ts +9 -2
  53. package/src/hooks/useDomEditWiring.ts +2 -2
  54. package/src/hooks/useDomSelection.ts +4 -0
  55. package/src/hooks/useEnableKeyframes.test.ts +122 -0
  56. package/src/hooks/useEnableKeyframes.ts +116 -56
  57. package/src/hooks/useGestureCommit.ts +2 -2
  58. package/src/hooks/useGsapAnimationFetchFallback.ts +1 -1
  59. package/src/hooks/useGsapKeyframeOps.test.tsx +92 -13
  60. package/src/hooks/useGsapKeyframeOps.ts +30 -13
  61. package/src/hooks/useGsapScriptCommits.test.tsx +21 -0
  62. package/src/hooks/useGsapScriptCommits.ts +2 -0
  63. package/src/hooks/useGsapSelectionHandlers.test.tsx +69 -3
  64. package/src/hooks/useGsapSelectionHandlers.ts +90 -65
  65. package/src/hooks/useGsapTweenCache.test.ts +16 -0
  66. package/src/hooks/useGsapTweenCache.ts +64 -178
  67. package/src/hooks/usePopulateKeyframeCacheForFile.test.tsx +68 -0
  68. package/src/hooks/useStudioContextValue.test.ts +93 -0
  69. package/src/hooks/useStudioContextValue.ts +17 -3
  70. package/src/hooks/useStudioTestHooks.ts +63 -0
  71. package/src/hooks/useTimelineEditing.test.tsx +187 -22
  72. package/src/hooks/useTimelineEditing.ts +43 -32
  73. package/src/hooks/useTimelineEditingTypes.ts +2 -0
  74. package/src/hooks/useTimelineGroupEditing.ts +106 -82
  75. package/src/player/components/KeyframeDiamondContextMenu.test.tsx +69 -0
  76. package/src/player/components/KeyframeDiamondContextMenu.tsx +19 -8
  77. package/src/player/components/LayerDisclosureRow.tsx +76 -0
  78. package/src/player/components/Timeline.test.ts +420 -90
  79. package/src/player/components/Timeline.tsx +80 -78
  80. package/src/player/components/TimelineCanvas.tsx +33 -21
  81. package/src/player/components/TimelineClip.tsx +3 -1
  82. package/src/player/components/TimelineClipDiamonds.test.tsx +540 -16
  83. package/src/player/components/TimelineClipDiamonds.tsx +320 -117
  84. package/src/player/components/TimelineDiamondConnectors.tsx +145 -0
  85. package/src/player/components/TimelineLanes.tsx +153 -79
  86. package/src/player/components/TimelineOverlays.tsx +4 -18
  87. package/src/player/components/TimelinePropertyLanes.test.tsx +450 -0
  88. package/src/player/components/TimelinePropertyLanes.tsx +191 -0
  89. package/src/player/components/TimelineRazorInteraction.tsx +60 -0
  90. package/src/player/components/TimelineRuler.tsx +11 -21
  91. package/src/player/components/TimelineTrackHeader.test.tsx +346 -0
  92. package/src/player/components/TimelineTrackHeader.tsx +363 -0
  93. package/src/player/components/TrackClipCount.tsx +17 -0
  94. package/src/player/components/timelineCallbacks.ts +21 -6
  95. package/src/player/components/timelineClipDragPreview.test.ts +50 -3
  96. package/src/player/components/timelineClipDragPreview.ts +33 -15
  97. package/src/player/components/timelineCollision.ts +4 -13
  98. package/src/player/components/timelineDiamondTypes.ts +120 -0
  99. package/src/player/components/timelineDragDrop.ts +8 -3
  100. package/src/player/components/timelineEditing.test.ts +19 -27
  101. package/src/player/components/timelineEditing.ts +7 -8
  102. package/src/player/components/timelineKeyframeIdentity.test.ts +49 -0
  103. package/src/player/components/timelineKeyframeIdentity.ts +66 -0
  104. package/src/player/components/timelineLayout.test.ts +162 -9
  105. package/src/player/components/timelineLayout.ts +198 -38
  106. package/src/player/components/timelineMarquee.test.ts +76 -21
  107. package/src/player/components/timelineMarquee.ts +17 -14
  108. package/src/player/components/trackHeaderLaneState.ts +121 -0
  109. package/src/player/components/trackHeaderLaneValues.ts +139 -0
  110. package/src/player/components/useAutoExpandKeyframedClips.test.tsx +75 -0
  111. package/src/player/components/useAutoExpandKeyframedClips.ts +50 -0
  112. package/src/player/components/useTimelineClipDrag.ts +4 -1
  113. package/src/player/components/useTimelineGeometry.ts +4 -1
  114. package/src/player/components/useTimelineKeyframeHandlers.test.tsx +108 -0
  115. package/src/player/components/useTimelineKeyframeHandlers.ts +50 -15
  116. package/src/player/components/useTimelinePlayhead.ts +21 -12
  117. package/src/player/components/useTimelineRangeSelection.ts +35 -14
  118. package/src/player/components/useTimelineTrackLayout.test.ts +53 -0
  119. package/src/player/components/useTimelineTrackLayout.ts +162 -0
  120. package/src/player/hooks/useExpandedTimelineElements.test.ts +43 -0
  121. package/src/player/hooks/useExpandedTimelineElements.ts +21 -12
  122. package/src/player/lib/timelineElementHelpers.ts +26 -11
  123. package/src/player/store/keyframeSlice.ts +122 -0
  124. package/src/player/store/playerStore.test.ts +25 -0
  125. package/src/player/store/playerStore.ts +7 -43
  126. package/src/telemetry/events.test.ts +9 -3
  127. package/src/telemetry/events.ts +16 -7
  128. package/src/utils/globalTimeCompiler.test.ts +10 -0
  129. package/src/utils/globalTimeCompiler.ts +3 -3
  130. package/dist/assets/index-B37rWXo4.css +0 -1
  131. package/dist/assets/index-DzDajONI.js +0 -428
  132. package/src/player/components/TimelineDragGhost.tsx +0 -59
  133. package/src/utils/keyframeSelection.test.ts +0 -45
  134. package/src/utils/keyframeSelection.ts +0 -29
@@ -0,0 +1,363 @@
1
+ import { Eye, EyeSlash } from "@phosphor-icons/react";
2
+ import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
3
+ import { Music } from "../../icons/SystemIcons";
4
+ import type { TimelineElement } from "../store/playerStore";
5
+ import type { TimelineEditCallbacks } from "./timelineCallbacks";
6
+ import { getTimelinePropertyLanes } from "./TimelinePropertyLanes";
7
+ import { LayerDisclosureRow } from "./LayerDisclosureRow";
8
+ import { TrackClipCount } from "./TrackClipCount";
9
+ import { LABEL_COL_W, LANE_H, getTimelineLaneTop } from "./timelineLayout";
10
+ import type { TimelineTheme } from "./timelineTheme";
11
+ import {
12
+ resolveLaneHeaderState,
13
+ type KeyframeNavigationState,
14
+ type TimelinePropertyLane,
15
+ } from "./trackHeaderLaneState";
16
+ import { valueReadout } from "./trackHeaderLaneValues";
17
+
18
+ interface TimelineTrackHeaderProps {
19
+ trackNumber: number;
20
+ trackLabel: string;
21
+ contentOrigin: number;
22
+ /** The track's active keyframe clip (selected, else primary) — the one whose
23
+ * disclosure + property rows this header shows, whether expanded or not. */
24
+ keyframeClip: TimelineElement | null;
25
+ /** Clips on this track, so the header can say how many the row holds. */
26
+ clipCount: number;
27
+ isExpanded: boolean;
28
+ animations: readonly GsapAnimation[];
29
+ currentTime: number;
30
+ isTrackHidden: boolean;
31
+ isAudioTrack: boolean;
32
+ theme: TimelineTheme;
33
+ onToggleClipExpanded: () => void;
34
+ onToggleTrackHidden: TimelineEditCallbacks["onToggleTrackHidden"];
35
+ onTogglePropertyGroupKeyframe?: TimelineEditCallbacks["onTogglePropertyGroupKeyframe"];
36
+ onSeek?: (time: number) => void;
37
+ }
38
+
39
+ function VisibilityButton({
40
+ hidden,
41
+ trackNumber,
42
+ visible,
43
+ onToggle,
44
+ }: {
45
+ hidden: boolean;
46
+ trackNumber: number;
47
+ visible: boolean;
48
+ onToggle: TimelineEditCallbacks["onToggleTrackHidden"];
49
+ }) {
50
+ if (!visible) return <span aria-hidden="true" className="h-6 w-6 shrink-0" />;
51
+ const label = hidden ? `Show track ${trackNumber}` : `Hide track ${trackNumber}`;
52
+ return (
53
+ <button
54
+ type="button"
55
+ aria-label={label}
56
+ title={label}
57
+ className={`flex h-6 w-6 shrink-0 items-center justify-center rounded border-0 bg-transparent p-0 transition-colors focus-visible:outline focus-visible:outline-1 focus-visible:outline-offset-[-1px] focus-visible:outline-[#3CE6AC] ${
58
+ hidden ? "text-[#3CE6AC] hover:text-white" : "text-white/35 hover:text-white/75"
59
+ }`}
60
+ onPointerDown={(event) => event.stopPropagation()}
61
+ onClick={(event) => {
62
+ event.stopPropagation();
63
+ void onToggle?.(trackNumber, !hidden);
64
+ }}
65
+ >
66
+ {hidden ? (
67
+ <EyeSlash size={14} weight="bold" aria-hidden="true" />
68
+ ) : (
69
+ <Eye size={14} weight="bold" aria-hidden="true" />
70
+ )}
71
+ </button>
72
+ );
73
+ }
74
+
75
+ // The header a track gets when it has no keyframe clip to disclose: label, clip
76
+ // count, eye. Not deprecated — it is the live path for every track without lanes.
77
+ function PlainTrackHeader({
78
+ trackNumber,
79
+ trackLabel,
80
+ clipCount,
81
+ showTrackLabel,
82
+ isTrackHidden,
83
+ isAudioTrack,
84
+ onToggleTrackHidden,
85
+ }: Pick<
86
+ TimelineTrackHeaderProps,
87
+ | "trackNumber"
88
+ | "trackLabel"
89
+ | "clipCount"
90
+ | "isTrackHidden"
91
+ | "isAudioTrack"
92
+ | "onToggleTrackHidden"
93
+ > & { showTrackLabel: boolean }) {
94
+ return (
95
+ <>
96
+ {isAudioTrack && (
97
+ <Music size={12} weight="fill" aria-hidden="true" className="text-white/35" />
98
+ )}
99
+ {showTrackLabel && (
100
+ <span className="min-w-0 flex-1 truncate text-[11px]" title={trackLabel}>
101
+ {trackLabel}
102
+ </span>
103
+ )}
104
+ {showTrackLabel && <TrackClipCount clipCount={clipCount} />}
105
+ <VisibilityButton
106
+ hidden={isTrackHidden}
107
+ trackNumber={trackNumber}
108
+ visible
109
+ onToggle={onToggleTrackHidden}
110
+ />
111
+ </>
112
+ );
113
+ }
114
+
115
+ // Figma layout: prev-keyframe ‹, the add/remove toggle (children), next ›.
116
+ function PropertyGroupNavigation({
117
+ navigation,
118
+ label,
119
+ expandedElement,
120
+ onSeek,
121
+ children,
122
+ }: {
123
+ navigation: KeyframeNavigationState;
124
+ label: string;
125
+ expandedElement: TimelineElement;
126
+ onSeek?: (time: number) => void;
127
+ children: React.ReactNode;
128
+ }) {
129
+ // The 12x20px glyph is all the lane row has room for, so the WCAG 24x24
130
+ // target is met with a centered transparent ::before overlay instead of a
131
+ // bigger box; focus-visible matches every other control in this header.
132
+ const CHEVRON_BUTTON_CLASS =
133
+ "relative h-5 w-3 border-0 bg-transparent p-0 text-white/55 hover:text-white disabled:text-white/15 " +
134
+ "focus-visible:outline focus-visible:outline-1 focus-visible:outline-[#3CE6AC] " +
135
+ "before:absolute before:left-1/2 before:top-1/2 before:h-6 before:w-6 " +
136
+ "before:-translate-x-1/2 before:-translate-y-1/2 before:content-['']";
137
+ const seekTo = (keyframe: { percentage: number } | null) => {
138
+ if (keyframe) {
139
+ onSeek?.(expandedElement.start + (keyframe.percentage / 100) * expandedElement.duration);
140
+ }
141
+ };
142
+ return (
143
+ <span className="flex shrink-0 items-center gap-0.5">
144
+ <button
145
+ type="button"
146
+ aria-label={`Previous ${label} keyframe`}
147
+ disabled={!navigation.prevKeyframe}
148
+ className={CHEVRON_BUTTON_CLASS}
149
+ onClick={(event) => {
150
+ event.stopPropagation();
151
+ seekTo(navigation.prevKeyframe);
152
+ }}
153
+ >
154
+
155
+ </button>
156
+ {children}
157
+ <button
158
+ type="button"
159
+ aria-label={`Next ${label} keyframe`}
160
+ disabled={!navigation.nextKeyframe}
161
+ className={CHEVRON_BUTTON_CLASS}
162
+ onClick={(event) => {
163
+ event.stopPropagation();
164
+ seekTo(navigation.nextKeyframe);
165
+ }}
166
+ >
167
+
168
+ </button>
169
+ </span>
170
+ );
171
+ }
172
+
173
+ function PropertyGroupHeaderRow({
174
+ lane,
175
+ laneIndex,
176
+ isLastLane,
177
+ expandedElement,
178
+ currentTime,
179
+ clipPercentage,
180
+ gutterBackground,
181
+ columnWidth,
182
+ onTogglePropertyGroupKeyframe,
183
+ onSeek,
184
+ }: {
185
+ lane: TimelinePropertyLane;
186
+ laneIndex: number;
187
+ isLastLane: boolean;
188
+ expandedElement: TimelineElement;
189
+ currentTime: number;
190
+ clipPercentage: number;
191
+ gutterBackground: string;
192
+ columnWidth: number;
193
+ onTogglePropertyGroupKeyframe?: TimelineEditCallbacks["onTogglePropertyGroupKeyframe"];
194
+ onSeek?: (time: number) => void;
195
+ }) {
196
+ const { navigation, values, label, toggleTarget } = resolveLaneHeaderState(
197
+ lane,
198
+ currentTime,
199
+ clipPercentage,
200
+ );
201
+
202
+ return (
203
+ <div
204
+ data-property-group={lane.group}
205
+ data-timeline-lane-top={getTimelineLaneTop(laneIndex)}
206
+ className="absolute left-0 flex items-center gap-1 overflow-hidden px-1.5 text-[10px] text-white/65"
207
+ style={{
208
+ top: getTimelineLaneTop(laneIndex),
209
+ // The header column narrows to contentOrigin whenever that is under
210
+ // LABEL_COL_W; a lane row pinned to LABEL_COL_W then hangs its value
211
+ // readout over the canvas, on top of the clips it is labelling.
212
+ width: columnWidth,
213
+ height: LANE_H,
214
+ background: gutterBackground,
215
+ }}
216
+ >
217
+ {/* Tree connector: vertical spine (top-half on the last lane) + branch tick. */}
218
+ <span className="relative h-full w-3 shrink-0" aria-hidden="true">
219
+ <span
220
+ className="absolute left-1.5 top-0 w-px bg-white/15"
221
+ style={{ height: isLastLane ? "50%" : "100%" }}
222
+ />
223
+ <span className="absolute left-1.5 top-1/2 h-px w-1.5 bg-white/15" />
224
+ </span>
225
+ <span className="w-[46px] shrink-0 truncate text-white" title={label}>
226
+ {label}
227
+ </span>
228
+ <PropertyGroupNavigation
229
+ navigation={navigation}
230
+ label={label}
231
+ expandedElement={expandedElement}
232
+ onSeek={onSeek}
233
+ >
234
+ <button
235
+ type="button"
236
+ aria-pressed={!!navigation.currentKeyframe}
237
+ aria-label={`${navigation.currentKeyframe ? "Remove" : "Add"} ${label} keyframe`}
238
+ title={`${navigation.currentKeyframe ? "Remove" : "Add"} ${label} keyframe`}
239
+ // h-6 w-6 = the 24x24 WCAG 2.2 minimum target; the ◆ glyph stays 11px.
240
+ className="flex h-6 w-6 shrink-0 items-center justify-center border-0 bg-transparent p-0 text-[11px] text-[#3CE6AC] focus-visible:outline focus-visible:outline-1 focus-visible:outline-[#3CE6AC]"
241
+ onClick={(event) => {
242
+ // Same as the disclosure caret and the eye: a control in the label
243
+ // column owns its click, it does not also hit the track row behind it.
244
+ event.stopPropagation();
245
+ if (toggleTarget) {
246
+ void onTogglePropertyGroupKeyframe?.(expandedElement, toggleTarget);
247
+ }
248
+ }}
249
+ >
250
+ {navigation.currentKeyframe ? "◆" : "◇"}
251
+ </button>
252
+ </PropertyGroupNavigation>
253
+ <span
254
+ className="min-w-0 flex-1 truncate text-right tabular-nums text-white/45"
255
+ title={valueReadout(lane.group, values)}
256
+ >
257
+ {valueReadout(lane.group, values)}
258
+ </span>
259
+ </div>
260
+ );
261
+ }
262
+
263
+ export function TimelineTrackHeader({
264
+ trackNumber,
265
+ trackLabel,
266
+ contentOrigin,
267
+ keyframeClip,
268
+ clipCount,
269
+ isExpanded,
270
+ animations,
271
+ currentTime,
272
+ isTrackHidden,
273
+ isAudioTrack,
274
+ theme,
275
+ onToggleClipExpanded,
276
+ onToggleTrackHidden,
277
+ onTogglePropertyGroupKeyframe,
278
+ onSeek,
279
+ }: TimelineTrackHeaderProps) {
280
+ const clipPercentage = keyframeClip
281
+ ? ((currentTime - keyframeClip.start) / keyframeClip.duration) * 100
282
+ : 0;
283
+ const lanes = keyframeClip
284
+ ? getTimelinePropertyLanes(animations, keyframeClip.start, keyframeClip.duration)
285
+ : [];
286
+ // Label mode = keyframe view; the label column stays LABEL_COL_W (Timeline.tsx
287
+ // owns the gutter past it, so a 0% diamond isn't clipped by this panel).
288
+ const showTrackLabel = contentOrigin >= LABEL_COL_W;
289
+ const isKeyframeLayer = !!keyframeClip && lanes.length > 0;
290
+ const lanesId = `timeline-lanes-track-${trackNumber}`;
291
+
292
+ return (
293
+ <div
294
+ className={`sticky left-0 z-[12] shrink-0 ${
295
+ !isKeyframeLayer
296
+ ? showTrackLabel
297
+ ? "flex items-center gap-1 px-1.5 text-white/55"
298
+ : "flex flex-col items-center justify-center gap-0.5"
299
+ : ""
300
+ }`}
301
+ style={{
302
+ width: showTrackLabel ? LABEL_COL_W : contentOrigin,
303
+ background: theme.gutterBackground,
304
+ borderRight: `1px solid ${theme.gutterBorder}`,
305
+ }}
306
+ >
307
+ {!keyframeClip || lanes.length === 0 ? (
308
+ <PlainTrackHeader
309
+ trackNumber={trackNumber}
310
+ trackLabel={trackLabel}
311
+ clipCount={clipCount}
312
+ showTrackLabel={showTrackLabel}
313
+ isTrackHidden={isTrackHidden}
314
+ isAudioTrack={isAudioTrack}
315
+ onToggleTrackHidden={onToggleTrackHidden}
316
+ />
317
+ ) : (
318
+ <>
319
+ <LayerDisclosureRow
320
+ keyframeClip={keyframeClip}
321
+ clipCount={clipCount}
322
+ isExpanded={isExpanded}
323
+ gutterBackground={theme.gutterBackground}
324
+ columnWidth={showTrackLabel ? LABEL_COL_W : contentOrigin}
325
+ lanesId={lanesId}
326
+ onToggleClipExpanded={onToggleClipExpanded}
327
+ >
328
+ {/* The eye belongs to the LAYER, so it lives on the always-mounted
329
+ layer row exactly like a plain track's. Hanging it off a lane row
330
+ (hover-gated, and only while expanded) left a keyframed track with
331
+ no way to be hidden at all by keyboard, and put the control on a
332
+ row it does not act on. */}
333
+ <VisibilityButton
334
+ hidden={isTrackHidden}
335
+ trackNumber={trackNumber}
336
+ visible
337
+ onToggle={onToggleTrackHidden}
338
+ />
339
+ </LayerDisclosureRow>
340
+ {/* Always mounted so the caret's aria-controls resolves in both states. */}
341
+ <div id={lanesId}>
342
+ {isExpanded &&
343
+ lanes.map((lane, laneIndex) => (
344
+ <PropertyGroupHeaderRow
345
+ key={lane.group}
346
+ lane={lane}
347
+ laneIndex={laneIndex}
348
+ isLastLane={laneIndex === lanes.length - 1}
349
+ expandedElement={keyframeClip}
350
+ currentTime={currentTime}
351
+ clipPercentage={clipPercentage}
352
+ gutterBackground={theme.gutterBackground}
353
+ columnWidth={showTrackLabel ? LABEL_COL_W : contentOrigin}
354
+ onTogglePropertyGroupKeyframe={onTogglePropertyGroupKeyframe}
355
+ onSeek={onSeek}
356
+ />
357
+ ))}
358
+ </div>
359
+ </>
360
+ )}
361
+ </div>
362
+ );
363
+ }
@@ -0,0 +1,17 @@
1
+ /**
2
+ * How many clips the track holds, shown next to the track's identity so a
3
+ * multi-clip track reads as one at a glance. A single-clip track shows nothing:
4
+ * the clip bar already says "one", and the header stays low-density.
5
+ */
6
+ export function TrackClipCount({ clipCount }: { clipCount: number }) {
7
+ if (clipCount < 2) return null;
8
+ return (
9
+ <span
10
+ aria-label={`${clipCount} clips`}
11
+ title={`${clipCount} clips`}
12
+ className="shrink-0 rounded-full bg-white/10 px-1 text-[9px] leading-[14px] tabular-nums text-white/55"
13
+ >
14
+ {clipCount}
15
+ </span>
16
+ );
17
+ }
@@ -3,6 +3,16 @@
3
3
  import type { TimelineElement } from "../store/playerStore";
4
4
  import type { TimelineMoveOperation } from "../../hooks/timelineMoveAdapter";
5
5
  import type { BlockedTimelineEditIntent } from "./timelineEditing";
6
+ import type { PropertyGroupName } from "@hyperframes/core/gsap-parser";
7
+ import type { TimelineKeyframeTarget } from "./timelineKeyframeIdentity";
8
+
9
+ export interface TimelinePropertyGroupKeyframeToggle {
10
+ animationId: string;
11
+ propertyGroup: PropertyGroupName;
12
+ tweenPercentage: number;
13
+ properties: Record<string, number | string>;
14
+ remove: boolean;
15
+ }
6
16
 
7
17
  /**
8
18
  * Shared callback signatures for timeline editing operations.
@@ -62,14 +72,19 @@ export interface TimelineEditCallbacks {
62
72
  onSplitElement?: (element: TimelineElement, splitTime: number) => Promise<void> | void;
63
73
  onRazorSplit?: (element: TimelineElement, splitTime: number) => Promise<void> | void;
64
74
  onRazorSplitAll?: (splitTime: number) => Promise<void> | void;
65
- onDeleteKeyframe?: (elementId: string, percentage: number) => void;
66
- onDeleteAllKeyframes?: (elementId: string) => void;
67
- onChangeKeyframeEase?: (elementId: string, percentage: number, ease: string) => void;
68
- onMoveKeyframeToPlayhead?: (elementId: string, percentage: number) => void;
75
+ onDeleteKeyframe?: (elementId: string, keyframe: TimelineKeyframeTarget) => void;
76
+ onDeleteAllKeyframes?: (element: TimelineElement) => void;
77
+ onMoveKeyframeToPlayhead?: (element: TimelineElement, keyframe: TimelineKeyframeTarget) => void;
78
+ /** Drag-to-retime: `keyframe` identifies the dragged keyframe (its percentage
79
+ * is clip-relative), `toClipPercentage` is the neighbour-clamped drop. */
69
80
  onMoveKeyframe?: (
70
81
  elementId: string,
71
- fromClipPercentage: number,
82
+ keyframe: TimelineKeyframeTarget,
72
83
  toClipPercentage: number,
73
- ) => void;
84
+ ) => Promise<boolean>;
74
85
  onToggleKeyframeAtPlayhead?: (element: TimelineElement) => void;
86
+ onTogglePropertyGroupKeyframe?: (
87
+ element: TimelineElement,
88
+ target: TimelinePropertyGroupKeyframeToggle,
89
+ ) => Promise<void> | void;
75
90
  }
@@ -6,7 +6,7 @@ import {
6
6
  type DragPreviewContext,
7
7
  } from "./timelineClipDragPreview";
8
8
  import type { DraggedClipState } from "./timelineClipDragTypes";
9
- import { RULER_H, TRACKS_TOP_PAD, TRACK_H } from "./timelineLayout";
9
+ import { LANE_H, RULER_H, TRACKS_TOP_PAD, TRACK_H } from "./timelineLayout";
10
10
 
11
11
  // ─────────────────────────────────────────────────────────────────────────────
12
12
  // Regression bed for the live-reproduced BUG 1: a PLAIN HORIZONTAL drag of a clip
@@ -55,13 +55,17 @@ function fakeScroll(): HTMLDivElement {
55
55
  } as unknown as HTMLDivElement;
56
56
  }
57
57
 
58
- function ctx(): DragPreviewContext {
58
+ function ctx(
59
+ rowHeights?: readonly number[],
60
+ elements: TimelineElement[] = fixtureElements,
61
+ ): DragPreviewContext {
59
62
  return {
60
63
  scroll: fakeScroll(),
61
64
  pps: PPS,
62
65
  duration: 44.5,
63
66
  trackOrder: [0, 1, 2],
64
- elements: fixtureElements,
67
+ elements,
68
+ rowHeights,
65
69
  selectedKeys: new Set<string>(),
66
70
  buildSnapTargets: () => [],
67
71
  audioTracks: new Set<number>(),
@@ -145,6 +149,49 @@ describe("computeDragPreview — plain horizontal drag never arms a phantom inse
145
149
  const next = computeDragPreview(drag, originClientX, yForRow(-0.6), ctx());
146
150
  expect(next.insertRow).toBe(0); // a new TOP track will be created on drop
147
151
  });
152
+
153
+ it("keeps a horizontal drag in the body of an expanded row out of insert mode", () => {
154
+ const rowHeights = [TRACK_H + 2 * LANE_H, TRACK_H, TRACK_H];
155
+ const clientY = RULER_H + TRACKS_TOP_PAD + rowHeights[0] - 8;
156
+ const { drag, clientX } = horizontalDrag(moodboard, 0.5, 2);
157
+ const next = computeDragPreview(
158
+ { ...drag, originClientY: clientY, pointerClientY: clientY },
159
+ clientX,
160
+ clientY,
161
+ ctx(rowHeights),
162
+ );
163
+ expect(next.insertRow).toBeNull();
164
+ expect(next.previewTrack).toBe(0);
165
+ });
166
+
167
+ it("uses the expanded row midpoint when choosing the side for an automatic insert", () => {
168
+ const rowHeights = [TRACK_H + 2 * LANE_H, TRACK_H];
169
+ const dragged = clip("dragged", 0, 0, 1, 3);
170
+ const occupied = [dragged, clip("block-0", 0, 0, 1, 2), clip("block-1", 1, 0, 1, 1)];
171
+ const clientY = RULER_H + TRACKS_TOP_PAD + 30;
172
+ const drag: DraggedClipState = {
173
+ element: dragged,
174
+ originClientX: 0,
175
+ originClientY: clientY,
176
+ originScrollLeft: 0,
177
+ originScrollTop: 0,
178
+ pointerClientX: 0,
179
+ pointerClientY: clientY,
180
+ pointerOffsetX: 0,
181
+ pointerOffsetY: 0,
182
+ previewStart: 0,
183
+ previewTrack: 0,
184
+ insertRow: null,
185
+ snapTime: null,
186
+ snapType: null,
187
+ started: true,
188
+ };
189
+ const next = computeDragPreview(drag, 0, clientY, {
190
+ ...ctx(rowHeights, occupied),
191
+ trackOrder: [0, 1],
192
+ });
193
+ expect(next.insertRow).toBe(0);
194
+ });
148
195
  });
149
196
 
150
197
  describe("computeResizePreview — composition source continuity", () => {
@@ -1,6 +1,11 @@
1
1
  import { resolveTimelineMove, resolveTimelineResize } from "./timelineEditing";
2
2
  import type { TimelineElement } from "../store/playerStore";
3
- import { TRACK_H, getTimelineRowFromY, INSERT_BOUNDARY_BAND } from "./timelineLayout";
3
+ import {
4
+ getTimelineInsertBoundaryBand,
5
+ getTimelineRowFromY,
6
+ getTimelineRowHeight,
7
+ getTimelineRowPositionFromY,
8
+ } from "./timelineLayout";
4
9
  import { isMusicTrack, isAudioTimelineElement } from "../../utils/timelineInspector";
5
10
  import {
6
11
  TIMELINE_SNAP_PX,
@@ -27,6 +32,7 @@ export interface DragPreviewContext {
27
32
  pps: number;
28
33
  duration: number;
29
34
  trackOrder: number[];
35
+ rowHeights?: readonly number[];
30
36
  elements: TimelineElement[];
31
37
  selectedKeys: ReadonlySet<string>;
32
38
  buildSnapTargets: BuildSnapTargets;
@@ -81,20 +87,26 @@ function resolveDropPlacement(
81
87
  desiredTrack: number,
82
88
  ctx: DragPreviewContext,
83
89
  ): { track: number; insertRow: number | null } {
84
- const { scroll, trackOrder, elements } = ctx;
90
+ const { scroll, trackOrder, rowHeights, elements } = ctx;
85
91
  // rowFloat = the pointer's position in track-heights from the top lane; a
86
92
  // near-boundary hover requests a deliberate new-track insert. Uses the
87
93
  // shared row→y inverse so the top breathing pad is subtracted consistently.
88
- const rowFloat = scroll
89
- ? getTimelineRowFromY(clientY - scroll.getBoundingClientRect().top + scroll.scrollTop)
90
- : 0;
91
- // Geometry-exact band (the clip inset) so an insert only arms in the visible
92
- // gutter BETWEEN clip bodies — dragging over a clip body is a lane move, never a
93
- // phantom insert (the plain-horizontal-drag misfire). See INSERT_BOUNDARY_BAND.
94
- const rawInsertRow = resolveInsertRow(rowFloat, trackOrder.length, INSERT_BOUNDARY_BAND);
94
+ const rowPosition = scroll
95
+ ? getTimelineRowPositionFromY(
96
+ clientY - scroll.getBoundingClientRect().top + scroll.scrollTop,
97
+ rowHeights,
98
+ )
99
+ : { rowFloat: 0, row: 0, fraction: 0, rowHeight: getTimelineRowHeight(0, rowHeights) };
100
+ // Geometry-exact band (the clip inset divided by this row's actual height) so
101
+ // an insert only arms in the visible gutter between clip bodies.
102
+ const rawInsertRow = resolveInsertRow(
103
+ rowPosition.rowFloat,
104
+ trackOrder.length,
105
+ getTimelineInsertBoundaryBand(rowPosition.rowHeight),
106
+ );
95
107
  // Pointer sub-row half: when a drop must auto-create a track (aimed span
96
108
  // occupied, no free lane), open it on the side the pointer is nearer.
97
- const preferInsertAbove = rowFloat - Math.floor(rowFloat) < 0.5;
109
+ const preferInsertAbove = rowPosition.fraction < 0.5;
98
110
  const audioTracks =
99
111
  ctx.audioTracks ?? new Set(elements.filter(isAudioTimelineElement).map((e) => e.track));
100
112
  return resolveZoneDropPlacement({
@@ -120,24 +132,30 @@ export function computeDragPreview(
120
132
  ): DraggedClipState {
121
133
  const { scroll, pps, duration, trackOrder, elements, selectedKeys, buildSnapTargets } = ctx;
122
134
  const dragMaxStart = resolveDragMaxStart(scroll, pps, duration);
135
+ const scrollTop = scroll?.scrollTop ?? drag.originScrollTop;
136
+ const scrollRectTop = scroll?.getBoundingClientRect().top ?? 0;
137
+ const originRow = getTimelineRowFromY(
138
+ drag.originClientY - scrollRectTop + drag.originScrollTop,
139
+ ctx.rowHeights,
140
+ );
141
+ const currentRow = getTimelineRowFromY(clientY - scrollRectTop + scrollTop, ctx.rowHeights);
142
+ // resolveTimelineMove's vertical axis is row indices, which is why the pointer
143
+ // and scroll pixels are folded into originRow/currentRow above.
123
144
  const nextMove = resolveTimelineMove(
124
145
  {
125
146
  start: drag.element.start,
126
147
  track: drag.element.track,
127
148
  duration: drag.element.duration,
128
149
  originClientX: drag.originClientX,
129
- originClientY: drag.originClientY,
150
+ originRow,
130
151
  originScrollLeft: drag.originScrollLeft,
131
- originScrollTop: drag.originScrollTop,
132
152
  currentScrollLeft: scroll?.scrollLeft ?? drag.originScrollLeft,
133
- currentScrollTop: scroll?.scrollTop ?? drag.originScrollTop,
134
153
  pixelsPerSecond: pps,
135
- trackHeight: TRACK_H,
136
154
  maxStart: dragMaxStart,
137
155
  trackOrder,
138
156
  },
139
157
  clientX,
140
- clientY,
158
+ currentRow,
141
159
  );
142
160
  // The music track defines the beats, so it must not snap to them —
143
161
  // but it still snaps to the playhead and other clip edges.
@@ -1,4 +1,5 @@
1
1
  import type { TimelineElement } from "../store/playerStore";
2
+ import { INSERT_BOUNDARY_BAND } from "./timelineLayout";
2
3
 
3
4
  /**
4
5
  * Keep a landing track inside the dragged clip's kind-zone: visual clips stay in
@@ -139,28 +140,18 @@ export function resolveZoneDropPlacement(input: {
139
140
  return { track: placement.track, insertRow: null };
140
141
  }
141
142
 
142
- /**
143
- * Fallback half-width (fraction of a track height) of the insert band straddling
144
- * a lane boundary — used only when the caller passes no explicit band. Production
145
- * threads the geometry-exact `INSERT_BOUNDARY_BAND` (timelineLayout.ts, = the clip
146
- * inset `CLIP_Y / TRACK_H`) so the band matches the rendered inter-clip gutter and
147
- * NEVER reaches into a clip body. Kept in sync with that constant; do not widen it
148
- * back toward the old 0.32 (which armed an insert across ~64% of every row — the
149
- * misfire that turned a plain horizontal drag into a phantom track insert).
150
- */
151
- const INSERT_BAND = 3 / 48;
152
-
153
143
  /**
154
144
  * Decide whether a vertical drag is inserting a new track at a lane boundary.
155
145
  * `rowFloat` is the pointer's position in track-height units from the top of the
156
146
  * first lane (0 = top of lane 0). Returns the boundary row to insert at
157
147
  * (0 = above the top lane, `trackCount` = below the bottom), or null when the
158
- * pointer is over a lane's middle band (a normal move/target).
148
+ * pointer is over a lane's middle band (a normal move/target). The default band
149
+ * preserves collapsed-row behavior; production passes the concrete row's band.
159
150
  */
160
151
  export function resolveInsertRow(
161
152
  rowFloat: number,
162
153
  trackCount: number,
163
- band: number = INSERT_BAND,
154
+ band: number = INSERT_BOUNDARY_BAND,
164
155
  ): number | null {
165
156
  if (trackCount === 0) return 0;
166
157
  if (rowFloat <= 0) return 0;