@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
|
@@ -37,13 +37,6 @@ export function TimelineDiamondConnectors({
|
|
|
37
37
|
keyframeTarget: (keyframe: TimelineDiamondKeyframe) => TimelineKeyframeTarget;
|
|
38
38
|
onSelectSegment?: (target: TimelineKeyframeTarget) => void;
|
|
39
39
|
}) {
|
|
40
|
-
// The ease button sits dead centre of its segment, which on a two-keyframe clip
|
|
41
|
-
// is the centre of the clip bar — the natural place to grab a clip and drag it.
|
|
42
|
-
// Swallowing pointerdown there made that grab a no-op. Instead the press falls
|
|
43
|
-
// through to the clip (so the drag starts normally) and the button keeps only
|
|
44
|
-
// the click, which we drop if the pointer actually travelled.
|
|
45
|
-
const pressXRef = useRef<number | null>(null);
|
|
46
|
-
|
|
47
40
|
return (
|
|
48
41
|
<>
|
|
49
42
|
{markers.map((marker, i) => {
|
|
@@ -55,13 +48,6 @@ export function TimelineDiamondConnectors({
|
|
|
55
48
|
if (x2 - x1 < 1) return null;
|
|
56
49
|
const connectorLeft = x1 + previous.visualSize / 2;
|
|
57
50
|
const connectorWidth = x2 - x1 - previous.visualSize / 2 - marker.visualSize / 2;
|
|
58
|
-
// The ease button targets one segment, so it needs the keyframe's own
|
|
59
|
-
// animationId/tweenPercentage. On a merged inline row the button is
|
|
60
|
-
// hidden where the segment is ambiguous (two source animations collide
|
|
61
|
-
// at this % with different eases; see easeAmbiguous) or the keyframe has
|
|
62
|
-
// no source animation id (runtime-scanned) so there is no tween to target.
|
|
63
|
-
const target = keyframeTarget(kf);
|
|
64
|
-
const ease = kf.ease ?? globalEase;
|
|
65
51
|
return (
|
|
66
52
|
<Fragment key={`line-${i}-${previous.keyframe.percentage}-${kf.percentage}`}>
|
|
67
53
|
<div
|
|
@@ -78,64 +64,22 @@ export function TimelineDiamondConnectors({
|
|
|
78
64
|
borderRadius: 1,
|
|
79
65
|
}}
|
|
80
66
|
/>
|
|
81
|
-
{onSelectSegment &&
|
|
82
|
-
<
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
// segment wrapper must not swallow connector/clip gestures.
|
|
98
|
-
pointerEvents: "none",
|
|
99
|
-
}}
|
|
100
|
-
>
|
|
101
|
-
<button
|
|
102
|
-
type="button"
|
|
103
|
-
data-keyframe-ease-button=""
|
|
104
|
-
aria-label={`Edit ${ease} easing`}
|
|
105
|
-
title={`Edit ${ease} easing`}
|
|
106
|
-
className="absolute flex items-center justify-center rounded opacity-0 transition-opacity group-hover:opacity-100 focus-visible:opacity-100"
|
|
107
|
-
style={{
|
|
108
|
-
left: "50%",
|
|
109
|
-
top: "50%",
|
|
110
|
-
width: 16,
|
|
111
|
-
height: 16,
|
|
112
|
-
transform: "translate(-50%, -50%)",
|
|
113
|
-
zIndex: 3,
|
|
114
|
-
pointerEvents: "auto",
|
|
115
|
-
padding: 0,
|
|
116
|
-
border: "1px solid rgba(255, 255, 255, 0.14)",
|
|
117
|
-
background: "#171717",
|
|
118
|
-
cursor: "pointer",
|
|
119
|
-
}}
|
|
120
|
-
onPointerDown={(e) => {
|
|
121
|
-
pressXRef.current = e.clientX;
|
|
122
|
-
}}
|
|
123
|
-
onClick={(e) => {
|
|
124
|
-
e.stopPropagation();
|
|
125
|
-
const pressX = pressXRef.current;
|
|
126
|
-
pressXRef.current = null;
|
|
127
|
-
if (
|
|
128
|
-
pressX !== null &&
|
|
129
|
-
Math.abs(e.clientX - pressX) >= KEYFRAME_DRAG_THRESHOLD_PX
|
|
130
|
-
) {
|
|
131
|
-
return;
|
|
132
|
-
}
|
|
133
|
-
onSelectSegment(target);
|
|
134
|
-
}}
|
|
135
|
-
>
|
|
136
|
-
<MiniCurveSvg ease={ease} active size={12} />
|
|
137
|
-
</button>
|
|
138
|
-
</div>
|
|
67
|
+
{onSelectSegment && showsEaseControl(kf) && (
|
|
68
|
+
<SegmentEaseControl
|
|
69
|
+
left={x1}
|
|
70
|
+
width={x2 - x1}
|
|
71
|
+
centerY={centerY}
|
|
72
|
+
ease={kf.ease ?? globalEase}
|
|
73
|
+
target={keyframeTarget(kf)}
|
|
74
|
+
// connectorWidth is the clear span between the two diamonds'
|
|
75
|
+
// edges, so a 24x24 target centred in it overhangs a diamond as
|
|
76
|
+
// soon as the span is narrower than 24. The segment wrapper sits
|
|
77
|
+
// at z-index 3, above the diamonds, so that overhang would win
|
|
78
|
+
// the hit test and steal their clicks at fit zoom. Grow the
|
|
79
|
+
// target only where the room exists.
|
|
80
|
+
roomForFullTarget={connectorWidth >= 24}
|
|
81
|
+
onSelectSegment={onSelectSegment}
|
|
82
|
+
/>
|
|
139
83
|
)}
|
|
140
84
|
</Fragment>
|
|
141
85
|
);
|
|
@@ -143,3 +87,105 @@ export function TimelineDiamondConnectors({
|
|
|
143
87
|
</>
|
|
144
88
|
);
|
|
145
89
|
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* The ease control targets one segment, so it needs the keyframe's own
|
|
93
|
+
* animationId. A merged keyframe now shows one too: the editor edits every
|
|
94
|
+
* colliding tween together, so one button standing for several curves is
|
|
95
|
+
* honest. Only a keyframe with no source animation id (runtime-scanned) is left
|
|
96
|
+
* out, because there is no tween to target.
|
|
97
|
+
*/
|
|
98
|
+
function showsEaseControl(kf: TimelineDiamondKeyframe): boolean {
|
|
99
|
+
return kf.animationId !== undefined;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* The ease button centred on one connector segment, plus the transparent
|
|
104
|
+
* wrapper that positions it. Split out of the connector map so that map stays a
|
|
105
|
+
* geometry loop and this keeps the press guard, hit-target sizing and click
|
|
106
|
+
* filtering together.
|
|
107
|
+
*/
|
|
108
|
+
function SegmentEaseControl({
|
|
109
|
+
left,
|
|
110
|
+
width,
|
|
111
|
+
centerY,
|
|
112
|
+
ease,
|
|
113
|
+
target,
|
|
114
|
+
roomForFullTarget,
|
|
115
|
+
onSelectSegment,
|
|
116
|
+
}: {
|
|
117
|
+
left: number;
|
|
118
|
+
width: number;
|
|
119
|
+
centerY: number;
|
|
120
|
+
ease: string;
|
|
121
|
+
target: TimelineKeyframeTarget;
|
|
122
|
+
roomForFullTarget: boolean;
|
|
123
|
+
onSelectSegment: (target: TimelineKeyframeTarget) => void;
|
|
124
|
+
}) {
|
|
125
|
+
// The ease button sits dead centre of its segment, which on a two-keyframe clip
|
|
126
|
+
// is the centre of the clip bar, the natural place to grab a clip and drag it.
|
|
127
|
+
// Swallowing pointerdown there made that grab a no-op. Instead the press falls
|
|
128
|
+
// through to the clip (so the drag starts normally) and the button keeps only
|
|
129
|
+
// the click, which we drop if the pointer actually travelled.
|
|
130
|
+
const pressXRef = useRef<number | null>(null);
|
|
131
|
+
return (
|
|
132
|
+
<div
|
|
133
|
+
className="group absolute"
|
|
134
|
+
data-keyframe-ease-segment=""
|
|
135
|
+
style={{
|
|
136
|
+
left,
|
|
137
|
+
top: centerY,
|
|
138
|
+
width,
|
|
139
|
+
height: 18,
|
|
140
|
+
transform: "translateY(-50%)",
|
|
141
|
+
// Own a stacking context above the diamond buttons. At fit zoom the 16px
|
|
142
|
+
// ease control can overlap its neighbouring diamond; without a z-index
|
|
143
|
+
// here the later diamond wins the hit test even though the child button
|
|
144
|
+
// has z-index 3.
|
|
145
|
+
zIndex: 3,
|
|
146
|
+
// Only the centered control is interactive. The transparent segment
|
|
147
|
+
// wrapper must not swallow connector/clip gestures.
|
|
148
|
+
pointerEvents: "none",
|
|
149
|
+
}}
|
|
150
|
+
>
|
|
151
|
+
<button
|
|
152
|
+
type="button"
|
|
153
|
+
data-keyframe-ease-button=""
|
|
154
|
+
aria-label={`Edit ${ease} easing`}
|
|
155
|
+
title={`Edit ${ease} easing`}
|
|
156
|
+
// A visible 24x24 badge would collide with the diamonds either side, so
|
|
157
|
+
// the WCAG 2.2 (2.5.8) target is met with a centered transparent
|
|
158
|
+
// ::before overlay; the box stays 16x16. Where the segment is too narrow
|
|
159
|
+
// for that overlay the button keeps its 16x16 hit area, which is WCAG's
|
|
160
|
+
// target-spacing exception: the neighbouring diamonds are themselves the
|
|
161
|
+
// reason it cannot grow, and stealing their clicks is the worse failure.
|
|
162
|
+
className={`absolute flex items-center justify-center rounded opacity-0 transition-opacity group-hover:opacity-100 focus-visible:opacity-100 ${roomForFullTarget ? "before:absolute before:left-1/2 before:top-1/2 before:h-6 before:w-6 before:-translate-x-1/2 before:-translate-y-1/2 before:content-['']" : ""}`}
|
|
163
|
+
style={{
|
|
164
|
+
left: "50%",
|
|
165
|
+
top: "50%",
|
|
166
|
+
width: 16,
|
|
167
|
+
height: 16,
|
|
168
|
+
transform: "translate(-50%, -50%)",
|
|
169
|
+
zIndex: 3,
|
|
170
|
+
pointerEvents: "auto",
|
|
171
|
+
padding: 0,
|
|
172
|
+
border: "1px solid rgba(255, 255, 255, 0.14)",
|
|
173
|
+
background: "#171717",
|
|
174
|
+
cursor: "pointer",
|
|
175
|
+
}}
|
|
176
|
+
onPointerDown={(e) => {
|
|
177
|
+
pressXRef.current = e.clientX;
|
|
178
|
+
}}
|
|
179
|
+
onClick={(e) => {
|
|
180
|
+
e.stopPropagation();
|
|
181
|
+
const pressX = pressXRef.current;
|
|
182
|
+
pressXRef.current = null;
|
|
183
|
+
if (pressX !== null && Math.abs(e.clientX - pressX) >= KEYFRAME_DRAG_THRESHOLD_PX) return;
|
|
184
|
+
onSelectSegment(target);
|
|
185
|
+
}}
|
|
186
|
+
>
|
|
187
|
+
<MiniCurveSvg ease={ease} active size={12} />
|
|
188
|
+
</button>
|
|
189
|
+
</div>
|
|
190
|
+
);
|
|
191
|
+
}
|
|
@@ -0,0 +1,281 @@
|
|
|
1
|
+
// @vitest-environment happy-dom
|
|
2
|
+
|
|
3
|
+
import React, { act, createRef } from "react";
|
|
4
|
+
import { createRoot, type Root } from "react-dom/client";
|
|
5
|
+
import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
|
|
6
|
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
7
|
+
import { TimelineLanes } from "./TimelineLanes";
|
|
8
|
+
import { getTrackStyle } from "./timelineIcons";
|
|
9
|
+
import { defaultTimelineTheme } from "./timelineTheme";
|
|
10
|
+
import { TRACK_H } from "./timelineLayout";
|
|
11
|
+
import { usePlayerStore, type TimelineElement } from "../store/playerStore";
|
|
12
|
+
import type { MultiDragPreviewInput } from "./timelineMultiDragPreview";
|
|
13
|
+
import type { TimelineEditCallbacks } from "./timelineCallbacks";
|
|
14
|
+
import type { DraggedClipState, BlockedClipState } from "./useTimelineClipDrag";
|
|
15
|
+
|
|
16
|
+
(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
|
17
|
+
|
|
18
|
+
afterEach(() => {
|
|
19
|
+
document.body.innerHTML = "";
|
|
20
|
+
usePlayerStore.getState().reset();
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
/** The z-order sort keys really are fractional: a clip nudged between two lanes
|
|
24
|
+
* lands on the midpoint. These are the values that used to reach aria-label. */
|
|
25
|
+
const TRACK_A = 1 / 6;
|
|
26
|
+
const TRACK_B = 0.5;
|
|
27
|
+
|
|
28
|
+
function element(id: string, track: number): TimelineElement {
|
|
29
|
+
return { id, label: id, tag: "div", start: 0, duration: 2, track };
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function positionTween(id: string): GsapAnimation {
|
|
33
|
+
return {
|
|
34
|
+
id: `${id}-tween`,
|
|
35
|
+
targetSelector: `#${id}`,
|
|
36
|
+
method: "to",
|
|
37
|
+
position: 0,
|
|
38
|
+
duration: 2,
|
|
39
|
+
properties: {},
|
|
40
|
+
propertyGroup: "position",
|
|
41
|
+
keyframes: {
|
|
42
|
+
format: "percentage",
|
|
43
|
+
keyframes: [
|
|
44
|
+
{ percentage: 0, properties: { x: 0 } },
|
|
45
|
+
{ percentage: 100, properties: { x: 100 } },
|
|
46
|
+
],
|
|
47
|
+
},
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
interface RenderLanesOptions {
|
|
52
|
+
elements?: TimelineElement[];
|
|
53
|
+
animations?: Map<string, GsapAnimation[]>;
|
|
54
|
+
expandedClipIds?: string[];
|
|
55
|
+
selectedElementIds?: Set<string>;
|
|
56
|
+
multiDragPreview?: MultiDragPreviewInput | null;
|
|
57
|
+
draggedClip?: DraggedClipState | null;
|
|
58
|
+
onToggleTrackHidden?: TimelineEditCallbacks["onToggleTrackHidden"];
|
|
59
|
+
onContextMenuLane?: (e: React.MouseEvent, track: number, time: number) => void;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function renderLanes(options: RenderLanesOptions = {}): {
|
|
63
|
+
host: HTMLDivElement;
|
|
64
|
+
root: Root;
|
|
65
|
+
rerender: (next: RenderLanesOptions) => void;
|
|
66
|
+
} {
|
|
67
|
+
const host = document.createElement("div");
|
|
68
|
+
document.body.append(host);
|
|
69
|
+
const root = createRoot(host);
|
|
70
|
+
const render = (next: RenderLanesOptions) => {
|
|
71
|
+
const elements = next.elements ?? [element("clip-a", TRACK_A)];
|
|
72
|
+
const gsapAnimations = next.animations ?? new Map<string, GsapAnimation[]>();
|
|
73
|
+
const displayTrackOrder = [...new Set(elements.map((el) => el.track))].sort((a, b) => a - b);
|
|
74
|
+
const tracks: [number, TimelineElement[]][] = displayTrackOrder.map((track) => [
|
|
75
|
+
track,
|
|
76
|
+
elements.filter((el) => el.track === track),
|
|
77
|
+
]);
|
|
78
|
+
const laneCounts = new Map(
|
|
79
|
+
elements.map((el) => [el.id, (gsapAnimations.get(el.id) ?? []).length]),
|
|
80
|
+
);
|
|
81
|
+
act(() => {
|
|
82
|
+
usePlayerStore.setState({ expandedClipIds: new Set(next.expandedClipIds ?? []) });
|
|
83
|
+
root.render(
|
|
84
|
+
<TimelineLanes
|
|
85
|
+
pps={100}
|
|
86
|
+
contentOrigin={232}
|
|
87
|
+
contentGutter={32}
|
|
88
|
+
trackContentWidth={800}
|
|
89
|
+
theme={defaultTimelineTheme}
|
|
90
|
+
displayTrackOrder={displayTrackOrder}
|
|
91
|
+
rowHeights={displayTrackOrder.map(() => TRACK_H)}
|
|
92
|
+
trackOrder={displayTrackOrder}
|
|
93
|
+
tracks={tracks}
|
|
94
|
+
trackStyles={new Map()}
|
|
95
|
+
laneCounts={laneCounts}
|
|
96
|
+
selectedElementId={null}
|
|
97
|
+
selectedElementIds={next.selectedElementIds ?? new Set()}
|
|
98
|
+
hoveredClip={null}
|
|
99
|
+
draggedClip={next.draggedClip ?? null}
|
|
100
|
+
draggedElement={null}
|
|
101
|
+
multiDragPreview={next.multiDragPreview ?? null}
|
|
102
|
+
blockedClipRef={createRef<BlockedClipState | null>()}
|
|
103
|
+
suppressClickRef={{ current: false }}
|
|
104
|
+
scrollRef={createRef<HTMLDivElement>()}
|
|
105
|
+
setHoveredClip={vi.fn()}
|
|
106
|
+
setShowPopover={vi.fn()}
|
|
107
|
+
setRangeSelection={vi.fn()}
|
|
108
|
+
setResizingClip={vi.fn()}
|
|
109
|
+
setDraggedClip={vi.fn()}
|
|
110
|
+
setSelectedElementId={vi.fn()}
|
|
111
|
+
syncClipDragAutoScroll={vi.fn()}
|
|
112
|
+
shiftClickClipRef={createRef()}
|
|
113
|
+
getPreviewElement={(el) => el}
|
|
114
|
+
getTrackStyle={getTrackStyle}
|
|
115
|
+
gsapAnimations={gsapAnimations}
|
|
116
|
+
selectedKeyframes={new Set()}
|
|
117
|
+
currentTime={0}
|
|
118
|
+
onContextMenuLane={next.onContextMenuLane}
|
|
119
|
+
onToggleTrackHidden={next.onToggleTrackHidden}
|
|
120
|
+
onTogglePropertyGroupKeyframe={vi.fn()}
|
|
121
|
+
onResizeElement={vi.fn()}
|
|
122
|
+
onMoveElement={vi.fn()}
|
|
123
|
+
onRazorSplit={vi.fn()}
|
|
124
|
+
onRazorSplitAll={vi.fn()}
|
|
125
|
+
/>,
|
|
126
|
+
);
|
|
127
|
+
});
|
|
128
|
+
};
|
|
129
|
+
render(options);
|
|
130
|
+
return { host, root, rerender: render };
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function visibilityLabels(host: HTMLElement): (string | null)[] {
|
|
134
|
+
return Array.from(host.querySelectorAll("button[aria-label^='Hide track ']")).map((button) =>
|
|
135
|
+
button.getAttribute("aria-label"),
|
|
136
|
+
);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
describe("TimelineLanes track numbering", () => {
|
|
140
|
+
// Screen readers literally announced "Hide track 0.16666666666666666".
|
|
141
|
+
it("numbers tracks contiguously from 1 regardless of the fractional sort keys", () => {
|
|
142
|
+
const view = renderLanes({
|
|
143
|
+
elements: [element("clip-a", TRACK_A), element("clip-b", TRACK_B)],
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
expect(visibilityLabels(view.host)).toEqual(["Hide track 1", "Hide track 2"]);
|
|
147
|
+
expect(view.host.innerHTML).not.toContain("0.16666666666666666");
|
|
148
|
+
act(() => view.root.unmount());
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
it("hands the visibility toggle the real track key, not the display index", () => {
|
|
152
|
+
const onToggleTrackHidden = vi.fn();
|
|
153
|
+
const view = renderLanes({
|
|
154
|
+
elements: [element("clip-a", TRACK_A), element("clip-b", TRACK_B)],
|
|
155
|
+
onToggleTrackHidden,
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
const second = view.host.querySelector<HTMLButtonElement>('button[aria-label="Hide track 2"]');
|
|
159
|
+
act(() => second?.click());
|
|
160
|
+
|
|
161
|
+
expect(onToggleTrackHidden).toHaveBeenCalledWith(TRACK_B, true);
|
|
162
|
+
act(() => view.root.unmount());
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
// The gap menu inserts at the track it is given, so a display index here would
|
|
166
|
+
// drop the new clip on the wrong lane.
|
|
167
|
+
it("hands the lane context menu the real track key, not the display index", () => {
|
|
168
|
+
const onContextMenuLane = vi.fn();
|
|
169
|
+
const view = renderLanes({
|
|
170
|
+
elements: [element("clip-a", TRACK_A), element("clip-b", TRACK_B)],
|
|
171
|
+
onContextMenuLane,
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
// Row children: [sticky header column, time-mapped track content].
|
|
175
|
+
const rows = Array.from(view.host.children);
|
|
176
|
+
const secondTrackContent = rows[1]?.children.item(1);
|
|
177
|
+
act(() => {
|
|
178
|
+
secondTrackContent?.dispatchEvent(
|
|
179
|
+
new MouseEvent("contextmenu", { bubbles: true, cancelable: true, clientX: 100 }),
|
|
180
|
+
);
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
expect(onContextMenuLane).toHaveBeenCalledOnce();
|
|
184
|
+
expect(onContextMenuLane.mock.calls[0]?.[1]).toBe(TRACK_B);
|
|
185
|
+
act(() => view.root.unmount());
|
|
186
|
+
});
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
describe("TimelineLanes disclosure target", () => {
|
|
190
|
+
const ANIMATIONS = new Map([["clip-a", [positionTween("clip-a")]]]);
|
|
191
|
+
|
|
192
|
+
function ariaControlsTarget(host: HTMLElement): HTMLElement | null {
|
|
193
|
+
const caret = host.querySelector("button[aria-controls]");
|
|
194
|
+
const id = caret?.getAttribute("aria-controls");
|
|
195
|
+
return id ? host.querySelector<HTMLElement>(`#${id}`) : null;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
// aria-controls used to name a div in the sticky label column: it computed to
|
|
199
|
+
// 0x0 and held no diamonds at all.
|
|
200
|
+
it("resolves the caret's aria-controls to an element holding the property lanes", () => {
|
|
201
|
+
const view = renderLanes({ animations: ANIMATIONS, expandedClipIds: ["clip-a"] });
|
|
202
|
+
const target = ariaControlsTarget(view.host);
|
|
203
|
+
|
|
204
|
+
expect(target).not.toBeNull();
|
|
205
|
+
expect(target?.querySelectorAll("[data-timeline-property-lane]").length).toBeGreaterThan(0);
|
|
206
|
+
act(() => view.root.unmount());
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
it("still resolves the caret's aria-controls while the layer is collapsed", () => {
|
|
210
|
+
const view = renderLanes({ animations: ANIMATIONS, expandedClipIds: [] });
|
|
211
|
+
const target = ariaControlsTarget(view.host);
|
|
212
|
+
|
|
213
|
+
expect(target).not.toBeNull();
|
|
214
|
+
expect(target?.querySelectorAll("[data-timeline-property-lane]")).toHaveLength(0);
|
|
215
|
+
act(() => view.root.unmount());
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
// Two timelines on one page (a mini-timeline in a modal beside the main one)
|
|
219
|
+
// both minted `timeline-lanes-track-0`, so every caret's aria-controls
|
|
220
|
+
// resolved to whichever instance mounted first.
|
|
221
|
+
it("mints lane ids that do not collide with a second TimelineLanes on the page", () => {
|
|
222
|
+
const first = renderLanes({ animations: ANIMATIONS, expandedClipIds: ["clip-a"] });
|
|
223
|
+
const second = renderLanes({ animations: ANIMATIONS, expandedClipIds: ["clip-a"] });
|
|
224
|
+
|
|
225
|
+
const idsFor = (host: HTMLElement) =>
|
|
226
|
+
Array.from(host.querySelectorAll("button[aria-controls]")).map((caret) =>
|
|
227
|
+
caret.getAttribute("aria-controls"),
|
|
228
|
+
);
|
|
229
|
+
const firstIds = idsFor(first.host);
|
|
230
|
+
const secondIds = idsFor(second.host);
|
|
231
|
+
|
|
232
|
+
expect(firstIds.length).toBeGreaterThan(0);
|
|
233
|
+
expect(firstIds.some((id) => secondIds.includes(id))).toBe(false);
|
|
234
|
+
// Still a legal CSS id selector: the aria-controls lookups above use `#id`.
|
|
235
|
+
for (const id of [...firstIds, ...secondIds]) {
|
|
236
|
+
expect(id).toMatch(/^[A-Za-z][\w-]*$/);
|
|
237
|
+
}
|
|
238
|
+
act(() => first.root.unmount());
|
|
239
|
+
act(() => second.root.unmount());
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
// The passenger branch wraps [clip, lanes] in a transformed div that re-renders
|
|
243
|
+
// on every pointer move. An unstable key there remounts the lanes and drops the
|
|
244
|
+
// in-flight drag.
|
|
245
|
+
it("does not remount the lanes while a multi-clip drag slides the formation", () => {
|
|
246
|
+
const elements = [element("clip-a", TRACK_A), element("clip-b", TRACK_A)];
|
|
247
|
+
const selectedElementIds = new Set(["clip-a", "clip-b"]);
|
|
248
|
+
const preview = (draggedPreviewStart: number): MultiDragPreviewInput => ({
|
|
249
|
+
dragStarted: true,
|
|
250
|
+
draggedKey: "clip-b",
|
|
251
|
+
draggedOriginStart: 0,
|
|
252
|
+
draggedPreviewStart,
|
|
253
|
+
selectedKeys: selectedElementIds,
|
|
254
|
+
});
|
|
255
|
+
const view = renderLanes({
|
|
256
|
+
elements,
|
|
257
|
+
animations: ANIMATIONS,
|
|
258
|
+
expandedClipIds: ["clip-a"],
|
|
259
|
+
selectedElementIds,
|
|
260
|
+
multiDragPreview: preview(0.25),
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
const before = ariaControlsTarget(view.host);
|
|
264
|
+
const beforeLane = before?.querySelector("[data-timeline-property-lane]");
|
|
265
|
+
expect(before).not.toBeNull();
|
|
266
|
+
expect(beforeLane).not.toBeNull();
|
|
267
|
+
|
|
268
|
+
view.rerender({
|
|
269
|
+
elements,
|
|
270
|
+
animations: ANIMATIONS,
|
|
271
|
+
expandedClipIds: ["clip-a"],
|
|
272
|
+
selectedElementIds,
|
|
273
|
+
multiDragPreview: preview(0.75),
|
|
274
|
+
});
|
|
275
|
+
|
|
276
|
+
// Node identity, not just presence: a remount replaces these nodes.
|
|
277
|
+
expect(ariaControlsTarget(view.host)).toBe(before);
|
|
278
|
+
expect(before?.querySelector("[data-timeline-property-lane]")).toBe(beforeLane);
|
|
279
|
+
act(() => view.root.unmount());
|
|
280
|
+
});
|
|
281
|
+
});
|