@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
|
@@ -27,6 +27,12 @@ export type { TimelineDiamondKeyframe } from "./timelineDiamondTypes";
|
|
|
27
27
|
// stops there: at the zoom floor the gap alone left a ~7px target, which is
|
|
28
28
|
// neither hittable nor selectable with any accuracy. Boxes may overlap slightly
|
|
29
29
|
// below this width; each diamond still owns the half-gap around its own centre.
|
|
30
|
+
//
|
|
31
|
+
// Deliberately below the 24px WCAG 2.2 (2.5.8) minimum, under that criterion's
|
|
32
|
+
// own spacing exception: at normal keyframe density the neighbour gap is under
|
|
33
|
+
// 24px, so a 24px floor would make adjacent diamonds' hit boxes OVERLAP — one
|
|
34
|
+
// diamond swallowing its neighbour's clicks is a worse 2.5.8 failure than a
|
|
35
|
+
// small target. Do not "fix" this to 24.
|
|
30
36
|
const KF_MIN_HIT_W = 12;
|
|
31
37
|
|
|
32
38
|
/** A clip-% is a float division, so a raw tooltip reads `25.032499999999995%`. */
|
|
@@ -141,6 +147,10 @@ export const TimelineDiamondLane = memo(function TimelineDiamondLane({
|
|
|
141
147
|
? Math.round(clipHeightPx * 0.45)
|
|
142
148
|
: Math.round(LANE_H * DIAMOND_RATIO);
|
|
143
149
|
const centerY = beatsActive ? BEAT_BAND_H + (clipHeightPx - BEAT_BAND_H) / 2 : clipHeightPx / 2;
|
|
150
|
+
// Hit-box height only — the glyph keeps `marker.visualSize`. 24 is the WCAG 2.2
|
|
151
|
+
// (2.5.8) minimum and still fits the 28px lane. Beat-strip lanes keep the
|
|
152
|
+
// shrunken box: 24px there would reach up into the beat strip.
|
|
153
|
+
const hitHeight = beatsActive ? diamondSize : 24;
|
|
144
154
|
const sorted = keyframesData.keyframes
|
|
145
155
|
.filter((kf) => kf.percentage >= KF_MIN_PCT && kf.percentage <= KF_MAX_PCT)
|
|
146
156
|
.sort((a, b) => a.percentage - b.percentage);
|
|
@@ -412,7 +422,7 @@ export const TimelineDiamondLane = memo(function TimelineDiamondLane({
|
|
|
412
422
|
top: centerY,
|
|
413
423
|
transform: "translateY(-50%)",
|
|
414
424
|
width: marker.hitWidth,
|
|
415
|
-
height:
|
|
425
|
+
height: hitHeight,
|
|
416
426
|
zIndex: isHighlighted ? 2 : 1,
|
|
417
427
|
pointerEvents: "auto",
|
|
418
428
|
background: "none",
|
|
@@ -62,6 +62,12 @@ export function TimelineDiamondConnectors({
|
|
|
62
62
|
// no source animation id (runtime-scanned) so there is no tween to target.
|
|
63
63
|
const target = keyframeTarget(kf);
|
|
64
64
|
const ease = kf.ease ?? globalEase;
|
|
65
|
+
// connectorWidth is the clear span between the two diamonds' edges, so a
|
|
66
|
+
// 24x24 target centred in it overhangs a diamond as soon as the span is
|
|
67
|
+
// narrower than 24. The segment wrapper sits at z-index 3, above the
|
|
68
|
+
// diamonds, so that overhang would win the hit test and steal their
|
|
69
|
+
// clicks at fit zoom. Grow the target only where the room exists.
|
|
70
|
+
const roomForFullTarget = connectorWidth >= 24;
|
|
65
71
|
return (
|
|
66
72
|
<Fragment key={`line-${i}-${previous.keyframe.percentage}-${kf.percentage}`}>
|
|
67
73
|
<div
|
|
@@ -103,7 +109,14 @@ export function TimelineDiamondConnectors({
|
|
|
103
109
|
data-keyframe-ease-button=""
|
|
104
110
|
aria-label={`Edit ${ease} easing`}
|
|
105
111
|
title={`Edit ${ease} easing`}
|
|
106
|
-
|
|
112
|
+
// A visible 24x24 badge would collide with the diamonds either
|
|
113
|
+
// side, so the WCAG 2.2 (2.5.8) target is met with a centered
|
|
114
|
+
// transparent ::before overlay; the box stays 16x16. Where the
|
|
115
|
+
// segment is too narrow for that overlay the button keeps its
|
|
116
|
+
// 16x16 hit area, which is WCAG's target-spacing exception:
|
|
117
|
+
// the neighbouring diamonds are themselves the reason it
|
|
118
|
+
// cannot grow, and stealing their clicks is the worse failure.
|
|
119
|
+
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-['']" : ""}`}
|
|
107
120
|
style={{
|
|
108
121
|
left: "50%",
|
|
109
122
|
top: "50%",
|
|
@@ -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
|
+
});
|
|
@@ -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)}
|