@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.
- package/dist/assets/{hyperframes-player-DsRdxz7A.js → hyperframes-player-uiAAPKvw.js} +1 -1
- package/dist/assets/index-BSZrK0bx.js +428 -0
- package/dist/assets/{index-Du1RoLiB.js → index-CDSTMtUs.js} +1 -1
- package/dist/assets/index-DpkO2Hvw.css +1 -0
- package/dist/assets/{index-BLICKger.js → index-r1tKKlU7.js} +1 -1
- package/dist/index.d.ts +64 -17
- package/dist/index.html +2 -2
- package/dist/index.js +25030 -23214
- package/dist/index.js.map +1 -1
- package/package.json +7 -7
- package/src/App.tsx +16 -17
- package/src/components/EditorShell.tsx +3 -0
- package/src/components/TimelineToolbar.test.tsx +48 -3
- package/src/components/TimelineToolbar.tsx +89 -36
- package/src/components/editor/AnimationCard.test.tsx +18 -75
- package/src/components/editor/AnimationCard.tsx +26 -2
- package/src/components/editor/GsapAddAnimationControl.tsx +68 -0
- package/src/components/editor/GsapAnimationSection.tsx +18 -157
- package/src/components/editor/KeyframeEaseList.tsx +5 -1
- package/src/components/editor/KeyframeNavigation.tsx +46 -14
- package/src/components/editor/KeyframeNavigationDiamond.test.tsx +59 -0
- package/src/components/editor/MotionPathOverlay.tsx +32 -10
- package/src/components/editor/PropertyPanel.tsx +15 -5
- package/src/components/editor/PropertyPanelFlat.tsx +48 -23
- package/src/components/editor/gsapAnimationCallbacks.test.ts +122 -0
- package/src/components/editor/gsapAnimationCallbacks.ts +108 -4
- package/src/components/editor/keyframeRetime.test.ts +25 -22
- package/src/components/editor/keyframeRetime.ts +1 -2
- package/src/components/editor/propertyPanelFlatLayoutSection.tsx +2 -2
- package/src/components/editor/propertyPanelFlatMotionSection.tsx +25 -139
- package/src/components/editor/propertyPanelFlatTextSection.test.tsx +32 -16
- package/src/components/editor/propertyPanelFlatTextSection.tsx +21 -4
- package/src/components/nle/PreviewOverlays.tsx +3 -1
- package/src/components/nle/useTimelineEditCallbacks.test.ts +30 -0
- package/src/components/nle/useTimelineEditCallbacks.test.tsx +693 -0
- package/src/components/nle/useTimelineEditCallbacks.ts +197 -57
- package/src/components/sidebar/CompositionsTab.drag.test.tsx +41 -0
- package/src/components/sidebar/CompositionsTab.tsx +66 -32
- package/src/contexts/TimelineEditContext.tsx +1 -1
- package/src/hooks/deleteSelectedKeyframes.ts +24 -8
- package/src/hooks/gsapDragCommit.ts +2 -2
- package/src/hooks/gsapDragPositionCommit.ts +84 -5
- package/src/hooks/gsapKeyframeCacheHelpers.test.ts +176 -1
- package/src/hooks/gsapKeyframeCacheHelpers.ts +93 -52
- package/src/hooks/gsapRuntimeBridge.test.ts +100 -0
- package/src/hooks/gsapScriptCommitHelpers.ts +3 -2
- package/src/hooks/gsapScriptCommitTypes.ts +2 -0
- package/src/hooks/gsapShared.test.ts +109 -1
- package/src/hooks/gsapShared.ts +142 -17
- package/src/hooks/gsapTweenSynth.test.ts +30 -1
- package/src/hooks/gsapTweenSynth.ts +61 -14
- package/src/hooks/keyframeCacheAstLoad.ts +181 -0
- package/src/hooks/timelineEditingHelpers.test.ts +80 -0
- package/src/hooks/timelineMoveAdapter.ts +9 -2
- package/src/hooks/useDomEditWiring.ts +2 -2
- package/src/hooks/useDomSelection.ts +4 -0
- package/src/hooks/useEnableKeyframes.test.ts +122 -0
- package/src/hooks/useEnableKeyframes.ts +116 -56
- package/src/hooks/useGestureCommit.ts +2 -2
- package/src/hooks/useGsapAnimationFetchFallback.ts +1 -1
- package/src/hooks/useGsapKeyframeOps.test.tsx +92 -13
- package/src/hooks/useGsapKeyframeOps.ts +30 -13
- package/src/hooks/useGsapScriptCommits.test.tsx +21 -0
- package/src/hooks/useGsapScriptCommits.ts +2 -0
- package/src/hooks/useGsapSelectionHandlers.test.tsx +69 -3
- package/src/hooks/useGsapSelectionHandlers.ts +90 -65
- package/src/hooks/useGsapTweenCache.test.ts +16 -0
- package/src/hooks/useGsapTweenCache.ts +64 -178
- package/src/hooks/usePopulateKeyframeCacheForFile.test.tsx +68 -0
- package/src/hooks/useStudioContextValue.test.ts +93 -0
- package/src/hooks/useStudioContextValue.ts +17 -3
- package/src/hooks/useStudioTestHooks.ts +63 -0
- package/src/hooks/useTimelineEditing.test.tsx +187 -22
- package/src/hooks/useTimelineEditing.ts +43 -32
- package/src/hooks/useTimelineEditingTypes.ts +2 -0
- package/src/hooks/useTimelineGroupEditing.ts +106 -82
- package/src/player/components/KeyframeDiamondContextMenu.test.tsx +69 -0
- package/src/player/components/KeyframeDiamondContextMenu.tsx +19 -8
- package/src/player/components/LayerDisclosureRow.tsx +76 -0
- package/src/player/components/Player.test.ts +143 -12
- package/src/player/components/Player.tsx +84 -43
- package/src/player/components/Timeline.test.ts +420 -90
- package/src/player/components/Timeline.tsx +80 -78
- package/src/player/components/TimelineCanvas.tsx +33 -21
- package/src/player/components/TimelineClip.tsx +3 -1
- package/src/player/components/TimelineClipDiamonds.test.tsx +540 -16
- package/src/player/components/TimelineClipDiamonds.tsx +320 -117
- package/src/player/components/TimelineDiamondConnectors.tsx +145 -0
- package/src/player/components/TimelineLanes.tsx +153 -79
- package/src/player/components/TimelineOverlays.tsx +4 -18
- package/src/player/components/TimelinePropertyLanes.test.tsx +450 -0
- package/src/player/components/TimelinePropertyLanes.tsx +191 -0
- package/src/player/components/TimelineRazorInteraction.tsx +60 -0
- package/src/player/components/TimelineRuler.tsx +11 -21
- package/src/player/components/TimelineTrackHeader.test.tsx +346 -0
- package/src/player/components/TimelineTrackHeader.tsx +363 -0
- package/src/player/components/TrackClipCount.tsx +17 -0
- package/src/player/components/timelineCallbacks.ts +21 -6
- package/src/player/components/timelineClipDragPreview.test.ts +50 -3
- package/src/player/components/timelineClipDragPreview.ts +33 -15
- package/src/player/components/timelineCollision.ts +4 -13
- package/src/player/components/timelineDiamondTypes.ts +120 -0
- package/src/player/components/timelineDragDrop.ts +8 -3
- package/src/player/components/timelineEditing.test.ts +19 -27
- package/src/player/components/timelineEditing.ts +7 -8
- package/src/player/components/timelineKeyframeIdentity.test.ts +49 -0
- package/src/player/components/timelineKeyframeIdentity.ts +66 -0
- package/src/player/components/timelineLayout.test.ts +162 -9
- package/src/player/components/timelineLayout.ts +198 -38
- package/src/player/components/timelineMarquee.test.ts +76 -21
- package/src/player/components/timelineMarquee.ts +17 -14
- package/src/player/components/trackHeaderLaneState.ts +121 -0
- package/src/player/components/trackHeaderLaneValues.ts +139 -0
- package/src/player/components/useAutoExpandKeyframedClips.test.tsx +75 -0
- package/src/player/components/useAutoExpandKeyframedClips.ts +50 -0
- package/src/player/components/useTimelineClipDrag.ts +4 -1
- package/src/player/components/useTimelineGeometry.ts +4 -1
- package/src/player/components/useTimelineKeyframeHandlers.test.tsx +108 -0
- package/src/player/components/useTimelineKeyframeHandlers.ts +50 -15
- package/src/player/components/useTimelinePlayhead.ts +21 -12
- package/src/player/components/useTimelineRangeSelection.ts +35 -14
- package/src/player/components/useTimelineTrackLayout.test.ts +53 -0
- package/src/player/components/useTimelineTrackLayout.ts +162 -0
- package/src/player/hooks/useExpandedTimelineElements.test.ts +43 -0
- package/src/player/hooks/useExpandedTimelineElements.ts +21 -12
- package/src/player/lib/timelineElementHelpers.ts +26 -11
- package/src/player/store/keyframeSlice.ts +122 -0
- package/src/player/store/playerStore.test.ts +25 -0
- package/src/player/store/playerStore.ts +7 -43
- package/src/telemetry/events.test.ts +9 -3
- package/src/telemetry/events.ts +16 -7
- package/src/utils/globalTimeCompiler.test.ts +10 -0
- package/src/utils/globalTimeCompiler.ts +3 -3
- package/dist/assets/index-B37rWXo4.css +0 -1
- package/dist/assets/index-BdOfFsR8.js +0 -428
- package/src/player/components/TimelineDragGhost.tsx +0 -59
- package/src/utils/keyframeSelection.test.ts +0 -45
- 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,
|
|
66
|
-
onDeleteAllKeyframes?: (
|
|
67
|
-
|
|
68
|
-
|
|
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
|
-
|
|
82
|
+
keyframe: TimelineKeyframeTarget,
|
|
72
83
|
toClipPercentage: number,
|
|
73
|
-
) =>
|
|
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(
|
|
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
|
|
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 {
|
|
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
|
|
89
|
-
?
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
154
|
+
band: number = INSERT_BOUNDARY_BAND,
|
|
164
155
|
): number | null {
|
|
165
156
|
if (trackCount === 0) return 0;
|
|
166
157
|
if (rowFloat <= 0) return 0;
|