@hyperframes/studio 0.7.78 → 0.7.80
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/assets/{hyperframes-player-uiAAPKvw.js → hyperframes-player-Csai0_vh.js} +1 -1
- package/dist/assets/{index-CDSTMtUs.js → index-B5aYTg-4.js} +1 -1
- package/dist/assets/{index-BSZrK0bx.js → index-BhdJN49y.js} +202 -202
- package/dist/assets/{index-r1tKKlU7.js → index-By-YiRxq.js} +1 -1
- package/dist/assets/index-D379YOKT.css +1 -0
- package/dist/index.d.ts +45 -3
- package/dist/index.html +2 -2
- package/dist/index.js +1891 -1522
- package/dist/index.js.map +1 -1
- package/package.json +7 -7
- package/src/components/StudioLeftSidebar.tsx +10 -5
- package/src/components/StudioRightPanel.tsx +7 -5
- package/src/components/TimelineToolbar.tsx +3 -1
- package/src/components/editor/AnimationCard.test.tsx +225 -18
- package/src/components/editor/AnimationCard.tsx +24 -3
- package/src/components/editor/EaseCurveSection.test.tsx +147 -2
- package/src/components/editor/EaseCurveSection.tsx +77 -15
- package/src/components/editor/GsapAnimationSection.test.tsx +102 -0
- package/src/components/editor/GsapAnimationSection.tsx +6 -1
- package/src/components/editor/KeyframeEaseList.tsx +4 -0
- package/src/components/editor/KeyframeNavigation.tsx +5 -0
- package/src/components/editor/MotionPathOverlay.tsx +58 -20
- package/src/components/editor/PropertyPanel.tsx +7 -7
- package/src/components/editor/PropertyPanelFlat.tsx +6 -91
- package/src/components/editor/gsapAnimationCallbacks.test.ts +8 -1
- package/src/components/editor/gsapAnimationCallbacks.ts +8 -0
- package/src/components/editor/holdEaseSeek.test.ts +37 -0
- package/src/components/editor/keyframeRetime.test.ts +42 -0
- package/src/components/editor/keyframeRetime.ts +4 -1
- package/src/components/editor/motionPathSelection.test.ts +65 -0
- package/src/components/editor/motionPathSelection.ts +13 -3
- package/src/components/editor/propertyPanelColor.test.tsx +181 -12
- package/src/components/editor/propertyPanelColor.tsx +32 -26
- package/src/components/editor/propertyPanelFlatMotionSection.test.tsx +69 -0
- package/src/components/editor/propertyPanelFlatMotionSection.tsx +2 -1
- package/src/components/editor/propertyPanelFlatProps.ts +91 -0
- package/src/components/editor/propertyPanelPrimitives.tsx +3 -1
- package/src/components/editor/propertyPanelTypes.ts +2 -0
- package/src/components/nle/TimelineResizeDivider.tsx +9 -4
- package/src/components/pointerTargetSize.test.tsx +65 -0
- package/src/components/sidebar/CompositionsTab.tsx +4 -1
- package/src/contexts/DomEditContext.tsx +4 -0
- package/src/hooks/gsapDragCommit.ts +43 -10
- package/src/hooks/gsapDragStaticSetHelpers.ts +16 -6
- package/src/hooks/gsapKeyframeCacheHelpers.test.ts +195 -43
- package/src/hooks/gsapKeyframeCacheHelpers.ts +190 -83
- package/src/hooks/gsapKeyframeCommit.ts +11 -2
- package/src/hooks/gsapResizeIntercept.ts +2 -2
- package/src/hooks/gsapRuntimeBridge.ts +3 -2
- package/src/hooks/gsapScriptCommitHelpers.ts +17 -2
- package/src/hooks/gsapShared.test.ts +144 -0
- package/src/hooks/gsapShared.ts +264 -0
- package/src/hooks/gsapShared.writeTarget.test.ts +289 -0
- package/src/hooks/gsapTweenSynth.test.ts +70 -15
- package/src/hooks/gsapTweenSynth.ts +50 -23
- package/src/hooks/keyframeCacheAstLoad.ts +9 -83
- package/src/hooks/newTweenTarget.test.ts +364 -0
- package/src/hooks/newTweenTargetHooks.test.tsx +255 -0
- package/src/hooks/timelineTrackVisibility.test.ts +61 -1
- package/src/hooks/timelineTrackVisibility.ts +11 -1
- package/src/hooks/useAnimatedPropertyCommit.ts +24 -8
- package/src/hooks/useDomEditSession.test.tsx +152 -3
- package/src/hooks/useDomEditSession.ts +4 -53
- package/src/hooks/useEnableKeyframes.test.ts +44 -0
- package/src/hooks/useEnableKeyframes.ts +18 -5
- package/src/hooks/useGestureCommit.ts +21 -4
- package/src/hooks/useGsapScriptCommits.ts +3 -0
- package/src/hooks/useGsapTweenCache.test.ts +11 -3
- package/src/hooks/useGsapTweenCache.ts +44 -45
- package/src/hooks/useKeyframeEaseCommits.ts +78 -0
- package/src/player/components/KeyframeDiamondContextMenu.test.tsx +21 -0
- package/src/player/components/KeyframeDiamondContextMenu.tsx +19 -12
- package/src/player/components/LayerDisclosureRow.tsx +4 -1
- package/src/player/components/ShortcutsPanel.test.tsx +158 -0
- package/src/player/components/ShortcutsPanel.tsx +11 -15
- package/src/player/components/Timeline.test.ts +3 -1
- package/src/player/components/TimelineCanvas.tsx +2 -1
- package/src/player/components/TimelineClipDiamonds.test.tsx +35 -8
- package/src/player/components/TimelineClipDiamonds.tsx +11 -1
- package/src/player/components/TimelineDiamondConnectors.tsx +118 -72
- package/src/player/components/TimelineLanes.test.tsx +281 -0
- package/src/player/components/TimelineLanes.tsx +43 -97
- package/src/player/components/TimelinePropertyLanes.test.tsx +217 -0
- package/src/player/components/TimelinePropertyLanes.tsx +80 -11
- package/src/player/components/TimelineTrackHeader.test.tsx +75 -5
- package/src/player/components/TimelineTrackHeader.tsx +52 -22
- package/src/player/components/timelineDiamondTypes.ts +4 -3
- package/src/player/components/timelineKeyframeIdentity.ts +3 -0
- package/src/player/components/timelineLaneProps.ts +86 -0
- package/src/player/components/timelineTrackDisplay.test.ts +35 -0
- package/src/player/components/timelineTrackDisplay.ts +38 -0
- package/src/player/components/useTimelineKeyframeHandlers.test.tsx +52 -32
- package/src/player/components/useTimelineKeyframeHandlers.ts +1 -0
- package/src/player/components/useTimelineRangeSelection.ts +5 -0
- package/src/player/components/useTimelineRangeSelectionScrub.test.tsx +127 -0
- package/src/player/components/useTimelineTrackLayout.test.ts +35 -0
- package/src/player/components/useTimelineTrackLayout.ts +4 -4
- package/src/player/hooks/useExpandedTimelineElements.test.ts +180 -2
- package/src/player/hooks/useExpandedTimelineElements.ts +70 -7
- package/src/player/store/keyframeSlice.ts +15 -5
- package/src/telemetry/client.test.ts +12 -3
- package/src/telemetry/client.ts +3 -2
- package/dist/assets/index-DpkO2Hvw.css +0 -1
|
@@ -1,28 +1,21 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
|
|
1
|
+
import { useId } from "react";
|
|
3
2
|
import { BeatStrip, BeatBackgroundLines } from "./BeatStrip";
|
|
4
3
|
import { TimelineClip } from "./TimelineClip";
|
|
5
4
|
import { TimelineClipDiamonds } from "./TimelineClipDiamonds";
|
|
6
5
|
import { TimelinePropertyLanes } from "./TimelinePropertyLanes";
|
|
7
6
|
import { TimelineTrackHeader } from "./TimelineTrackHeader";
|
|
8
7
|
import { resolveTrackKeyframeClip } from "./useTimelineTrackLayout";
|
|
9
|
-
import
|
|
10
|
-
import
|
|
8
|
+
import { trackDisplayNumber, trackDisplaySuffix } from "./timelineTrackDisplay";
|
|
9
|
+
import { clipTimingStart } from "../../hooks/gsapShared";
|
|
11
10
|
import { getTimelineEditCapabilities, resolveBlockedTimelineEditIntent } from "./timelineEditing";
|
|
12
|
-
import type { TimelineTheme } from "./timelineTheme";
|
|
13
11
|
import { CLIP_Y, CLIP_HANDLE_W, TRACK_H, getTimelineRowHeight } from "./timelineLayout";
|
|
14
|
-
import {
|
|
15
|
-
usePlayerStore,
|
|
16
|
-
type TimelineElement,
|
|
17
|
-
type KeyframeCacheEntry,
|
|
18
|
-
} from "../store/playerStore";
|
|
19
|
-
import type { DraggedClipState, ResizingClipState, BlockedClipState } from "./useTimelineClipDrag";
|
|
12
|
+
import { usePlayerStore, type TimelineElement } from "../store/playerStore";
|
|
20
13
|
import {
|
|
21
14
|
isMultiDragPassenger,
|
|
22
15
|
multiDragPassengerOffsetPx,
|
|
23
16
|
type MultiDragPreviewInput,
|
|
24
17
|
} from "./timelineMultiDragPreview";
|
|
25
|
-
import type {
|
|
18
|
+
import type { TimelineLaneBaseProps } from "./timelineLaneProps";
|
|
26
19
|
import type { TimelineEditCallbacks } from "./timelineCallbacks";
|
|
27
20
|
import { STUDIO_KEYFRAMES_ENABLED } from "../../components/editor/manualEditingAvailability";
|
|
28
21
|
import { trackStudioKeyframeLaneExpand } from "../../telemetry/events";
|
|
@@ -30,83 +23,6 @@ import { SPLIT_BOUNDARY_EPSILON_S } from "../../utils/timelineElementSplit";
|
|
|
30
23
|
import { isAudioTimelineElement, isMusicTrack } from "../../utils/timelineInspector";
|
|
31
24
|
import { renderClipChildren } from "./timelineClipChildren";
|
|
32
25
|
|
|
33
|
-
/**
|
|
34
|
-
* Props shared by the scroll container ({@link TimelineCanvas}) and the lane
|
|
35
|
-
* renderer below. TimelineCanvas passes these straight through via spread, so
|
|
36
|
-
* they are declared once here and both prop types compose from this base — no
|
|
37
|
-
* duplicated prop list.
|
|
38
|
-
*/
|
|
39
|
-
export interface TimelineLaneBaseProps {
|
|
40
|
-
pps: number;
|
|
41
|
-
contentOrigin: number;
|
|
42
|
-
contentGutter: number;
|
|
43
|
-
trackContentWidth: number;
|
|
44
|
-
theme: TimelineTheme;
|
|
45
|
-
displayTrackOrder: number[];
|
|
46
|
-
rowHeights: readonly number[];
|
|
47
|
-
trackOrder: number[];
|
|
48
|
-
tracks: [number, TimelineElement[]][];
|
|
49
|
-
trackStyles: Map<number, TrackVisualStyle>;
|
|
50
|
-
laneCounts: ReadonlyMap<string, number>;
|
|
51
|
-
selectedElementId: string | null;
|
|
52
|
-
selectedElementIds: Set<string>;
|
|
53
|
-
hoveredClip: string | null;
|
|
54
|
-
draggedClip: DraggedClipState | null;
|
|
55
|
-
blockedClipRef: React.RefObject<BlockedClipState | null>;
|
|
56
|
-
suppressClickRef: React.RefObject<boolean>;
|
|
57
|
-
scrollRef: React.RefObject<HTMLDivElement | null>;
|
|
58
|
-
renderClipContent?: (
|
|
59
|
-
element: TimelineElement,
|
|
60
|
-
style: { clip: string; label: string },
|
|
61
|
-
) => ReactNode;
|
|
62
|
-
renderClipOverlay?: (element: TimelineElement) => ReactNode;
|
|
63
|
-
onDrillDown?: (element: TimelineElement) => void;
|
|
64
|
-
onSelectElement?: (element: TimelineElement | null) => void;
|
|
65
|
-
setHoveredClip: (key: string | null) => void;
|
|
66
|
-
setShowPopover: (v: boolean) => void;
|
|
67
|
-
setRangeSelection: (v: null) => void;
|
|
68
|
-
setResizingClip: (v: ResizingClipState | null) => void;
|
|
69
|
-
setDraggedClip: (v: DraggedClipState | null) => void;
|
|
70
|
-
setSelectedElementId: (id: string | null) => void;
|
|
71
|
-
syncClipDragAutoScroll: (x: number, y: number) => void;
|
|
72
|
-
shiftClickClipRef: React.RefObject<{
|
|
73
|
-
element: TimelineElement;
|
|
74
|
-
anchorX: number;
|
|
75
|
-
anchorY: number;
|
|
76
|
-
} | null>;
|
|
77
|
-
getPreviewElement: (element: TimelineElement) => TimelineElement;
|
|
78
|
-
getTrackStyle: (tag: string) => TrackVisualStyle;
|
|
79
|
-
keyframeCache?: Map<string, KeyframeCacheEntry>;
|
|
80
|
-
gsapAnimations: Map<string, GsapAnimation[]>;
|
|
81
|
-
selectedKeyframes: Set<string>;
|
|
82
|
-
currentTime: number;
|
|
83
|
-
onSeek?: (time: number) => void;
|
|
84
|
-
onSelectSegment?: (elementId: string, target: TimelineKeyframeTarget) => void;
|
|
85
|
-
onClickKeyframe?: (element: TimelineElement, target: TimelineKeyframeTarget) => void;
|
|
86
|
-
onShiftClickKeyframe?: (elementId: string, target: TimelineKeyframeTarget) => void;
|
|
87
|
-
onContextMenuKeyframe?: (
|
|
88
|
-
e: React.MouseEvent,
|
|
89
|
-
elementId: string,
|
|
90
|
-
target: TimelineKeyframeTarget,
|
|
91
|
-
) => void;
|
|
92
|
-
onMoveKeyframe?: (
|
|
93
|
-
elementId: string,
|
|
94
|
-
keyframe: TimelineKeyframeTarget,
|
|
95
|
-
toClipPercentage: number,
|
|
96
|
-
propertyGroup?: string,
|
|
97
|
-
tweenPercentage?: number,
|
|
98
|
-
animationId?: string,
|
|
99
|
-
) => Promise<boolean>;
|
|
100
|
-
onContextMenuClip?: (e: React.MouseEvent, element: TimelineElement) => void;
|
|
101
|
-
/**
|
|
102
|
-
* Right-click on EMPTY lane space (not on a clip — those preventDefault
|
|
103
|
-
* before this fires — not the gutter/ruler, not below the lanes). `time` is
|
|
104
|
-
* the timeline time (seconds) under the pointer on that lane.
|
|
105
|
-
*/
|
|
106
|
-
onContextMenuLane?: (e: React.MouseEvent, track: number, time: number) => void;
|
|
107
|
-
beatAnalysis?: MusicBeatAnalysis | null;
|
|
108
|
-
}
|
|
109
|
-
|
|
110
26
|
interface TimelineLanesProps extends TimelineLaneBaseProps {
|
|
111
27
|
/** Live-derived by TimelineCanvas from {@link TimelineLaneBaseProps.draggedClip}. */
|
|
112
28
|
draggedElement: TimelineElement | null;
|
|
@@ -174,6 +90,12 @@ export function TimelineLanes({
|
|
|
174
90
|
onRazorSplit,
|
|
175
91
|
onRazorSplitAll,
|
|
176
92
|
}: TimelineLanesProps) {
|
|
93
|
+
// Per-INSTANCE, so two timelines on one page (a mini-timeline in a modal
|
|
94
|
+
// beside the main one) cannot both mint `...-track-0` and have every caret's
|
|
95
|
+
// aria-controls resolve to whichever mounted first. React's useId embeds
|
|
96
|
+
// colons, which are legal in an id and in aria-controls but need escaping in
|
|
97
|
+
// a CSS `#id` selector, so they come out here and the prefix stays plain.
|
|
98
|
+
const lanesIdPrefix = `timeline-lanes${useId().replaceAll(":", "")}`;
|
|
177
99
|
const expandedClipIds = usePlayerStore((s) => s.expandedClipIds);
|
|
178
100
|
const toggleClipExpanded = usePlayerStore((s) => s.toggleClipExpanded);
|
|
179
101
|
const toggleClipExpandedTracked = (key: string) => {
|
|
@@ -191,6 +113,7 @@ export function TimelineLanes({
|
|
|
191
113
|
// in a virtualizer if editorial workflows ever push very high clip counts.
|
|
192
114
|
// fallow-ignore-next-line complexity
|
|
193
115
|
displayTrackOrder.map((trackNum, row) => {
|
|
116
|
+
const displayNumber = trackDisplayNumber(displayTrackOrder, trackNum);
|
|
194
117
|
const rowHeight = getTimelineRowHeight(row, rowHeights);
|
|
195
118
|
const els = tracks.find(([t]) => t === trackNum)?.[1] ?? [];
|
|
196
119
|
const ts = trackStyles.get(trackNum) ?? getTrackStyle("");
|
|
@@ -217,6 +140,11 @@ export function TimelineLanes({
|
|
|
217
140
|
const keyframeClipKey = keyframeClip?.key ?? keyframeClip?.id;
|
|
218
141
|
const keyframeClipExpanded =
|
|
219
142
|
keyframeClipKey != null && expandedClipIds.has(keyframeClipKey);
|
|
143
|
+
// Minted here because this is the only place that sees BOTH ends of
|
|
144
|
+
// the disclosure: the caret in the sticky header and the diamond lanes
|
|
145
|
+
// on the canvas. Keyed by display row, not by `trackNum`, which is a
|
|
146
|
+
// fractional sort key and would mint ids like `...-0.16666666666666666`.
|
|
147
|
+
const lanesId = `${lanesIdPrefix}-track-${row}`;
|
|
220
148
|
return (
|
|
221
149
|
<div
|
|
222
150
|
key={trackNum}
|
|
@@ -229,7 +157,16 @@ export function TimelineLanes({
|
|
|
229
157
|
>
|
|
230
158
|
<TimelineTrackHeader
|
|
231
159
|
trackNumber={trackNum}
|
|
232
|
-
|
|
160
|
+
// What gets announced. `trackNum` is a fractional z-order sort
|
|
161
|
+
// key, so it stays out of every label and in every callback.
|
|
162
|
+
trackDisplayNumber={displayNumber}
|
|
163
|
+
trackLabel={
|
|
164
|
+
els[0]?.label ??
|
|
165
|
+
els[0]?.domId ??
|
|
166
|
+
els[0]?.id ??
|
|
167
|
+
`Track${trackDisplaySuffix(displayNumber)}`
|
|
168
|
+
}
|
|
169
|
+
lanesId={lanesId}
|
|
233
170
|
contentOrigin={contentOrigin}
|
|
234
171
|
keyframeClip={keyframeClip}
|
|
235
172
|
clipCount={els.length}
|
|
@@ -312,10 +249,9 @@ export function TimelineLanes({
|
|
|
312
249
|
// Only the track's active keyframe clip shows expanded lanes;
|
|
313
250
|
// other clips (incl. siblings on a shared track) show compact
|
|
314
251
|
// diamonds on their own bar instead.
|
|
315
|
-
const
|
|
316
|
-
STUDIO_KEYFRAMES_ENABLED &&
|
|
317
|
-
|
|
318
|
-
keyframeClipExpanded;
|
|
252
|
+
const isTrackKeyframeClip =
|
|
253
|
+
STUDIO_KEYFRAMES_ENABLED && elementKey === keyframeClipKey;
|
|
254
|
+
const showsLanes = isTrackKeyframeClip && keyframeClipExpanded;
|
|
319
255
|
const capabilities = getTimelineEditCapabilities(el);
|
|
320
256
|
const isSelected =
|
|
321
257
|
selectedElementId === elementKey || selectedElementIds.has(elementKey);
|
|
@@ -523,11 +459,21 @@ export function TimelineLanes({
|
|
|
523
459
|
)}
|
|
524
460
|
</TimelineClip>
|
|
525
461
|
);
|
|
526
|
-
|
|
462
|
+
// Mounted for the track's keyframe clip in BOTH disclosure
|
|
463
|
+
// states, so the header caret's aria-controls resolves while
|
|
464
|
+
// collapsed too; collapsed just feeds it no animations, so
|
|
465
|
+
// the wrapper renders empty. The key is stable across a
|
|
466
|
+
// multi-drag: without it the passenger branch below remounts
|
|
467
|
+
// this subtree and interrupts the gesture.
|
|
468
|
+
const propertyLanes = isTrackKeyframeClip && (
|
|
527
469
|
<TimelinePropertyLanes
|
|
528
470
|
key={`${clipKey}-property-lanes`}
|
|
529
|
-
|
|
530
|
-
|
|
471
|
+
id={lanesId}
|
|
472
|
+
animations={showsLanes ? (gsapAnimations.get(elementKey) ?? []) : []}
|
|
473
|
+
// clipTimingStart, not the raw start: an expanded sub-comp
|
|
474
|
+
// child's start is host-absolute while its tweens are
|
|
475
|
+
// local to its own file.
|
|
476
|
+
clipStart={clipTimingStart(previewElement)}
|
|
531
477
|
clipDuration={previewElement.duration}
|
|
532
478
|
clipLeftPx={previewElement.start * pps}
|
|
533
479
|
clipWidthPx={Math.max(previewElement.duration * pps, 4)}
|
|
@@ -7,10 +7,15 @@ import { afterEach, describe, expect, it, vi } from "vitest";
|
|
|
7
7
|
import { TimelineClipDiamonds } from "./TimelineClipDiamonds";
|
|
8
8
|
import {
|
|
9
9
|
getTimelinePropertyLanes,
|
|
10
|
+
resolveAnimIdForProperty,
|
|
10
11
|
TimelinePropertyLanes,
|
|
11
12
|
type TimelinePropertyLanesProps,
|
|
12
13
|
} from "./TimelinePropertyLanes";
|
|
13
14
|
import { timelineKeyframeSelectionKey } from "./timelineKeyframeIdentity";
|
|
15
|
+
import { LANE_H, getTimelineLaneTop } from "./timelineLayout";
|
|
16
|
+
import { groupLabel } from "./trackHeaderLaneValues";
|
|
17
|
+
import { clipTimingStart } from "../../hooks/gsapShared";
|
|
18
|
+
import { resolveTimelineKeyframeTarget } from "../../components/nle/useTimelineEditCallbacks";
|
|
14
19
|
|
|
15
20
|
(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
|
16
21
|
|
|
@@ -121,7 +126,111 @@ const POSITION_SEGMENT_ANIMATION = animation("position-tween", "position", [
|
|
|
121
126
|
{ percentage: 50, properties: { x: 50 } },
|
|
122
127
|
]);
|
|
123
128
|
|
|
129
|
+
/** A tween the parser leaves unclassified because it spans several groups. */
|
|
130
|
+
function ungroupedAnimation(
|
|
131
|
+
id: string,
|
|
132
|
+
properties: Record<string, number | string>,
|
|
133
|
+
): GsapAnimation {
|
|
134
|
+
return {
|
|
135
|
+
id,
|
|
136
|
+
targetSelector: "#clip-1",
|
|
137
|
+
method: "to",
|
|
138
|
+
position: 0,
|
|
139
|
+
duration: 1,
|
|
140
|
+
properties,
|
|
141
|
+
};
|
|
142
|
+
}
|
|
143
|
+
|
|
124
144
|
describe("TimelinePropertyLanes", () => {
|
|
145
|
+
// `{ x, opacity }` is the canonical HyperFrames entrance tween. The parser
|
|
146
|
+
// classifies it to `undefined` (two groups), which used to erase it from the
|
|
147
|
+
// lanes entirely — no caret, no reserved row, nothing to edit.
|
|
148
|
+
it("lanes a mixed-group tween once per group it animates", () => {
|
|
149
|
+
const lanes = getTimelinePropertyLanes(
|
|
150
|
+
[ungroupedAnimation("entrance", { x: 0, opacity: 1 })],
|
|
151
|
+
0,
|
|
152
|
+
1,
|
|
153
|
+
);
|
|
154
|
+
|
|
155
|
+
expect(lanes.map((lane) => lane.group).sort()).toEqual(["position", "visual"]);
|
|
156
|
+
for (const lane of lanes) {
|
|
157
|
+
expect(lane.keyframes.map((keyframe) => keyframe.percentage)).toEqual([0, 100]);
|
|
158
|
+
expect(lane.keyframes.every((keyframe) => keyframe.animationId === "entrance")).toBe(true);
|
|
159
|
+
}
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
it("lanes a tween whose properties are all unknown as one 'other' lane", () => {
|
|
163
|
+
const lanes = getTimelinePropertyLanes(
|
|
164
|
+
[ungroupedAnimation("rounded", { borderRadius: 12, fontSize: 24 })],
|
|
165
|
+
0,
|
|
166
|
+
1,
|
|
167
|
+
);
|
|
168
|
+
|
|
169
|
+
expect(lanes).toHaveLength(1);
|
|
170
|
+
expect(lanes[0]?.group).toBe("other");
|
|
171
|
+
expect(groupLabel("other", lanes[0]!.keyframes[0]!.properties)).toBe("BorderRadius");
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
// An expanded sub-composition child sits on the MASTER timeline at a
|
|
175
|
+
// host-absolute start while its tweens are parsed from its own file and are
|
|
176
|
+
// local to it. clipTimingStart is what brings the two into one frame.
|
|
177
|
+
it("keeps an expanded sub-comp child's lane percentages inside the clip", () => {
|
|
178
|
+
const child = { start: 16.5, duration: 2, expandedParentStart: 16 };
|
|
179
|
+
const local = animation("pill-tween", "position", [
|
|
180
|
+
{ percentage: 0, properties: { x: 0 } },
|
|
181
|
+
{ percentage: 100, properties: { x: 100 } },
|
|
182
|
+
]);
|
|
183
|
+
local.position = 0.5;
|
|
184
|
+
local.resolvedStart = 0.5;
|
|
185
|
+
local.duration = 2;
|
|
186
|
+
|
|
187
|
+
const percentages = getTimelinePropertyLanes(
|
|
188
|
+
[local],
|
|
189
|
+
clipTimingStart(child),
|
|
190
|
+
child.duration,
|
|
191
|
+
).flatMap((lane) => lane.keyframes.map((keyframe) => keyframe.percentage));
|
|
192
|
+
|
|
193
|
+
expect(percentages).toHaveLength(2);
|
|
194
|
+
for (const percentage of percentages) {
|
|
195
|
+
expect(percentage).toBeGreaterThanOrEqual(0);
|
|
196
|
+
expect(percentage).toBeLessThanOrEqual(100);
|
|
197
|
+
}
|
|
198
|
+
// Falsifier: the raw host-absolute start is what used to be passed.
|
|
199
|
+
expect(
|
|
200
|
+
getTimelinePropertyLanes([local], child.start, child.duration)[0]?.keyframes[0]?.percentage,
|
|
201
|
+
).toBeLessThan(0);
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
it("still lanes a single-group tween exactly once", () => {
|
|
205
|
+
const lanes = getTimelinePropertyLanes(
|
|
206
|
+
[animation("position-tween", "position", [{ percentage: 0, properties: { x: 0, y: 0 } }])],
|
|
207
|
+
0,
|
|
208
|
+
1,
|
|
209
|
+
);
|
|
210
|
+
|
|
211
|
+
expect(lanes.map((lane) => lane.group)).toEqual(["position"]);
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
// A lane can merge several tweens, so an edit routed from it must carry the
|
|
215
|
+
// clicked keyframe's own animation identity — group matching alone is
|
|
216
|
+
// ambiguous once two tweens feed the same lane.
|
|
217
|
+
it("routes a mixed-tween lane edit to the tween that owns the keyframe", () => {
|
|
218
|
+
const mixed = ungroupedAnimation("entrance", { x: 40, opacity: 1 });
|
|
219
|
+
const sibling = animation("drift", "position", [
|
|
220
|
+
{ percentage: 0, properties: { x: 0 } },
|
|
221
|
+
{ percentage: 100, properties: { x: 9 } },
|
|
222
|
+
]);
|
|
223
|
+
const lanes = getTimelinePropertyLanes([mixed, sibling], 0, 1);
|
|
224
|
+
const position = lanes.find((lane) => lane.group === "position");
|
|
225
|
+
|
|
226
|
+
expect(
|
|
227
|
+
resolveTimelineKeyframeTarget(100, position?.keyframes ?? [], [
|
|
228
|
+
{ id: "entrance" },
|
|
229
|
+
{ id: "drift", propertyGroup: "position" },
|
|
230
|
+
]),
|
|
231
|
+
).toEqual({ animId: "entrance", tweenPct: 100 });
|
|
232
|
+
});
|
|
233
|
+
|
|
125
234
|
it("returns a position lane with synthesized endpoints for a flat tween", () => {
|
|
126
235
|
const lanes = getTimelinePropertyLanes(
|
|
127
236
|
[flatAnimation("position-tween", "position", { x: 420 })],
|
|
@@ -399,6 +508,68 @@ describe("TimelinePropertyLanes", () => {
|
|
|
399
508
|
act(() => root.unmount());
|
|
400
509
|
});
|
|
401
510
|
|
|
511
|
+
// The disclosure caret's aria-controls used to name a div in the STICKY LABEL
|
|
512
|
+
// COLUMN whose children are all absolutely positioned: it computed to 0x0 and
|
|
513
|
+
// held no diamonds. The real lanes had no wrapper at all to point at.
|
|
514
|
+
it("wraps the lanes in the identified element so aria-controls resolves to the diamonds", () => {
|
|
515
|
+
const animations = [
|
|
516
|
+
animation("position-tween", "position", [
|
|
517
|
+
{ percentage: 0, properties: { x: 0 } },
|
|
518
|
+
{ percentage: 100, properties: { x: 100 } },
|
|
519
|
+
]),
|
|
520
|
+
animation("visual-tween", "visual", [
|
|
521
|
+
{ percentage: 0, properties: { opacity: 0 } },
|
|
522
|
+
{ percentage: 100, properties: { opacity: 1 } },
|
|
523
|
+
]),
|
|
524
|
+
];
|
|
525
|
+
const { host, root } = renderPropertyLanes({ id: "timeline-lanes-track-0", animations });
|
|
526
|
+
const wrapper = host.querySelector("#timeline-lanes-track-0");
|
|
527
|
+
|
|
528
|
+
expect(wrapper).not.toBeNull();
|
|
529
|
+
expect(wrapper?.querySelectorAll("[data-timeline-property-lane]")).toHaveLength(2);
|
|
530
|
+
expect(wrapper?.querySelectorAll("button[data-keyframe-percentage]").length).toBeGreaterThan(0);
|
|
531
|
+
// Load-bearing: a `position: relative` wrapper would become the containing
|
|
532
|
+
// block for the absolute lanes below and shift every one of them.
|
|
533
|
+
expect((wrapper as HTMLElement).style.position).toBe("");
|
|
534
|
+
act(() => root.unmount());
|
|
535
|
+
});
|
|
536
|
+
|
|
537
|
+
// happy-dom has no CSS engine, so measured geometry is always 0x0. The lanes'
|
|
538
|
+
// own inline offsets are what the component actually computes, so pin those.
|
|
539
|
+
it("leaves every lane's inline offsets untouched by the wrapper", () => {
|
|
540
|
+
const animations = [
|
|
541
|
+
animation("position-tween", "position", [{ percentage: 0, properties: { x: 0 } }]),
|
|
542
|
+
animation("visual-tween", "visual", [{ percentage: 0, properties: { opacity: 0 } }]),
|
|
543
|
+
];
|
|
544
|
+
const { host, root } = renderPropertyLanes({
|
|
545
|
+
id: "timeline-lanes-track-0",
|
|
546
|
+
animations,
|
|
547
|
+
clipLeftPx: 120,
|
|
548
|
+
clipWidthPx: 200,
|
|
549
|
+
});
|
|
550
|
+
|
|
551
|
+
const lanes = Array.from(host.querySelectorAll<HTMLElement>("[data-timeline-property-lane]"));
|
|
552
|
+
expect(lanes.map((lane) => lane.style.top)).toEqual([
|
|
553
|
+
`${getTimelineLaneTop(0)}px`,
|
|
554
|
+
`${getTimelineLaneTop(1)}px`,
|
|
555
|
+
]);
|
|
556
|
+
expect(lanes.map((lane) => lane.style.left)).toEqual(["120px", "120px"]);
|
|
557
|
+
expect(lanes.map((lane) => lane.style.width)).toEqual(["200px", "200px"]);
|
|
558
|
+
expect(lanes.map((lane) => lane.style.height)).toEqual([`${LANE_H}px`, `${LANE_H}px`]);
|
|
559
|
+
act(() => root.unmount());
|
|
560
|
+
});
|
|
561
|
+
|
|
562
|
+
// The wrapper is the aria-controls target in BOTH disclosure states, so a
|
|
563
|
+
// collapsed layer (no animations reach it) must still resolve the id.
|
|
564
|
+
it("still renders the identified wrapper when there are no lanes to show", () => {
|
|
565
|
+
const { host, root } = renderPropertyLanes({ id: "timeline-lanes-track-0", animations: [] });
|
|
566
|
+
|
|
567
|
+
const wrapper = host.querySelector("#timeline-lanes-track-0");
|
|
568
|
+
expect(wrapper).not.toBeNull();
|
|
569
|
+
expect(wrapper?.querySelectorAll("[data-timeline-property-lane]")).toHaveLength(0);
|
|
570
|
+
act(() => root.unmount());
|
|
571
|
+
});
|
|
572
|
+
|
|
402
573
|
it("keeps the collapsed TimelineClipDiamonds positions and callback contract unchanged", () => {
|
|
403
574
|
const onClickKeyframe = vi.fn();
|
|
404
575
|
const COLLAPSED_IDENTITY = { animationId: "position-tween", propertyGroup: "position" };
|
|
@@ -448,3 +619,49 @@ describe("TimelinePropertyLanes", () => {
|
|
|
448
619
|
act(() => root.unmount());
|
|
449
620
|
});
|
|
450
621
|
});
|
|
622
|
+
|
|
623
|
+
describe("resolveAnimIdForProperty", () => {
|
|
624
|
+
/** A legacy mixed tween: the parser leaves propertyGroup undefined for it. */
|
|
625
|
+
const mixed = {
|
|
626
|
+
id: "mixed-1",
|
|
627
|
+
targetSelector: "#box",
|
|
628
|
+
method: "to",
|
|
629
|
+
position: 0,
|
|
630
|
+
properties: {},
|
|
631
|
+
keyframes: {
|
|
632
|
+
keyframes: [
|
|
633
|
+
{ percentage: 0, properties: { x: 0, opacity: 0 } },
|
|
634
|
+
{ percentage: 100, properties: { x: 40, opacity: 1 } },
|
|
635
|
+
],
|
|
636
|
+
},
|
|
637
|
+
} as unknown as GsapAnimation;
|
|
638
|
+
|
|
639
|
+
it("routes both groups of a mixed tween to that tween, not the fallback", () => {
|
|
640
|
+
expect(mixed.propertyGroup).toBeUndefined();
|
|
641
|
+
|
|
642
|
+
expect(resolveAnimIdForProperty("x", [mixed], "fallback")).toBe("mixed-1");
|
|
643
|
+
expect(resolveAnimIdForProperty("opacity", [mixed], "fallback")).toBe("mixed-1");
|
|
644
|
+
});
|
|
645
|
+
|
|
646
|
+
it("falls back only when no tween animates the property's group", () => {
|
|
647
|
+
expect(resolveAnimIdForProperty("rotation", [mixed], "fallback")).toBe("fallback");
|
|
648
|
+
expect(resolveAnimIdForProperty("rotation", [mixed], undefined)).toBe("");
|
|
649
|
+
});
|
|
650
|
+
|
|
651
|
+
it("prefers a single-group tween that owns the lane", () => {
|
|
652
|
+
const opacityOnly = {
|
|
653
|
+
...mixed,
|
|
654
|
+
id: "opacity-1",
|
|
655
|
+
propertyGroup: "visual",
|
|
656
|
+
keyframes: {
|
|
657
|
+
keyframes: [
|
|
658
|
+
{ percentage: 0, properties: { opacity: 0 } },
|
|
659
|
+
{ percentage: 100, properties: { opacity: 1 } },
|
|
660
|
+
],
|
|
661
|
+
},
|
|
662
|
+
} as unknown as GsapAnimation;
|
|
663
|
+
|
|
664
|
+
expect(resolveAnimIdForProperty("opacity", [opacityOnly, mixed], "fallback")).toBe("opacity-1");
|
|
665
|
+
expect(resolveAnimIdForProperty("x", [opacityOnly, mixed], "fallback")).toBe("mixed-1");
|
|
666
|
+
});
|
|
667
|
+
});
|
|
@@ -11,6 +11,12 @@ import { LANE_H, getTimelineLaneTop } from "./timelineLayout";
|
|
|
11
11
|
import type { TimelineKeyframeTarget } from "./timelineKeyframeIdentity";
|
|
12
12
|
|
|
13
13
|
export interface TimelinePropertyLanesProps {
|
|
14
|
+
/**
|
|
15
|
+
* Id of the wrapper below, so the layer's disclosure caret can point
|
|
16
|
+
* `aria-controls` at the lanes a sighted user sees it reveal. Minted by
|
|
17
|
+
* TimelineLanes, which owns both this subtree and the caret's.
|
|
18
|
+
*/
|
|
19
|
+
id?: string;
|
|
14
20
|
animations: readonly GsapAnimation[];
|
|
15
21
|
clipStart: number;
|
|
16
22
|
clipDuration: number;
|
|
@@ -29,11 +35,25 @@ export interface TimelinePropertyLanesProps {
|
|
|
29
35
|
suppressClickRef?: RefObject<boolean>;
|
|
30
36
|
}
|
|
31
37
|
|
|
38
|
+
/**
|
|
39
|
+
* Keys that ride along in a tween's property bag without being animated: a
|
|
40
|
+
* transform modifier, Studio's internal endpoint marker, and GSAP's reserved
|
|
41
|
+
* `data`. Same exclusion list the parser's classifyTweenPropertyGroup applies —
|
|
42
|
+
* without it `{ x, transformOrigin }` would draw a spurious "Other" lane.
|
|
43
|
+
*/
|
|
44
|
+
const NON_ANIMATED_PROPERTIES = new Set(["transformOrigin", "_auto", "data"]);
|
|
45
|
+
|
|
46
|
+
function isAnimatedProperty(property: string): boolean {
|
|
47
|
+
return !NON_ANIMATED_PROPERTIES.has(property);
|
|
48
|
+
}
|
|
49
|
+
|
|
32
50
|
function hasGroupProperty(
|
|
33
51
|
properties: Record<string, number | string>,
|
|
34
52
|
group: PropertyGroupName,
|
|
35
53
|
): boolean {
|
|
36
|
-
return Object.keys(properties).some(
|
|
54
|
+
return Object.keys(properties).some(
|
|
55
|
+
(property) => isAnimatedProperty(property) && classifyPropertyGroup(property) === group,
|
|
56
|
+
);
|
|
37
57
|
}
|
|
38
58
|
|
|
39
59
|
/** The tween's editable keyframes: its real keyframes, or the start→end pair
|
|
@@ -42,19 +62,63 @@ function animationKeyframes(animation: GsapAnimation) {
|
|
|
42
62
|
return animation.keyframes?.keyframes ?? synthesizeFlatTweenKeyframes(animation)?.keyframes ?? [];
|
|
43
63
|
}
|
|
44
64
|
|
|
45
|
-
/**
|
|
46
|
-
*
|
|
65
|
+
/**
|
|
66
|
+
* Every property group a tween draws a lane for, classified PER PROPERTY.
|
|
67
|
+
* `animation.propertyGroup` is the parser's whole-tween verdict and is
|
|
68
|
+
* `undefined` for anything spanning more than one group — but `{ x, opacity }`
|
|
69
|
+
* is the canonical HyperFrames entrance tween, and reading that verdict gave it
|
|
70
|
+
* no caret, no reserved row and no diamonds. classifyPropertyGroup is total, so
|
|
71
|
+
* an unrecognised property still lands in "other" rather than vanishing.
|
|
72
|
+
*
|
|
73
|
+
* Single owner: the rendered lanes (sourceGroups) and the reserved row heights
|
|
74
|
+
* (computeLaneCounts) both count groups through here, or they drift.
|
|
75
|
+
*/
|
|
76
|
+
export function animationLaneGroups(animation: GsapAnimation): PropertyGroupName[] {
|
|
77
|
+
const groups = new Set<PropertyGroupName>();
|
|
78
|
+
for (const keyframe of animationKeyframes(animation)) {
|
|
79
|
+
for (const property of Object.keys(keyframe.properties)) {
|
|
80
|
+
if (isAnimatedProperty(property)) groups.add(classifyPropertyGroup(property));
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
return Array.from(groups);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Which tween a panel edit to `prop` belongs to.
|
|
88
|
+
*
|
|
89
|
+
* Matches on the groups the tween's KEYFRAMES animate, not on the parser's
|
|
90
|
+
* whole-tween `propertyGroup` verdict: that field is undefined for a legacy
|
|
91
|
+
* mixed tween such as `{ x, opacity }`, so matching it dropped every such
|
|
92
|
+
* tween and sent the edit to the selection's default animation instead, which
|
|
93
|
+
* is a different tween than the lane the user is looking at.
|
|
94
|
+
* {@link animationLaneGroups} is the single owner the rendered lanes count
|
|
95
|
+
* groups through, so resolving here through the same helper keeps the panel
|
|
96
|
+
* and the lanes on one answer.
|
|
97
|
+
*/
|
|
98
|
+
export function resolveAnimIdForProperty(
|
|
99
|
+
prop: string,
|
|
100
|
+
animations: readonly GsapAnimation[] | undefined,
|
|
101
|
+
fallbackAnimId: string | undefined,
|
|
102
|
+
): string {
|
|
103
|
+
const group = classifyPropertyGroup(prop);
|
|
104
|
+
const groupAnim = animations?.find((a) => animationLaneGroups(a).includes(group));
|
|
105
|
+
return groupAnim?.id ?? fallbackAnimId ?? "";
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** A tween contributes a property lane when it animates at least one property
|
|
109
|
+
* on at least one editable keyframe (real or synthesized). */
|
|
47
110
|
export function animationContributesLane(animation: GsapAnimation): boolean {
|
|
48
|
-
return
|
|
111
|
+
return animationLaneGroups(animation).length > 0;
|
|
49
112
|
}
|
|
50
113
|
|
|
51
114
|
function sourceGroups(animations: readonly GsapAnimation[]) {
|
|
52
115
|
const groups = new Map<PropertyGroupName, GsapAnimation[]>();
|
|
53
116
|
for (const animation of animations) {
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
117
|
+
for (const group of animationLaneGroups(animation)) {
|
|
118
|
+
const groupAnimations = groups.get(group) ?? [];
|
|
119
|
+
groupAnimations.push(animation);
|
|
120
|
+
groups.set(group, groupAnimations);
|
|
121
|
+
}
|
|
58
122
|
}
|
|
59
123
|
return groups;
|
|
60
124
|
}
|
|
@@ -112,6 +176,7 @@ export function getTimelinePropertyLanes(
|
|
|
112
176
|
}
|
|
113
177
|
|
|
114
178
|
export function TimelinePropertyLanes({
|
|
179
|
+
id,
|
|
115
180
|
animations,
|
|
116
181
|
clipStart,
|
|
117
182
|
clipDuration,
|
|
@@ -148,9 +213,13 @@ export function TimelinePropertyLanes({
|
|
|
148
213
|
[lanes],
|
|
149
214
|
);
|
|
150
215
|
|
|
151
|
-
|
|
216
|
+
// One STATIC wrapper, never `relative`: a static box establishes no containing
|
|
217
|
+
// block, so every absolutely-positioned lane below still resolves against the
|
|
218
|
+
// track-content div and the rendered geometry is byte-identical to the bare
|
|
219
|
+
// fragment this replaced. It is also rendered when there are no lanes at all
|
|
220
|
+
// (collapsed layer), so `id` stays resolvable in both disclosure states.
|
|
152
221
|
return (
|
|
153
|
-
|
|
222
|
+
<div id={id}>
|
|
154
223
|
{laneData.map(({ group, keyframesData }, laneIndex) => (
|
|
155
224
|
<div
|
|
156
225
|
key={group}
|
|
@@ -186,6 +255,6 @@ export function TimelinePropertyLanes({
|
|
|
186
255
|
/>
|
|
187
256
|
</div>
|
|
188
257
|
))}
|
|
189
|
-
|
|
258
|
+
</div>
|
|
190
259
|
);
|
|
191
260
|
}
|