@hyperframes/studio 0.7.78 → 0.7.79
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-mFah2TZE.js} +1 -1
- package/dist/assets/{index-CDSTMtUs.js → index-Bqj3h_1a.js} +1 -1
- package/dist/assets/{index-BSZrK0bx.js → index-DlZMDyYs.js} +194 -194
- package/dist/assets/index-gGVKuFg5.css +1 -0
- package/dist/assets/{index-r1tKKlU7.js → index-hmnoiSEV.js} +1 -1
- package/dist/index.html +2 -2
- package/dist/index.js +1303 -1121
- package/dist/index.js.map +1 -1
- package/package.json +7 -7
- package/src/components/StudioLeftSidebar.tsx +10 -5
- package/src/components/StudioRightPanel.tsx +5 -4
- package/src/components/TimelineToolbar.tsx +3 -1
- package/src/components/editor/KeyframeNavigation.tsx +5 -0
- package/src/components/editor/MotionPathOverlay.tsx +4 -1
- package/src/components/editor/PropertyPanel.tsx +3 -7
- 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/propertyPanelPrimitives.tsx +3 -1
- 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/hooks/gsapDragCommit.ts +43 -10
- package/src/hooks/gsapDragStaticSetHelpers.ts +16 -6
- package/src/hooks/gsapKeyframeCacheHelpers.test.ts +63 -0
- package/src/hooks/gsapKeyframeCacheHelpers.ts +45 -38
- 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/keyframeCacheAstLoad.ts +5 -71
- 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/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 +4 -11
- 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 +17 -2
- package/src/player/components/TimelineClipDiamonds.tsx +11 -1
- package/src/player/components/TimelineDiamondConnectors.tsx +14 -1
- 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/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/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 +151 -2
- package/src/player/hooks/useExpandedTimelineElements.ts +46 -7
- package/src/telemetry/client.test.ts +12 -3
- package/src/telemetry/client.ts +3 -2
- package/dist/assets/index-DpkO2Hvw.css +0 -1
|
@@ -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
|
}
|
|
@@ -60,12 +60,14 @@ const OPACITY = animation("opacity-tween", "visual", [
|
|
|
60
60
|
]);
|
|
61
61
|
|
|
62
62
|
interface RenderHeaderOptions {
|
|
63
|
+
keyframeClip?: TimelineElement;
|
|
63
64
|
animations?: GsapAnimation[];
|
|
64
65
|
clipCount?: number;
|
|
65
66
|
currentTime?: number;
|
|
66
67
|
expanded?: boolean;
|
|
67
68
|
onSeek?: (time: number) => void;
|
|
68
69
|
onTogglePropertyGroupKeyframe?: TimelineEditCallbacks["onTogglePropertyGroupKeyframe"];
|
|
70
|
+
onToggleTrackHidden?: TimelineEditCallbacks["onToggleTrackHidden"];
|
|
69
71
|
}
|
|
70
72
|
|
|
71
73
|
function renderHeader(options: RenderHeaderOptions = {}): {
|
|
@@ -80,10 +82,14 @@ function renderHeader(options: RenderHeaderOptions = {}): {
|
|
|
80
82
|
act(() => {
|
|
81
83
|
root.render(
|
|
82
84
|
<TimelineTrackHeader
|
|
83
|
-
|
|
85
|
+
// A real fractional z-order sort key, so a label built from it would
|
|
86
|
+
// read out "track 0.16666666666666666".
|
|
87
|
+
trackNumber={1 / 6}
|
|
88
|
+
trackDisplayNumber={1}
|
|
84
89
|
trackLabel="Hero card"
|
|
90
|
+
lanesId="timeline-lanes-track-0"
|
|
85
91
|
contentOrigin={LABEL_COL_W}
|
|
86
|
-
keyframeClip={ELEMENT}
|
|
92
|
+
keyframeClip={next.keyframeClip ?? ELEMENT}
|
|
87
93
|
clipCount={next.clipCount ?? 1}
|
|
88
94
|
isExpanded={next.expanded !== false}
|
|
89
95
|
animations={next.animations ?? [POSITION, OPACITY]}
|
|
@@ -92,7 +98,7 @@ function renderHeader(options: RenderHeaderOptions = {}): {
|
|
|
92
98
|
isAudioTrack={false}
|
|
93
99
|
theme={defaultTimelineTheme}
|
|
94
100
|
onToggleClipExpanded={vi.fn()}
|
|
95
|
-
onToggleTrackHidden={vi.fn()}
|
|
101
|
+
onToggleTrackHidden={next.onToggleTrackHidden ?? vi.fn()}
|
|
96
102
|
onTogglePropertyGroupKeyframe={next.onTogglePropertyGroupKeyframe}
|
|
97
103
|
onSeek={next.onSeek}
|
|
98
104
|
/>,
|
|
@@ -110,6 +116,53 @@ function click(host: HTMLElement, label: string) {
|
|
|
110
116
|
}
|
|
111
117
|
|
|
112
118
|
describe("TimelineTrackHeader", () => {
|
|
119
|
+
// An expanded sub-composition child sits on the MASTER timeline at a
|
|
120
|
+
// host-absolute start, but its tweens are parsed from its own file and are
|
|
121
|
+
// local to it. Feeding the raw start straight into the clip-% math put every
|
|
122
|
+
// lane keyframe far outside the clip.
|
|
123
|
+
it("keeps an expanded sub-comp child's lane percentages inside the clip", () => {
|
|
124
|
+
const child: TimelineElement = {
|
|
125
|
+
id: "pill",
|
|
126
|
+
tag: "div",
|
|
127
|
+
start: 16.5,
|
|
128
|
+
duration: 2,
|
|
129
|
+
track: 0,
|
|
130
|
+
expandedParentStart: 16,
|
|
131
|
+
sourceFile: "scene.html",
|
|
132
|
+
};
|
|
133
|
+
const local: GsapAnimation = {
|
|
134
|
+
id: "pill-tween",
|
|
135
|
+
targetSelector: "#pill",
|
|
136
|
+
method: "to",
|
|
137
|
+
position: 0.5,
|
|
138
|
+
resolvedStart: 0.5,
|
|
139
|
+
duration: 2,
|
|
140
|
+
properties: {},
|
|
141
|
+
propertyGroup: "position",
|
|
142
|
+
keyframes: {
|
|
143
|
+
format: "percentage",
|
|
144
|
+
keyframes: [
|
|
145
|
+
{ percentage: 0, properties: { x: 0 } },
|
|
146
|
+
{ percentage: 100, properties: { x: 100 } },
|
|
147
|
+
],
|
|
148
|
+
},
|
|
149
|
+
};
|
|
150
|
+
// Playhead at the clip's midpoint (master time), so the 100% keyframe is
|
|
151
|
+
// ahead of it. On the raw host-absolute basis every keyframe rebased to a
|
|
152
|
+
// large negative percentage and nothing was ever ahead of the playhead.
|
|
153
|
+
const view = renderHeader({
|
|
154
|
+
keyframeClip: child,
|
|
155
|
+
animations: [local],
|
|
156
|
+
currentTime: 17.5,
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
expect(
|
|
160
|
+
view.host.querySelector<HTMLButtonElement>('button[aria-label="Next Position keyframe"]')
|
|
161
|
+
?.disabled,
|
|
162
|
+
).toBe(false);
|
|
163
|
+
act(() => view.root.unmount());
|
|
164
|
+
});
|
|
165
|
+
|
|
113
166
|
// The header shows one clip's lanes, so how many clips the track holds is
|
|
114
167
|
// otherwise invisible from the label column. A single-clip track stays silent.
|
|
115
168
|
it("shows the track's clip count only once the track holds more than one clip", () => {
|
|
@@ -125,10 +178,27 @@ describe("TimelineTrackHeader", () => {
|
|
|
125
178
|
// in every disclosure state — a hover-gated eye is unusable by keyboard.
|
|
126
179
|
it("keeps the visibility eye mounted whether the layer is expanded or collapsed", () => {
|
|
127
180
|
const view = renderHeader({ expanded: true });
|
|
128
|
-
expect(view.host.querySelector('button[aria-label="Hide track
|
|
181
|
+
expect(view.host.querySelector('button[aria-label="Hide track 1"]')).not.toBeNull();
|
|
129
182
|
|
|
130
183
|
view.rerender({ expanded: false });
|
|
131
|
-
expect(view.host.querySelector('button[aria-label="Hide track
|
|
184
|
+
expect(view.host.querySelector('button[aria-label="Hide track 1"]')).not.toBeNull();
|
|
185
|
+
act(() => view.root.unmount());
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
// trackNumber is a fractional z-order sort key, so building the label from it
|
|
189
|
+
// made screen readers announce "Hide track 0.16666666666666666". The display
|
|
190
|
+
// number is label-only; the toggle still routes by the real key.
|
|
191
|
+
it("announces the display track number but toggles with the real fractional key", () => {
|
|
192
|
+
const onToggleTrackHidden = vi.fn();
|
|
193
|
+
const view = renderHeader({ onToggleTrackHidden });
|
|
194
|
+
const eye = view.host.querySelector<HTMLButtonElement>('button[aria-label="Hide track 1"]');
|
|
195
|
+
|
|
196
|
+
expect(eye).not.toBeNull();
|
|
197
|
+
expect(eye?.title).toBe("Hide track 1");
|
|
198
|
+
expect(view.host.innerHTML).not.toContain("0.16666666666666666");
|
|
199
|
+
|
|
200
|
+
act(() => eye?.click());
|
|
201
|
+
expect(onToggleTrackHidden).toHaveBeenCalledWith(1 / 6, true);
|
|
132
202
|
act(() => view.root.unmount());
|
|
133
203
|
});
|
|
134
204
|
|
|
@@ -4,6 +4,7 @@ import { Music } from "../../icons/SystemIcons";
|
|
|
4
4
|
import type { TimelineElement } from "../store/playerStore";
|
|
5
5
|
import type { TimelineEditCallbacks } from "./timelineCallbacks";
|
|
6
6
|
import { getTimelinePropertyLanes } from "./TimelinePropertyLanes";
|
|
7
|
+
import { clipTimingStart } from "../../hooks/gsapShared";
|
|
7
8
|
import { LayerDisclosureRow } from "./LayerDisclosureRow";
|
|
8
9
|
import { TrackClipCount } from "./TrackClipCount";
|
|
9
10
|
import { LABEL_COL_W, LANE_H, getTimelineLaneTop } from "./timelineLayout";
|
|
@@ -14,10 +15,21 @@ import {
|
|
|
14
15
|
type TimelinePropertyLane,
|
|
15
16
|
} from "./trackHeaderLaneState";
|
|
16
17
|
import { valueReadout } from "./trackHeaderLaneValues";
|
|
18
|
+
import { trackDisplaySuffix } from "./timelineTrackDisplay";
|
|
17
19
|
|
|
18
20
|
interface TimelineTrackHeaderProps {
|
|
21
|
+
/** The track's real key: a FRACTIONAL z-order sort value. Routes callbacks;
|
|
22
|
+
* never shown or announced. */
|
|
19
23
|
trackNumber: number;
|
|
24
|
+
/** The track's 1-based position in the rendered order: the only number safe
|
|
25
|
+
* to put in a label. Announcing `trackNumber` read out "track
|
|
26
|
+
* 0.16666666666666666". Null when the key has no row, which drops the number
|
|
27
|
+
* from the label rather than inventing one (see trackDisplayNumber). */
|
|
28
|
+
trackDisplayNumber: number | null;
|
|
20
29
|
trackLabel: string;
|
|
30
|
+
/** Id of the canvas-side lanes element the disclosure caret expands. Minted by
|
|
31
|
+
* TimelineLanes, which is the one place that sees both subtrees. */
|
|
32
|
+
lanesId: string;
|
|
21
33
|
contentOrigin: number;
|
|
22
34
|
/** The track's active keyframe clip (selected, else primary) — the one whose
|
|
23
35
|
* disclosure + property rows this header shows, whether expanded or not. */
|
|
@@ -39,16 +51,21 @@ interface TimelineTrackHeaderProps {
|
|
|
39
51
|
function VisibilityButton({
|
|
40
52
|
hidden,
|
|
41
53
|
trackNumber,
|
|
54
|
+
trackDisplayNumber,
|
|
42
55
|
visible,
|
|
43
56
|
onToggle,
|
|
44
57
|
}: {
|
|
45
58
|
hidden: boolean;
|
|
46
59
|
trackNumber: number;
|
|
60
|
+
trackDisplayNumber: number | null;
|
|
47
61
|
visible: boolean;
|
|
48
62
|
onToggle: TimelineEditCallbacks["onToggleTrackHidden"];
|
|
49
63
|
}) {
|
|
50
64
|
if (!visible) return <span aria-hidden="true" className="h-6 w-6 shrink-0" />;
|
|
51
|
-
|
|
65
|
+
// Display number in the text, real key in the callback. The two must not be
|
|
66
|
+
// conflated in either direction.
|
|
67
|
+
const suffix = trackDisplaySuffix(trackDisplayNumber);
|
|
68
|
+
const label = hidden ? `Show track${suffix}` : `Hide track${suffix}`;
|
|
52
69
|
return (
|
|
53
70
|
<button
|
|
54
71
|
type="button"
|
|
@@ -76,6 +93,7 @@ function VisibilityButton({
|
|
|
76
93
|
// count, eye. Not deprecated — it is the live path for every track without lanes.
|
|
77
94
|
function PlainTrackHeader({
|
|
78
95
|
trackNumber,
|
|
96
|
+
trackDisplayNumber,
|
|
79
97
|
trackLabel,
|
|
80
98
|
clipCount,
|
|
81
99
|
showTrackLabel,
|
|
@@ -85,6 +103,7 @@ function PlainTrackHeader({
|
|
|
85
103
|
}: Pick<
|
|
86
104
|
TimelineTrackHeaderProps,
|
|
87
105
|
| "trackNumber"
|
|
106
|
+
| "trackDisplayNumber"
|
|
88
107
|
| "trackLabel"
|
|
89
108
|
| "clipCount"
|
|
90
109
|
| "isTrackHidden"
|
|
@@ -105,6 +124,7 @@ function PlainTrackHeader({
|
|
|
105
124
|
<VisibilityButton
|
|
106
125
|
hidden={isTrackHidden}
|
|
107
126
|
trackNumber={trackNumber}
|
|
127
|
+
trackDisplayNumber={trackDisplayNumber}
|
|
108
128
|
visible
|
|
109
129
|
onToggle={onToggleTrackHidden}
|
|
110
130
|
/>
|
|
@@ -262,7 +282,9 @@ function PropertyGroupHeaderRow({
|
|
|
262
282
|
|
|
263
283
|
export function TimelineTrackHeader({
|
|
264
284
|
trackNumber,
|
|
285
|
+
trackDisplayNumber,
|
|
265
286
|
trackLabel,
|
|
287
|
+
lanesId,
|
|
266
288
|
contentOrigin,
|
|
267
289
|
keyframeClip,
|
|
268
290
|
clipCount,
|
|
@@ -281,13 +303,14 @@ export function TimelineTrackHeader({
|
|
|
281
303
|
? ((currentTime - keyframeClip.start) / keyframeClip.duration) * 100
|
|
282
304
|
: 0;
|
|
283
305
|
const lanes = keyframeClip
|
|
284
|
-
?
|
|
306
|
+
? // clipTimingStart, not the raw start: an expanded sub-comp child's start is
|
|
307
|
+
// host-absolute while its tweens are local to its own file.
|
|
308
|
+
getTimelinePropertyLanes(animations, clipTimingStart(keyframeClip), keyframeClip.duration)
|
|
285
309
|
: [];
|
|
286
310
|
// Label mode = keyframe view; the label column stays LABEL_COL_W (Timeline.tsx
|
|
287
311
|
// owns the gutter past it, so a 0% diamond isn't clipped by this panel).
|
|
288
312
|
const showTrackLabel = contentOrigin >= LABEL_COL_W;
|
|
289
313
|
const isKeyframeLayer = !!keyframeClip && lanes.length > 0;
|
|
290
|
-
const lanesId = `timeline-lanes-track-${trackNumber}`;
|
|
291
314
|
|
|
292
315
|
return (
|
|
293
316
|
<div
|
|
@@ -307,6 +330,7 @@ export function TimelineTrackHeader({
|
|
|
307
330
|
{!keyframeClip || lanes.length === 0 ? (
|
|
308
331
|
<PlainTrackHeader
|
|
309
332
|
trackNumber={trackNumber}
|
|
333
|
+
trackDisplayNumber={trackDisplayNumber}
|
|
310
334
|
trackLabel={trackLabel}
|
|
311
335
|
clipCount={clipCount}
|
|
312
336
|
showTrackLabel={showTrackLabel}
|
|
@@ -333,29 +357,35 @@ export function TimelineTrackHeader({
|
|
|
333
357
|
<VisibilityButton
|
|
334
358
|
hidden={isTrackHidden}
|
|
335
359
|
trackNumber={trackNumber}
|
|
360
|
+
trackDisplayNumber={trackDisplayNumber}
|
|
336
361
|
visible
|
|
337
362
|
onToggle={onToggleTrackHidden}
|
|
338
363
|
/>
|
|
339
364
|
</LayerDisclosureRow>
|
|
340
|
-
{/*
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
365
|
+
{/* The caret expands TWO disjoint subtrees: these label-column rows,
|
|
366
|
+
which carry the per-lane keyframe controls, and the diamond lanes
|
|
367
|
+
on the canvas. `lanesId` names the canvas lanes (rendered by
|
|
368
|
+
TimelineLanes), because that is what a sighted user watches appear
|
|
369
|
+
and what following the reference has to land on. These rows are not
|
|
370
|
+
empty and are not the target; they are absolutely positioned inside
|
|
371
|
+
the sticky column, which is what made a wrapper HERE compute to
|
|
372
|
+
0x0 and hold no diamonds. */}
|
|
373
|
+
{isExpanded &&
|
|
374
|
+
lanes.map((lane, laneIndex) => (
|
|
375
|
+
<PropertyGroupHeaderRow
|
|
376
|
+
key={lane.group}
|
|
377
|
+
lane={lane}
|
|
378
|
+
laneIndex={laneIndex}
|
|
379
|
+
isLastLane={laneIndex === lanes.length - 1}
|
|
380
|
+
expandedElement={keyframeClip}
|
|
381
|
+
currentTime={currentTime}
|
|
382
|
+
clipPercentage={clipPercentage}
|
|
383
|
+
gutterBackground={theme.gutterBackground}
|
|
384
|
+
columnWidth={showTrackLabel ? LABEL_COL_W : contentOrigin}
|
|
385
|
+
onTogglePropertyGroupKeyframe={onTogglePropertyGroupKeyframe}
|
|
386
|
+
onSeek={onSeek}
|
|
387
|
+
/>
|
|
388
|
+
))}
|
|
359
389
|
</>
|
|
360
390
|
)}
|
|
361
391
|
</div>
|