@hyperframes/studio 0.7.75 → 0.7.77
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-BrfzYqX3.js → hyperframes-player-CEggaaxR.js} +1 -1
- package/dist/assets/{index-BvGKYVrH.js → index-B1INQNmj.js} +1 -1
- package/dist/assets/{index-CjGZkNxS.js → index-B1Tjjdse.js} +1 -1
- package/dist/assets/index-Bp4jAYZG.js +428 -0
- package/dist/assets/index-CBAfprvx.css +1 -0
- package/dist/index.d.ts +64 -17
- package/dist/index.html +2 -2
- package/dist/index.js +18040 -16293
- package/dist/index.js.map +1 -1
- package/package.json +7 -7
- package/src/App.tsx +16 -17
- package/src/components/EditorShell.tsx +3 -0
- package/src/components/TimelineToolbar.test.tsx +48 -3
- package/src/components/TimelineToolbar.tsx +89 -36
- package/src/components/editor/AnimationCard.test.tsx +18 -75
- package/src/components/editor/AnimationCard.tsx +26 -2
- package/src/components/editor/GsapAddAnimationControl.tsx +68 -0
- package/src/components/editor/GsapAnimationSection.tsx +18 -157
- package/src/components/editor/KeyframeEaseList.tsx +5 -1
- package/src/components/editor/KeyframeNavigation.tsx +46 -14
- package/src/components/editor/KeyframeNavigationDiamond.test.tsx +59 -0
- package/src/components/editor/MotionPathOverlay.tsx +32 -10
- package/src/components/editor/PropertyPanel.tsx +15 -5
- package/src/components/editor/PropertyPanelFlat.tsx +48 -23
- package/src/components/editor/gsapAnimationCallbacks.test.ts +122 -0
- package/src/components/editor/gsapAnimationCallbacks.ts +108 -4
- package/src/components/editor/keyframeRetime.test.ts +25 -22
- package/src/components/editor/keyframeRetime.ts +1 -2
- package/src/components/editor/propertyPanelFlatLayoutSection.tsx +2 -2
- package/src/components/editor/propertyPanelFlatMotionSection.tsx +25 -139
- package/src/components/editor/propertyPanelFlatTextSection.test.tsx +32 -16
- package/src/components/editor/propertyPanelFlatTextSection.tsx +21 -4
- package/src/components/nle/PreviewOverlays.tsx +3 -1
- package/src/components/nle/useTimelineEditCallbacks.test.ts +30 -0
- package/src/components/nle/useTimelineEditCallbacks.test.tsx +693 -0
- package/src/components/nle/useTimelineEditCallbacks.ts +197 -57
- package/src/contexts/TimelineEditContext.tsx +1 -1
- package/src/hooks/deleteSelectedKeyframes.ts +24 -8
- package/src/hooks/gsapDragCommit.ts +2 -2
- package/src/hooks/gsapDragPositionCommit.ts +84 -5
- package/src/hooks/gsapKeyframeCacheHelpers.test.ts +176 -1
- package/src/hooks/gsapKeyframeCacheHelpers.ts +93 -52
- package/src/hooks/gsapRuntimeBridge.test.ts +100 -0
- package/src/hooks/gsapScriptCommitHelpers.ts +3 -2
- package/src/hooks/gsapScriptCommitTypes.ts +2 -0
- package/src/hooks/gsapShared.test.ts +109 -1
- package/src/hooks/gsapShared.ts +142 -17
- package/src/hooks/gsapTweenSynth.test.ts +30 -1
- package/src/hooks/gsapTweenSynth.ts +61 -14
- package/src/hooks/keyframeCacheAstLoad.ts +181 -0
- package/src/hooks/timelineEditingHelpers.test.ts +80 -0
- package/src/hooks/timelineMoveAdapter.ts +9 -2
- package/src/hooks/useDomEditWiring.ts +2 -2
- package/src/hooks/useDomSelection.ts +4 -0
- package/src/hooks/useEnableKeyframes.test.ts +122 -0
- package/src/hooks/useEnableKeyframes.ts +116 -56
- package/src/hooks/useGestureCommit.ts +2 -2
- package/src/hooks/useGsapAnimationFetchFallback.ts +1 -1
- package/src/hooks/useGsapKeyframeOps.test.tsx +92 -13
- package/src/hooks/useGsapKeyframeOps.ts +30 -13
- package/src/hooks/useGsapScriptCommits.test.tsx +21 -0
- package/src/hooks/useGsapScriptCommits.ts +2 -0
- package/src/hooks/useGsapSelectionHandlers.test.tsx +69 -3
- package/src/hooks/useGsapSelectionHandlers.ts +90 -65
- package/src/hooks/useGsapTweenCache.test.ts +16 -0
- package/src/hooks/useGsapTweenCache.ts +64 -178
- package/src/hooks/usePopulateKeyframeCacheForFile.test.tsx +68 -0
- package/src/hooks/useStudioContextValue.test.ts +93 -0
- package/src/hooks/useStudioContextValue.ts +17 -3
- package/src/hooks/useStudioTestHooks.ts +63 -0
- package/src/hooks/useTimelineEditing.test.tsx +187 -22
- package/src/hooks/useTimelineEditing.ts +43 -32
- package/src/hooks/useTimelineEditingTypes.ts +2 -0
- package/src/hooks/useTimelineGroupEditing.ts +106 -82
- package/src/player/components/KeyframeDiamondContextMenu.test.tsx +69 -0
- package/src/player/components/KeyframeDiamondContextMenu.tsx +19 -8
- package/src/player/components/LayerDisclosureRow.tsx +76 -0
- package/src/player/components/Timeline.test.ts +420 -90
- package/src/player/components/Timeline.tsx +80 -78
- package/src/player/components/TimelineCanvas.tsx +33 -21
- package/src/player/components/TimelineClip.tsx +3 -1
- package/src/player/components/TimelineClipDiamonds.test.tsx +540 -16
- package/src/player/components/TimelineClipDiamonds.tsx +320 -117
- package/src/player/components/TimelineDiamondConnectors.tsx +145 -0
- package/src/player/components/TimelineLanes.tsx +153 -79
- package/src/player/components/TimelineOverlays.tsx +4 -18
- package/src/player/components/TimelinePropertyLanes.test.tsx +450 -0
- package/src/player/components/TimelinePropertyLanes.tsx +191 -0
- package/src/player/components/TimelineRazorInteraction.tsx +60 -0
- package/src/player/components/TimelineRuler.tsx +11 -21
- package/src/player/components/TimelineTrackHeader.test.tsx +346 -0
- package/src/player/components/TimelineTrackHeader.tsx +363 -0
- package/src/player/components/TrackClipCount.tsx +17 -0
- package/src/player/components/timelineCallbacks.ts +21 -6
- package/src/player/components/timelineClipDragPreview.test.ts +50 -3
- package/src/player/components/timelineClipDragPreview.ts +33 -15
- package/src/player/components/timelineCollision.ts +4 -13
- package/src/player/components/timelineDiamondTypes.ts +120 -0
- package/src/player/components/timelineDragDrop.ts +8 -3
- package/src/player/components/timelineEditing.test.ts +19 -27
- package/src/player/components/timelineEditing.ts +7 -8
- package/src/player/components/timelineKeyframeIdentity.test.ts +49 -0
- package/src/player/components/timelineKeyframeIdentity.ts +66 -0
- package/src/player/components/timelineLayout.test.ts +162 -9
- package/src/player/components/timelineLayout.ts +198 -38
- package/src/player/components/timelineMarquee.test.ts +76 -21
- package/src/player/components/timelineMarquee.ts +17 -14
- package/src/player/components/trackHeaderLaneState.ts +121 -0
- package/src/player/components/trackHeaderLaneValues.ts +139 -0
- package/src/player/components/useAutoExpandKeyframedClips.test.tsx +75 -0
- package/src/player/components/useAutoExpandKeyframedClips.ts +50 -0
- package/src/player/components/useTimelineClipDrag.ts +4 -1
- package/src/player/components/useTimelineGeometry.ts +4 -1
- package/src/player/components/useTimelineKeyframeHandlers.test.tsx +108 -0
- package/src/player/components/useTimelineKeyframeHandlers.ts +50 -15
- package/src/player/components/useTimelinePlayhead.ts +21 -12
- package/src/player/components/useTimelineRangeSelection.ts +35 -14
- package/src/player/components/useTimelineTrackLayout.test.ts +53 -0
- package/src/player/components/useTimelineTrackLayout.ts +162 -0
- package/src/player/hooks/useExpandedTimelineElements.test.ts +43 -0
- package/src/player/hooks/useExpandedTimelineElements.ts +21 -12
- package/src/player/lib/timelineElementHelpers.ts +26 -11
- package/src/player/store/keyframeSlice.ts +122 -0
- package/src/player/store/playerStore.test.ts +25 -0
- package/src/player/store/playerStore.ts +7 -43
- package/src/telemetry/events.test.ts +9 -3
- package/src/telemetry/events.ts +16 -7
- package/src/utils/globalTimeCompiler.test.ts +10 -0
- package/src/utils/globalTimeCompiler.ts +3 -3
- package/dist/assets/index-B37rWXo4.css +0 -1
- package/dist/assets/index-DzDajONI.js +0 -428
- package/src/player/components/TimelineDragGhost.tsx +0 -59
- package/src/utils/keyframeSelection.test.ts +0 -45
- package/src/utils/keyframeSelection.ts +0 -29
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { memo } from "react";
|
|
2
2
|
import type { TimelineTheme } from "./timelineTheme";
|
|
3
|
-
import {
|
|
3
|
+
import { RULER_H, formatTimelineTickLabel } from "./timelineLayout";
|
|
4
4
|
import { usePlayerStore } from "../store/playerStore";
|
|
5
5
|
import { secondsToFrame } from "../lib/time";
|
|
6
6
|
import type { MusicBeatAnalysis } from "@hyperframes/core/beats";
|
|
@@ -15,6 +15,7 @@ interface TimelineRulerProps {
|
|
|
15
15
|
majorTickInterval: number;
|
|
16
16
|
theme: TimelineTheme;
|
|
17
17
|
beatAnalysis?: MusicBeatAnalysis | null;
|
|
18
|
+
contentOrigin: number;
|
|
18
19
|
}
|
|
19
20
|
|
|
20
21
|
export const TimelineRuler = memo(function TimelineRuler({
|
|
@@ -27,6 +28,7 @@ export const TimelineRuler = memo(function TimelineRuler({
|
|
|
27
28
|
majorTickInterval,
|
|
28
29
|
theme,
|
|
29
30
|
beatAnalysis,
|
|
31
|
+
contentOrigin,
|
|
30
32
|
}: TimelineRulerProps) {
|
|
31
33
|
const timeDisplayMode = usePlayerStore((s) => s.timeDisplayMode);
|
|
32
34
|
const beatTimes = beatAnalysis?.beatTimes ?? [];
|
|
@@ -45,7 +47,7 @@ export const TimelineRuler = memo(function TimelineRuler({
|
|
|
45
47
|
the ruler's own small ticks mark intervals (no full-height lines). */}
|
|
46
48
|
<svg
|
|
47
49
|
className="absolute pointer-events-none"
|
|
48
|
-
style={{ left:
|
|
50
|
+
style={{ left: contentOrigin, width: trackContentWidth, zIndex: 0 }}
|
|
49
51
|
height={totalH}
|
|
50
52
|
>
|
|
51
53
|
{showBeats &&
|
|
@@ -69,35 +71,23 @@ export const TimelineRuler = memo(function TimelineRuler({
|
|
|
69
71
|
</svg>
|
|
70
72
|
|
|
71
73
|
{/* Ruler — sticky so the timestamps stay visible while the tracks scroll
|
|
72
|
-
vertically. Opaque background (plus the
|
|
74
|
+
vertically. Opaque background (plus the label-column corner block) so clips
|
|
73
75
|
scrolling underneath don't bleed through; z-index sits above the track
|
|
74
76
|
rows and drag overlays but below the playhead (z 100). */}
|
|
75
77
|
<div
|
|
76
78
|
className="sticky top-0 flex"
|
|
77
|
-
style={{
|
|
78
|
-
height: RULER_H,
|
|
79
|
-
width: GUTTER + TRACKS_LEFT_PAD + trackContentWidth,
|
|
80
|
-
zIndex: 70,
|
|
81
|
-
}}
|
|
79
|
+
style={{ height: RULER_H, width: contentOrigin + trackContentWidth, zIndex: 70 }}
|
|
82
80
|
>
|
|
83
81
|
<div
|
|
84
82
|
className="sticky left-0 z-[12] flex-shrink-0"
|
|
85
83
|
style={{
|
|
86
|
-
width:
|
|
87
|
-
// Ruler corner uses the panel surface — same as the ruler strip
|
|
88
|
-
// itself, and NO right border: the ruler band stays completely
|
|
89
|
-
// clean until 00:00 (the header-boundary line belongs to the track
|
|
90
|
-
// rows below, not the ruler).
|
|
84
|
+
width: contentOrigin,
|
|
85
|
+
// Ruler corner uses the panel surface — same as the ruler strip itself.
|
|
91
86
|
background: theme.shellBackground,
|
|
92
87
|
}}
|
|
93
88
|
/>
|
|
94
|
-
{/*
|
|
95
|
-
|
|
96
|
-
<div
|
|
97
|
-
aria-hidden="true"
|
|
98
|
-
className="flex-shrink-0"
|
|
99
|
-
style={{ width: TRACKS_LEFT_PAD, background: theme.shellBackground }}
|
|
100
|
-
/>
|
|
89
|
+
{/* Breathing pad before 00:00 is folded into contentOrigin (see
|
|
90
|
+
Timeline.tsx: GUTTER + TRACKS_LEFT_PAD), so no separate pad div. */}
|
|
101
91
|
<div
|
|
102
92
|
className="relative overflow-hidden"
|
|
103
93
|
style={{
|
|
@@ -110,7 +100,7 @@ export const TimelineRuler = memo(function TimelineRuler({
|
|
|
110
100
|
>
|
|
111
101
|
{/* Each 1px tick line is shifted -0.5px so its CENTER sits exactly on
|
|
112
102
|
t * pps — matching the playhead line, which is also centered on
|
|
113
|
-
|
|
103
|
+
contentOrigin + t * pps (see getTimelinePlayheadLeft). Without the shift
|
|
114
104
|
a tick spans [x, x+1) and its center is half a pixel right. */}
|
|
115
105
|
{minor.map((t) => (
|
|
116
106
|
<div key={`m-${t}`} className="absolute bottom-0" style={{ left: t * pps - 0.5 }}>
|
|
@@ -0,0 +1,346 @@
|
|
|
1
|
+
// @vitest-environment happy-dom
|
|
2
|
+
|
|
3
|
+
import React, { act } from "react";
|
|
4
|
+
import { createRoot, type Root } from "react-dom/client";
|
|
5
|
+
import type { GsapAnimation, PropertyGroupName } from "@hyperframes/core/gsap-parser";
|
|
6
|
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
7
|
+
import { TimelinePropertyLanes } from "./TimelinePropertyLanes";
|
|
8
|
+
import { TimelineTrackHeader } from "./TimelineTrackHeader";
|
|
9
|
+
import { defaultTimelineTheme } from "./timelineTheme";
|
|
10
|
+
import type { TimelineElement } from "../store/playerStore";
|
|
11
|
+
import type { TimelineEditCallbacks } from "./timelineCallbacks";
|
|
12
|
+
import { LABEL_COL_W } from "./timelineLayout";
|
|
13
|
+
|
|
14
|
+
(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
|
15
|
+
|
|
16
|
+
afterEach(() => {
|
|
17
|
+
document.body.innerHTML = "";
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
const ELEMENT: TimelineElement = {
|
|
21
|
+
id: "clip-1",
|
|
22
|
+
label: "Hero card",
|
|
23
|
+
tag: "div",
|
|
24
|
+
start: 0,
|
|
25
|
+
duration: 2,
|
|
26
|
+
track: 0,
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
function animation(
|
|
30
|
+
id: string,
|
|
31
|
+
propertyGroup: PropertyGroupName,
|
|
32
|
+
keyframes: Array<{
|
|
33
|
+
percentage: number;
|
|
34
|
+
properties: Record<string, number | string>;
|
|
35
|
+
ease?: string;
|
|
36
|
+
}>,
|
|
37
|
+
): GsapAnimation {
|
|
38
|
+
return {
|
|
39
|
+
id,
|
|
40
|
+
targetSelector: "#clip-1",
|
|
41
|
+
method: "to",
|
|
42
|
+
position: 0,
|
|
43
|
+
duration: 2,
|
|
44
|
+
properties: {},
|
|
45
|
+
propertyGroup,
|
|
46
|
+
keyframes: { format: "percentage", keyframes },
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const POSITION = animation("position-tween", "position", [
|
|
51
|
+
{ percentage: 0, properties: { x: 0, y: 0 } },
|
|
52
|
+
{ percentage: 50, properties: { x: 100, y: 50 } },
|
|
53
|
+
{ percentage: 100, properties: { x: 200, y: 100 } },
|
|
54
|
+
]);
|
|
55
|
+
|
|
56
|
+
const OPACITY = animation("opacity-tween", "visual", [
|
|
57
|
+
{ percentage: 0, properties: { opacity: 0 } },
|
|
58
|
+
{ percentage: 50, properties: { opacity: 0.5 } },
|
|
59
|
+
{ percentage: 100, properties: { opacity: 1 } },
|
|
60
|
+
]);
|
|
61
|
+
|
|
62
|
+
interface RenderHeaderOptions {
|
|
63
|
+
animations?: GsapAnimation[];
|
|
64
|
+
clipCount?: number;
|
|
65
|
+
currentTime?: number;
|
|
66
|
+
expanded?: boolean;
|
|
67
|
+
onSeek?: (time: number) => void;
|
|
68
|
+
onTogglePropertyGroupKeyframe?: TimelineEditCallbacks["onTogglePropertyGroupKeyframe"];
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function renderHeader(options: RenderHeaderOptions = {}): {
|
|
72
|
+
host: HTMLDivElement;
|
|
73
|
+
root: Root;
|
|
74
|
+
rerender: (next: RenderHeaderOptions) => void;
|
|
75
|
+
} {
|
|
76
|
+
const host = document.createElement("div");
|
|
77
|
+
document.body.append(host);
|
|
78
|
+
const root = createRoot(host);
|
|
79
|
+
const render = (next: RenderHeaderOptions) => {
|
|
80
|
+
act(() => {
|
|
81
|
+
root.render(
|
|
82
|
+
<TimelineTrackHeader
|
|
83
|
+
trackNumber={0}
|
|
84
|
+
trackLabel="Hero card"
|
|
85
|
+
contentOrigin={LABEL_COL_W}
|
|
86
|
+
keyframeClip={ELEMENT}
|
|
87
|
+
clipCount={next.clipCount ?? 1}
|
|
88
|
+
isExpanded={next.expanded !== false}
|
|
89
|
+
animations={next.animations ?? [POSITION, OPACITY]}
|
|
90
|
+
currentTime={next.currentTime ?? 0}
|
|
91
|
+
isTrackHidden={false}
|
|
92
|
+
isAudioTrack={false}
|
|
93
|
+
theme={defaultTimelineTheme}
|
|
94
|
+
onToggleClipExpanded={vi.fn()}
|
|
95
|
+
onToggleTrackHidden={vi.fn()}
|
|
96
|
+
onTogglePropertyGroupKeyframe={next.onTogglePropertyGroupKeyframe}
|
|
97
|
+
onSeek={next.onSeek}
|
|
98
|
+
/>,
|
|
99
|
+
);
|
|
100
|
+
});
|
|
101
|
+
};
|
|
102
|
+
render(options);
|
|
103
|
+
return { host, root, rerender: render };
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function click(host: HTMLElement, label: string) {
|
|
107
|
+
const button = host.querySelector<HTMLButtonElement>(`button[aria-label="${label}"]`);
|
|
108
|
+
expect(button).not.toBeNull();
|
|
109
|
+
act(() => button?.click());
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
describe("TimelineTrackHeader", () => {
|
|
113
|
+
// The header shows one clip's lanes, so how many clips the track holds is
|
|
114
|
+
// otherwise invisible from the label column. A single-clip track stays silent.
|
|
115
|
+
it("shows the track's clip count only once the track holds more than one clip", () => {
|
|
116
|
+
const view = renderHeader({ clipCount: 1 });
|
|
117
|
+
expect(view.host.querySelector('[aria-label="1 clips"]')).toBeNull();
|
|
118
|
+
|
|
119
|
+
view.rerender({ clipCount: 3 });
|
|
120
|
+
expect(view.host.querySelector('[aria-label="3 clips"]')?.textContent).toBe("3");
|
|
121
|
+
act(() => view.root.unmount());
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
// The eye acts on the layer, so it has to be reachable without a pointer and
|
|
125
|
+
// in every disclosure state — a hover-gated eye is unusable by keyboard.
|
|
126
|
+
it("keeps the visibility eye mounted whether the layer is expanded or collapsed", () => {
|
|
127
|
+
const view = renderHeader({ expanded: true });
|
|
128
|
+
expect(view.host.querySelector('button[aria-label="Hide track 0"]')).not.toBeNull();
|
|
129
|
+
|
|
130
|
+
view.rerender({ expanded: false });
|
|
131
|
+
expect(view.host.querySelector('button[aria-label="Hide track 0"]')).not.toBeNull();
|
|
132
|
+
act(() => view.root.unmount());
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
it("adds and removes a keyframe on the explicitly targeted property-group tween", () => {
|
|
136
|
+
const onTogglePropertyGroupKeyframe = vi.fn();
|
|
137
|
+
const view = renderHeader({ currentTime: 0.5, onTogglePropertyGroupKeyframe });
|
|
138
|
+
|
|
139
|
+
click(view.host, "Add Opacity keyframe");
|
|
140
|
+
expect(onTogglePropertyGroupKeyframe).toHaveBeenLastCalledWith(
|
|
141
|
+
ELEMENT,
|
|
142
|
+
expect.objectContaining({
|
|
143
|
+
animationId: "opacity-tween",
|
|
144
|
+
propertyGroup: "visual",
|
|
145
|
+
tweenPercentage: 25,
|
|
146
|
+
properties: { opacity: 0.25 },
|
|
147
|
+
remove: false,
|
|
148
|
+
}),
|
|
149
|
+
);
|
|
150
|
+
|
|
151
|
+
view.rerender({ currentTime: 1, onTogglePropertyGroupKeyframe });
|
|
152
|
+
click(view.host, "Remove Opacity keyframe");
|
|
153
|
+
expect(onTogglePropertyGroupKeyframe).toHaveBeenLastCalledWith(
|
|
154
|
+
ELEMENT,
|
|
155
|
+
expect.objectContaining({
|
|
156
|
+
animationId: "opacity-tween",
|
|
157
|
+
propertyGroup: "visual",
|
|
158
|
+
tweenPercentage: 50,
|
|
159
|
+
properties: { opacity: 0.5 },
|
|
160
|
+
remove: true,
|
|
161
|
+
}),
|
|
162
|
+
);
|
|
163
|
+
expect(onTogglePropertyGroupKeyframe).not.toHaveBeenCalledWith(
|
|
164
|
+
ELEMENT,
|
|
165
|
+
expect.objectContaining({ animationId: "position-tween" }),
|
|
166
|
+
);
|
|
167
|
+
act(() => view.root.unmount());
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
it("seeks only to the selected group's adjacent keyframes", () => {
|
|
171
|
+
const onSeek = vi.fn();
|
|
172
|
+
const view = renderHeader({
|
|
173
|
+
currentTime: 1,
|
|
174
|
+
animations: [
|
|
175
|
+
POSITION,
|
|
176
|
+
animation("opacity-tween", "visual", [
|
|
177
|
+
{ percentage: 25, properties: { opacity: 0.25 } },
|
|
178
|
+
{ percentage: 50, properties: { opacity: 0.5 } },
|
|
179
|
+
{ percentage: 75, properties: { opacity: 0.75 } },
|
|
180
|
+
]),
|
|
181
|
+
],
|
|
182
|
+
onSeek,
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
click(view.host, "Next Position keyframe");
|
|
186
|
+
expect(onSeek).toHaveBeenLastCalledWith(2);
|
|
187
|
+
click(view.host, "Previous Position keyframe");
|
|
188
|
+
expect(onSeek).toHaveBeenLastCalledWith(0);
|
|
189
|
+
expect(onSeek).not.toHaveBeenCalledWith(1.5);
|
|
190
|
+
act(() => view.root.unmount());
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
// The lane header sits inside the track row, whose own click handler selects
|
|
194
|
+
// the track. Every control in the label column has to own its click, or
|
|
195
|
+
// seeking to a keyframe also reselects whatever is behind the header.
|
|
196
|
+
it("keeps lane-header control clicks off the ancestor track row", () => {
|
|
197
|
+
const onAncestorClick = vi.fn();
|
|
198
|
+
const view = renderHeader({
|
|
199
|
+
currentTime: 1,
|
|
200
|
+
onSeek: vi.fn(),
|
|
201
|
+
onTogglePropertyGroupKeyframe: vi.fn(),
|
|
202
|
+
});
|
|
203
|
+
// React 18 delegates from the root container, so an ancestor of it is where
|
|
204
|
+
// a leaked click actually shows up.
|
|
205
|
+
document.body.addEventListener("click", onAncestorClick);
|
|
206
|
+
|
|
207
|
+
// Every control in the lane's label column, found by row rather than by
|
|
208
|
+
// label, so a wording change to one button can't silently drop it here.
|
|
209
|
+
const controls = view.host.querySelectorAll<HTMLButtonElement>(
|
|
210
|
+
'[data-property-group="position"] button',
|
|
211
|
+
);
|
|
212
|
+
expect(controls.length).toBeGreaterThanOrEqual(3);
|
|
213
|
+
for (const button of controls) {
|
|
214
|
+
act(() => {
|
|
215
|
+
button.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
|
216
|
+
});
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
document.body.removeEventListener("click", onAncestorClick);
|
|
220
|
+
expect(onAncestorClick).not.toHaveBeenCalled();
|
|
221
|
+
act(() => view.root.unmount());
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
it("fills the toggle diamond exactly at that group's keyframe", () => {
|
|
225
|
+
const view = renderHeader({ currentTime: 0.5 });
|
|
226
|
+
const positionToggle = view.host.querySelector<HTMLButtonElement>(
|
|
227
|
+
'button[aria-label="Add Position keyframe"]',
|
|
228
|
+
);
|
|
229
|
+
expect(positionToggle?.textContent).toBe("◇");
|
|
230
|
+
|
|
231
|
+
view.rerender({ currentTime: 1 });
|
|
232
|
+
expect(
|
|
233
|
+
view.host.querySelector<HTMLButtonElement>('button[aria-label="Remove Position keyframe"]')
|
|
234
|
+
?.textContent,
|
|
235
|
+
).toBe("◆");
|
|
236
|
+
act(() => view.root.unmount());
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
it("updates formatted group values when the playhead moves", () => {
|
|
240
|
+
const view = renderHeader({ currentTime: 0.5 });
|
|
241
|
+
expect(view.host.querySelector('[data-property-group="position"]')?.textContent).toContain(
|
|
242
|
+
"50, 25",
|
|
243
|
+
);
|
|
244
|
+
expect(view.host.querySelector('[data-property-group="visual"]')?.textContent).toContain("25%");
|
|
245
|
+
|
|
246
|
+
view.rerender({ currentTime: 1.5 });
|
|
247
|
+
expect(view.host.querySelector('[data-property-group="position"]')?.textContent).toContain(
|
|
248
|
+
"150, 75",
|
|
249
|
+
);
|
|
250
|
+
expect(view.host.querySelector('[data-property-group="visual"]')?.textContent).toContain("75%");
|
|
251
|
+
act(() => view.root.unmount());
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
it("samples mid-segment values along the segment's ease, not linearly", () => {
|
|
255
|
+
// GSAP hangs a segment's ease on the keyframe it arrives at, so 0% -> 50%
|
|
256
|
+
// runs power2.in. Half way through that segment power2.in(0.5) = 0.125, so
|
|
257
|
+
// the readout is 12.5/6.25 and NOT the linear 50/25.
|
|
258
|
+
const eased = animation("eased-position", "position", [
|
|
259
|
+
{ percentage: 0, properties: { x: 0, y: 0 } },
|
|
260
|
+
{ percentage: 50, properties: { x: 100, y: 50 }, ease: "power2.in" },
|
|
261
|
+
{ percentage: 100, properties: { x: 200, y: 100 }, ease: "power2.in" },
|
|
262
|
+
]);
|
|
263
|
+
const onTogglePropertyGroupKeyframe = vi.fn();
|
|
264
|
+
const view = renderHeader({
|
|
265
|
+
animations: [eased],
|
|
266
|
+
currentTime: 0.5,
|
|
267
|
+
onTogglePropertyGroupKeyframe,
|
|
268
|
+
});
|
|
269
|
+
|
|
270
|
+
expect(view.host.querySelector('[data-property-group="position"]')?.textContent).toContain(
|
|
271
|
+
"12.5, 6.25",
|
|
272
|
+
);
|
|
273
|
+
|
|
274
|
+
// The same sampled value is what an added keyframe gets stamped with, so a
|
|
275
|
+
// header insert lands on the existing curve instead of deforming it.
|
|
276
|
+
click(view.host, "Add Position keyframe");
|
|
277
|
+
expect(onTogglePropertyGroupKeyframe).toHaveBeenCalledOnce();
|
|
278
|
+
expect(onTogglePropertyGroupKeyframe.mock.calls[0][1]).toMatchObject({
|
|
279
|
+
properties: { x: 12.5, y: 6.25 },
|
|
280
|
+
});
|
|
281
|
+
act(() => view.root.unmount());
|
|
282
|
+
});
|
|
283
|
+
|
|
284
|
+
it("disables the previous chevron at or before the group's first keyframe", () => {
|
|
285
|
+
const view = renderHeader({ currentTime: 0 });
|
|
286
|
+
const prevAt0 = view.host.querySelector<HTMLButtonElement>(
|
|
287
|
+
'button[aria-label="Previous Position keyframe"]',
|
|
288
|
+
);
|
|
289
|
+
expect(prevAt0).not.toBeNull();
|
|
290
|
+
expect(prevAt0?.disabled).toBe(true);
|
|
291
|
+
|
|
292
|
+
view.rerender({ currentTime: 1 });
|
|
293
|
+
const prevAt1 = view.host.querySelector<HTMLButtonElement>(
|
|
294
|
+
'button[aria-label="Previous Position keyframe"]',
|
|
295
|
+
);
|
|
296
|
+
expect(prevAt1?.disabled).toBe(false);
|
|
297
|
+
act(() => view.root.unmount());
|
|
298
|
+
});
|
|
299
|
+
|
|
300
|
+
it("uses the same lane row offsets when collapsed, expanded once, and expanded multiple times", () => {
|
|
301
|
+
const view = renderHeader({ expanded: false });
|
|
302
|
+
expect(view.host.querySelectorAll("[data-timeline-lane-top]")).toHaveLength(0);
|
|
303
|
+
|
|
304
|
+
const assertAligned = (animations: GsapAnimation[]) => {
|
|
305
|
+
view.rerender({ animations });
|
|
306
|
+
const lanesHost = document.createElement("div");
|
|
307
|
+
document.body.append(lanesHost);
|
|
308
|
+
const lanesRoot = createRoot(lanesHost);
|
|
309
|
+
act(() => {
|
|
310
|
+
lanesRoot.render(
|
|
311
|
+
<TimelinePropertyLanes
|
|
312
|
+
animations={animations}
|
|
313
|
+
clipStart={0}
|
|
314
|
+
clipDuration={2}
|
|
315
|
+
clipLeftPx={120}
|
|
316
|
+
clipWidthPx={200}
|
|
317
|
+
accentColor="#3CE6AC"
|
|
318
|
+
isSelected
|
|
319
|
+
currentPercentage={0}
|
|
320
|
+
elementId="clip-1"
|
|
321
|
+
selectedKeyframes={new Set()}
|
|
322
|
+
/>,
|
|
323
|
+
);
|
|
324
|
+
});
|
|
325
|
+
expect(
|
|
326
|
+
Array.from(view.host.querySelectorAll<HTMLElement>("[data-timeline-lane-top]")).map(
|
|
327
|
+
(row) => row.style.top,
|
|
328
|
+
),
|
|
329
|
+
).toEqual(
|
|
330
|
+
Array.from(lanesHost.querySelectorAll<HTMLElement>("[data-timeline-lane-top]")).map(
|
|
331
|
+
(row) => row.style.top,
|
|
332
|
+
),
|
|
333
|
+
);
|
|
334
|
+
expect(
|
|
335
|
+
Array.from(lanesHost.querySelectorAll<HTMLElement>("[data-timeline-property-lane]")).map(
|
|
336
|
+
(row) => row.style.left,
|
|
337
|
+
),
|
|
338
|
+
).toEqual(animations.map(() => "120px"));
|
|
339
|
+
act(() => lanesRoot.unmount());
|
|
340
|
+
};
|
|
341
|
+
|
|
342
|
+
assertAligned([POSITION]);
|
|
343
|
+
assertAligned([POSITION, OPACITY]);
|
|
344
|
+
act(() => view.root.unmount());
|
|
345
|
+
});
|
|
346
|
+
});
|