@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
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@hyperframes/studio",
|
|
3
|
-
"version": "0.7.
|
|
3
|
+
"version": "0.7.80",
|
|
4
4
|
"description": "",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -46,11 +46,11 @@
|
|
|
46
46
|
"gsap": "^3.13.0",
|
|
47
47
|
"marked": "^14.1.4",
|
|
48
48
|
"mediabunny": "^1.45.3",
|
|
49
|
-
"@hyperframes/
|
|
50
|
-
"@hyperframes/
|
|
51
|
-
"@hyperframes/
|
|
52
|
-
"@hyperframes/
|
|
53
|
-
"@hyperframes/
|
|
49
|
+
"@hyperframes/core": "0.7.80",
|
|
50
|
+
"@hyperframes/parsers": "0.7.80",
|
|
51
|
+
"@hyperframes/player": "0.7.80",
|
|
52
|
+
"@hyperframes/sdk": "0.7.80",
|
|
53
|
+
"@hyperframes/studio-server": "0.7.80"
|
|
54
54
|
},
|
|
55
55
|
"devDependencies": {
|
|
56
56
|
"@types/react": "19",
|
|
@@ -65,7 +65,7 @@
|
|
|
65
65
|
"vite": "^6.4.2",
|
|
66
66
|
"vitest": "^3.2.4",
|
|
67
67
|
"zustand": "^5.0.0",
|
|
68
|
-
"@hyperframes/producer": "0.7.
|
|
68
|
+
"@hyperframes/producer": "0.7.80"
|
|
69
69
|
},
|
|
70
70
|
"peerDependencies": {
|
|
71
71
|
"react": "19",
|
|
@@ -154,10 +154,10 @@ export function StudioLeftSidebar({
|
|
|
154
154
|
onAddAssetToTimeline={onAddAssetToTimeline}
|
|
155
155
|
onAddCompositionToTimeline={onAddCompositionToTimeline}
|
|
156
156
|
/>
|
|
157
|
-
{/* Vertical resize divider: 3px visible seam,
|
|
157
|
+
{/* Vertical resize divider: 3px visible seam, 13px pointer-capture zone via
|
|
158
158
|
the absolutely-positioned inner hit area. The outer element is w-[3px] so
|
|
159
|
-
it contributes only 3px of gap in the flex row; the inner -left-[
|
|
160
|
-
element widens the hit area
|
|
159
|
+
it contributes only 3px of gap in the flex row; the inner -left-[2px]
|
|
160
|
+
element widens the hit area without affecting layout. */}
|
|
161
161
|
<div
|
|
162
162
|
role="separator"
|
|
163
163
|
aria-label="Resize sidebar"
|
|
@@ -176,8 +176,13 @@ export function StudioLeftSidebar({
|
|
|
176
176
|
adjustPanelWidth("left", delta);
|
|
177
177
|
}}
|
|
178
178
|
>
|
|
179
|
-
{/* Expanded hit zone
|
|
180
|
-
|
|
179
|
+
{/* Expanded hit zone, deliberately asymmetric: 2px into the sidebar card,
|
|
180
|
+
the 3px seam, then 8px into the preview pane's p-2 stage gutter — the
|
|
181
|
+
only dead space adjacent to this seam. It stops at 13px rather than the
|
|
182
|
+
24px WCAG 2.2 (2.5.8) target because the next pixel on either side is
|
|
183
|
+
live: the sidebar's scrolling tab content on the left, the preview
|
|
184
|
+
stage on the right. Silently stealing their clicks is the worse bug. */}
|
|
185
|
+
<div className="absolute inset-y-0 -left-[2px] w-[13px]" />
|
|
181
186
|
{/* Visible hairline */}
|
|
182
187
|
<div className="absolute top-1/2 left-0 h-[52px] w-[3px] -translate-y-1/2 bg-white/12 transition-colors group-hover:bg-white/18 group-active:bg-white/24" />
|
|
183
188
|
</div>
|
|
@@ -19,7 +19,6 @@ import type { EditHistoryKind } from "../utils/editHistory";
|
|
|
19
19
|
import { useSlideshowPersist, type UseSlideshowPersistParams } from "../hooks/useSlideshowPersist";
|
|
20
20
|
import { useSlideshowTabState } from "../hooks/useSlideshowTabState";
|
|
21
21
|
import { DesignPanelPromoteProvider } from "./DesignPanelPromoteProvider";
|
|
22
|
-
|
|
23
22
|
import { useStudioPlaybackContext, useStudioShellContext } from "../contexts/StudioContext";
|
|
24
23
|
import { usePanelLayoutContext } from "../contexts/PanelLayoutContext";
|
|
25
24
|
import { useFileManagerContext } from "../contexts/FileManagerContext";
|
|
@@ -156,6 +155,7 @@ export function StudioRightPanel({
|
|
|
156
155
|
handleUpdateArcSegment,
|
|
157
156
|
handleUnroll,
|
|
158
157
|
handleUpdateKeyframeEase,
|
|
158
|
+
handleUpdateSegmentEase,
|
|
159
159
|
handleSetAllKeyframeEases,
|
|
160
160
|
handleGsapAddKeyframe,
|
|
161
161
|
handleGsapRemoveKeyframe,
|
|
@@ -406,6 +406,7 @@ export function StudioRightPanel({
|
|
|
406
406
|
onUnroll={handleUnroll}
|
|
407
407
|
onUpdateKeyframeEase={handleUpdateKeyframeEase}
|
|
408
408
|
onSetAllKeyframeEases={handleSetAllKeyframeEases}
|
|
409
|
+
onUpdateSegmentEase={handleUpdateSegmentEase}
|
|
409
410
|
recordingState={recordingState}
|
|
410
411
|
recordingDuration={recordingDuration}
|
|
411
412
|
onToggleRecording={onToggleRecording}
|
|
@@ -446,8 +447,7 @@ export function StudioRightPanel({
|
|
|
446
447
|
|
|
447
448
|
return (
|
|
448
449
|
<>
|
|
449
|
-
{/* Vertical resize divider: 3px visible seam,
|
|
450
|
-
the absolutely-positioned inner hit area. */}
|
|
450
|
+
{/* Vertical resize divider: 3px visible seam, 13px hit zone via the inner div. */}
|
|
451
451
|
<div
|
|
452
452
|
role="separator"
|
|
453
453
|
aria-label="Resize inspector panel"
|
|
@@ -467,8 +467,10 @@ export function StudioRightPanel({
|
|
|
467
467
|
adjustPanelWidth("right", delta);
|
|
468
468
|
}}
|
|
469
469
|
>
|
|
470
|
-
{/*
|
|
471
|
-
|
|
470
|
+
{/* Asymmetric hit zone: 8px into the preview's p-2 gutter (the only dead
|
|
471
|
+
space), the 3px seam, 2px into the card. Stops short of the 24px WCAG
|
|
472
|
+
2.5.8 target because the next pixel each way is live. */}
|
|
473
|
+
<div className="absolute inset-y-0 -left-[8px] w-[13px]" />
|
|
472
474
|
{/* Visible hairline */}
|
|
473
475
|
<div className="absolute top-1/2 left-0 h-[52px] w-[3px] -translate-y-1/2 bg-white/12 transition-colors group-hover:bg-white/18 group-active:bg-white/24" />
|
|
474
476
|
</div>
|
|
@@ -449,7 +449,9 @@ export function TimelineToolbar({ domEditSession, onSplitElement }: TimelineTool
|
|
|
449
449
|
setZoomMode("manual");
|
|
450
450
|
setManualZoomPercent(timelineSliderToZoomPercent(Number(e.target.value)));
|
|
451
451
|
}}
|
|
452
|
-
|
|
452
|
+
// h-6 on the input is the 24x24 WCAG 2.2 (2.5.8) target: the visible
|
|
453
|
+
// track stays 2px and the thumb 10px, only the pointer box grows.
|
|
454
|
+
className="mx-1 h-6 w-[96px] cursor-pointer appearance-none bg-transparent [&::-webkit-slider-runnable-track]:h-[2px] [&::-webkit-slider-runnable-track]:rounded-full [&::-webkit-slider-runnable-track]:bg-neutral-700 [&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:w-[10px] [&::-webkit-slider-thumb]:h-[10px] [&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:bg-white [&::-webkit-slider-thumb]:-mt-1 [&::-webkit-slider-thumb]:shadow-[0_0_0_2px_#0a0a0a,0_1px_3px_rgba(0,0,0,0.5)] [&::-webkit-slider-thumb]:cursor-grab [&::-webkit-slider-thumb:active]:cursor-grabbing"
|
|
453
455
|
/>
|
|
454
456
|
<Tooltip label="Zoom in">
|
|
455
457
|
<button
|
|
@@ -2,39 +2,98 @@
|
|
|
2
2
|
|
|
3
3
|
import React, { act } from "react";
|
|
4
4
|
import { createRoot } from "react-dom/client";
|
|
5
|
+
import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
|
|
5
6
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
6
7
|
import { AnimationCard } from "./AnimationCard";
|
|
7
|
-
import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
|
|
8
8
|
import { EASE_PRESETS } from "./easePresetLibrary";
|
|
9
|
+
import type { AnimationKeyframeTarget } from "../../hooks/gsapTweenSynth";
|
|
9
10
|
|
|
10
11
|
const trackStudioSegmentEaseEdit = vi.hoisted(() => vi.fn());
|
|
11
12
|
vi.mock("../../telemetry/events", () => ({ trackStudioSegmentEaseEdit }));
|
|
12
13
|
|
|
13
14
|
(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
|
14
15
|
|
|
16
|
+
const ANIMATION: GsapAnimation = {
|
|
17
|
+
id: "position-tween",
|
|
18
|
+
targetSelector: "#clip-1",
|
|
19
|
+
method: "to",
|
|
20
|
+
position: 0,
|
|
21
|
+
duration: 2,
|
|
22
|
+
ease: "power1.out",
|
|
23
|
+
properties: { x: 200 },
|
|
24
|
+
keyframes: {
|
|
25
|
+
format: "percentage",
|
|
26
|
+
keyframes: [
|
|
27
|
+
{ percentage: 0, properties: { x: 0 } },
|
|
28
|
+
{ percentage: 50, properties: { x: 100 } },
|
|
29
|
+
{ percentage: 100, properties: { x: 200 } },
|
|
30
|
+
],
|
|
31
|
+
},
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
const FLAT_ANIMATION: GsapAnimation = {
|
|
35
|
+
...ANIMATION,
|
|
36
|
+
id: "flat-position-tween",
|
|
37
|
+
keyframes: undefined,
|
|
38
|
+
};
|
|
39
|
+
|
|
15
40
|
afterEach(() => {
|
|
16
41
|
document.body.innerHTML = "";
|
|
17
42
|
trackStudioSegmentEaseEdit.mockClear();
|
|
18
43
|
});
|
|
19
44
|
|
|
20
|
-
function
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
45
|
+
function renderFocusCard(
|
|
46
|
+
focusedSegment: {
|
|
47
|
+
tweenPercentage: number;
|
|
48
|
+
collidingAnimationTargets?: AnimationKeyframeTarget[];
|
|
49
|
+
} | null,
|
|
50
|
+
onEaseCommit = vi.fn(),
|
|
51
|
+
defaultExpanded = false,
|
|
52
|
+
animation = ANIMATION,
|
|
53
|
+
onUpdateMeta = vi.fn(),
|
|
54
|
+
onUpdateSegmentEase = vi.fn(),
|
|
55
|
+
) {
|
|
56
|
+
const host = document.createElement("div");
|
|
57
|
+
document.body.append(host);
|
|
58
|
+
const root = createRoot(host);
|
|
59
|
+
const render = (nextFocusedSegment: { tweenPercentage: number } | null) => {
|
|
60
|
+
act(() => {
|
|
61
|
+
root.render(
|
|
62
|
+
<AnimationCard
|
|
63
|
+
animation={animation}
|
|
64
|
+
defaultExpanded={defaultExpanded}
|
|
65
|
+
focusedSegment={nextFocusedSegment}
|
|
66
|
+
onFocusSegmentConsumed={vi.fn()}
|
|
67
|
+
onUpdateProperty={vi.fn()}
|
|
68
|
+
onUpdateMeta={onUpdateMeta}
|
|
69
|
+
onDeleteAnimation={vi.fn()}
|
|
70
|
+
onAddProperty={vi.fn()}
|
|
71
|
+
onRemoveProperty={vi.fn()}
|
|
72
|
+
onUpdateKeyframeEase={onEaseCommit}
|
|
73
|
+
onUpdateSegmentEase={onUpdateSegmentEase}
|
|
74
|
+
/>,
|
|
75
|
+
);
|
|
76
|
+
});
|
|
77
|
+
};
|
|
78
|
+
render(focusedSegment);
|
|
79
|
+
return { host, root, render };
|
|
30
80
|
}
|
|
31
81
|
|
|
32
|
-
|
|
82
|
+
function findButton(host: HTMLElement, text: string): HTMLButtonElement | undefined {
|
|
83
|
+
return Array.from(host.querySelectorAll("button")).find((button) =>
|
|
84
|
+
button.textContent?.includes(text),
|
|
85
|
+
);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function openSegment(host: HTMLElement, label: string): void {
|
|
89
|
+
const segment = findButton(host, label);
|
|
90
|
+
expect(segment).toBeDefined();
|
|
91
|
+
act(() => segment?.click());
|
|
92
|
+
}
|
|
33
93
|
|
|
34
94
|
function selectPreset(host: HTMLElement, presetId: string): string {
|
|
35
95
|
const presetConfig = EASE_PRESETS.find((candidate) => candidate.id === presetId);
|
|
36
96
|
if (!presetConfig) throw new Error(`Missing ease preset: ${presetId}`);
|
|
37
|
-
|
|
38
97
|
const dropdown = host.querySelector<HTMLButtonElement>("[data-ease-type-dropdown]");
|
|
39
98
|
expect(dropdown).not.toBeNull();
|
|
40
99
|
act(() => dropdown?.click());
|
|
@@ -45,6 +104,8 @@ function selectPreset(host: HTMLElement, presetId: string): string {
|
|
|
45
104
|
return presetConfig.ease;
|
|
46
105
|
}
|
|
47
106
|
|
|
107
|
+
const noop = () => {};
|
|
108
|
+
|
|
48
109
|
/** Every test mounts the same card; only expansion, flat mode, and the spies differ. */
|
|
49
110
|
function renderCard({
|
|
50
111
|
animation = baseAnimation(),
|
|
@@ -82,7 +143,157 @@ function renderCard({
|
|
|
82
143
|
return { host, root };
|
|
83
144
|
}
|
|
84
145
|
|
|
146
|
+
function restoreScrollIntoView(descriptor: PropertyDescriptor | undefined): void {
|
|
147
|
+
if (descriptor) Object.defineProperty(HTMLElement.prototype, "scrollIntoView", descriptor);
|
|
148
|
+
else Reflect.deleteProperty(HTMLElement.prototype, "scrollIntoView");
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
describe("AnimationCard", () => {
|
|
152
|
+
it("scrolls a focused segment into view but not a manually toggled segment", () => {
|
|
153
|
+
const originalScrollIntoView = Object.getOwnPropertyDescriptor(
|
|
154
|
+
HTMLElement.prototype,
|
|
155
|
+
"scrollIntoView",
|
|
156
|
+
);
|
|
157
|
+
const scrollIntoView = vi.fn();
|
|
158
|
+
Object.defineProperty(HTMLElement.prototype, "scrollIntoView", {
|
|
159
|
+
configurable: true,
|
|
160
|
+
value: scrollIntoView,
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
const view = renderFocusCard({ tweenPercentage: 50 });
|
|
164
|
+
try {
|
|
165
|
+
expect(scrollIntoView).toHaveBeenCalledOnce();
|
|
166
|
+
expect(scrollIntoView).toHaveBeenCalledWith({ block: "nearest", behavior: "smooth" });
|
|
167
|
+
|
|
168
|
+
view.render(null);
|
|
169
|
+
const manualToggle = findButton(view.host, "50% → 100%");
|
|
170
|
+
expect(manualToggle).toBeDefined();
|
|
171
|
+
act(() => manualToggle?.click());
|
|
172
|
+
expect(scrollIntoView).toHaveBeenCalledOnce();
|
|
173
|
+
} finally {
|
|
174
|
+
act(() => view.root.unmount());
|
|
175
|
+
restoreScrollIntoView(originalScrollIntoView);
|
|
176
|
+
}
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
it("tracks a committed segment ease alongside the existing update", () => {
|
|
180
|
+
const onEaseCommit = vi.fn();
|
|
181
|
+
const view = renderFocusCard(null, onEaseCommit, true);
|
|
182
|
+
openSegment(view.host, "0% → 50%");
|
|
183
|
+
const ease = selectPreset(view.host, "quad-out");
|
|
184
|
+
|
|
185
|
+
expect(onEaseCommit).toHaveBeenCalledWith(ANIMATION.id, 50, ease);
|
|
186
|
+
expect(trackStudioSegmentEaseEdit).toHaveBeenCalledWith({ action: "commit", ease });
|
|
187
|
+
act(() => view.root.unmount());
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
it("commits a focused multi-id segment ease through the bulk callback", () => {
|
|
191
|
+
const onUpdateKeyframeEase = vi.fn();
|
|
192
|
+
const onUpdateSegmentEase = vi.fn();
|
|
193
|
+
const collidingAnimationTargets = [
|
|
194
|
+
{ animationId: ANIMATION.id, tweenPercentage: 50 },
|
|
195
|
+
{ animationId: "scale-tween", tweenPercentage: 75 },
|
|
196
|
+
{ animationId: "opacity-tween", tweenPercentage: 25 },
|
|
197
|
+
];
|
|
198
|
+
const view = renderFocusCard(
|
|
199
|
+
{ tweenPercentage: 50, collidingAnimationTargets },
|
|
200
|
+
onUpdateKeyframeEase,
|
|
201
|
+
false,
|
|
202
|
+
ANIMATION,
|
|
203
|
+
vi.fn(),
|
|
204
|
+
onUpdateSegmentEase,
|
|
205
|
+
);
|
|
206
|
+
const ease = selectPreset(view.host, "quad-out");
|
|
207
|
+
|
|
208
|
+
expect(onUpdateSegmentEase).toHaveBeenCalledExactlyOnceWith(collidingAnimationTargets, ease);
|
|
209
|
+
expect(onUpdateKeyframeEase).not.toHaveBeenCalled();
|
|
210
|
+
act(() => view.root.unmount());
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
it("keeps a focused single-id segment ease on the single callback", () => {
|
|
214
|
+
const onUpdateKeyframeEase = vi.fn();
|
|
215
|
+
const onUpdateSegmentEase = vi.fn();
|
|
216
|
+
const view = renderFocusCard(
|
|
217
|
+
{
|
|
218
|
+
tweenPercentage: 50,
|
|
219
|
+
collidingAnimationTargets: [{ animationId: ANIMATION.id, tweenPercentage: 50 }],
|
|
220
|
+
},
|
|
221
|
+
onUpdateKeyframeEase,
|
|
222
|
+
false,
|
|
223
|
+
ANIMATION,
|
|
224
|
+
vi.fn(),
|
|
225
|
+
onUpdateSegmentEase,
|
|
226
|
+
);
|
|
227
|
+
const ease = selectPreset(view.host, "quad-out");
|
|
228
|
+
|
|
229
|
+
expect(onUpdateKeyframeEase).toHaveBeenCalledExactlyOnceWith(ANIMATION.id, 50, ease);
|
|
230
|
+
expect(onUpdateSegmentEase).not.toHaveBeenCalled();
|
|
231
|
+
act(() => view.root.unmount());
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
it("commits a focused flat tween segment ease through tween metadata", () => {
|
|
235
|
+
const onUpdateMeta = vi.fn();
|
|
236
|
+
const onUpdateKeyframeEase = vi.fn();
|
|
237
|
+
const view = renderFocusCard(
|
|
238
|
+
{ tweenPercentage: 100 },
|
|
239
|
+
onUpdateKeyframeEase,
|
|
240
|
+
false,
|
|
241
|
+
FLAT_ANIMATION,
|
|
242
|
+
onUpdateMeta,
|
|
243
|
+
);
|
|
244
|
+
const ease = selectPreset(view.host, "quad-out");
|
|
245
|
+
|
|
246
|
+
expect(onUpdateMeta).toHaveBeenCalledExactlyOnceWith(FLAT_ANIMATION.id, { ease });
|
|
247
|
+
expect(onUpdateKeyframeEase).not.toHaveBeenCalled();
|
|
248
|
+
act(() => view.root.unmount());
|
|
249
|
+
});
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
function baseAnimation(overrides: Partial<GsapAnimation> = {}): GsapAnimation {
|
|
253
|
+
return {
|
|
254
|
+
id: "anim-1",
|
|
255
|
+
method: "to",
|
|
256
|
+
position: 0.8,
|
|
257
|
+
duration: 1.2,
|
|
258
|
+
ease: "power2.out",
|
|
259
|
+
properties: { opacity: 1 },
|
|
260
|
+
...overrides,
|
|
261
|
+
} as GsapAnimation;
|
|
262
|
+
}
|
|
263
|
+
|
|
85
264
|
describe("AnimationCard ease editing", () => {
|
|
265
|
+
it.each([
|
|
266
|
+
["spring", "power2.out", "spring(0.42)", "Spring bounce"],
|
|
267
|
+
["wiggle", "power2.out", "wiggle(3,easeInOut,0.12)", "Wiggle count"],
|
|
268
|
+
["curve", "spring(0.6)", "custom(M0,0 C0.16,1 0.3,1 1,1)", "Cubic bezier control points"],
|
|
269
|
+
] as const)(
|
|
270
|
+
"commits and immediately displays the %s default when a keyframe segment switches mode",
|
|
271
|
+
(mode, currentEase, ease, fieldLabel) => {
|
|
272
|
+
const onUpdateKeyframeEase = vi.fn();
|
|
273
|
+
const animation = baseAnimation({
|
|
274
|
+
keyframes: {
|
|
275
|
+
format: "percentage",
|
|
276
|
+
keyframes: [
|
|
277
|
+
{ percentage: 0, properties: { opacity: 0 } },
|
|
278
|
+
{ percentage: 50, properties: { opacity: 0.5 }, ease: currentEase },
|
|
279
|
+
{ percentage: 100, properties: { opacity: 1 } },
|
|
280
|
+
],
|
|
281
|
+
},
|
|
282
|
+
});
|
|
283
|
+
const view = renderFocusCard(null, onUpdateKeyframeEase, true, animation);
|
|
284
|
+
|
|
285
|
+
openSegment(view.host, "0% → 50%");
|
|
286
|
+
const modeButton = view.host.querySelector<HTMLButtonElement>(`[data-ease-mode="${mode}"]`);
|
|
287
|
+
expect(modeButton).not.toBeNull();
|
|
288
|
+
act(() => modeButton?.click());
|
|
289
|
+
|
|
290
|
+
expect(onUpdateKeyframeEase).toHaveBeenCalledExactlyOnceWith(animation.id, 50, ease);
|
|
291
|
+
expect(modeButton?.getAttribute("aria-checked")).toBe("true");
|
|
292
|
+
expect(view.host.querySelector(`[aria-label="${fieldLabel}"]`)).not.toBeNull();
|
|
293
|
+
act(() => view.root.unmount());
|
|
294
|
+
},
|
|
295
|
+
);
|
|
296
|
+
|
|
86
297
|
it("commits one preset change to the selected keyframe segment", () => {
|
|
87
298
|
const onUpdateKeyframeEase = vi.fn();
|
|
88
299
|
const animation = baseAnimation({
|
|
@@ -97,11 +308,7 @@ describe("AnimationCard ease editing", () => {
|
|
|
97
308
|
});
|
|
98
309
|
const view = renderCard({ animation, onUpdateKeyframeEase });
|
|
99
310
|
|
|
100
|
-
|
|
101
|
-
button.textContent?.includes("0% → 50%"),
|
|
102
|
-
);
|
|
103
|
-
expect(segment).toBeDefined();
|
|
104
|
-
act(() => segment?.click());
|
|
311
|
+
openSegment(view.host, "0% → 50%");
|
|
105
312
|
const ease = selectPreset(view.host, "quad-out");
|
|
106
313
|
|
|
107
314
|
expect(onUpdateKeyframeEase).toHaveBeenCalledExactlyOnceWith(animation.id, 50, ease);
|
|
@@ -18,12 +18,16 @@ import {
|
|
|
18
18
|
parseNumericOrString,
|
|
19
19
|
BOOLEAN_PROPS,
|
|
20
20
|
} from "./AnimationCardParts";
|
|
21
|
+
import type { AnimationKeyframeTarget } from "../../hooks/gsapTweenSynth";
|
|
21
22
|
|
|
22
23
|
interface AnimationCardProps extends GsapAnimationEditCallbacks {
|
|
23
24
|
animation: GsapAnimation;
|
|
24
25
|
defaultExpanded: boolean;
|
|
25
26
|
flat?: boolean;
|
|
26
|
-
focusedSegment?: {
|
|
27
|
+
focusedSegment?: {
|
|
28
|
+
tweenPercentage: number;
|
|
29
|
+
collidingAnimationTargets?: AnimationKeyframeTarget[];
|
|
30
|
+
} | null;
|
|
27
31
|
onFocusSegmentConsumed?: () => void;
|
|
28
32
|
}
|
|
29
33
|
|
|
@@ -47,6 +51,7 @@ export const AnimationCard = memo(function AnimationCard({
|
|
|
47
51
|
onSetArcPath,
|
|
48
52
|
onUpdateArcSegment,
|
|
49
53
|
onUpdateKeyframeEase,
|
|
54
|
+
onUpdateSegmentEase,
|
|
50
55
|
onSetAllKeyframeEases,
|
|
51
56
|
onUnroll,
|
|
52
57
|
}: AnimationCardProps) {
|
|
@@ -54,6 +59,9 @@ export const AnimationCard = memo(function AnimationCard({
|
|
|
54
59
|
const [addingProp, setAddingProp] = useState(false);
|
|
55
60
|
const [addingFromProp, setAddingFromProp] = useState(false);
|
|
56
61
|
const [expandedKfPct, setExpandedKfPct] = useState<number | null>(null);
|
|
62
|
+
const [focusedCollidingAnimationTargets, setFocusedCollidingAnimationTargets] = useState<
|
|
63
|
+
AnimationKeyframeTarget[] | undefined
|
|
64
|
+
>();
|
|
57
65
|
const cardRef = useRef<HTMLDivElement>(null);
|
|
58
66
|
const pendingAutoScrollRef = useRef(false);
|
|
59
67
|
|
|
@@ -62,6 +70,7 @@ export const AnimationCard = memo(function AnimationCard({
|
|
|
62
70
|
setExpanded(true);
|
|
63
71
|
pendingAutoScrollRef.current = true;
|
|
64
72
|
setExpandedKfPct(focusedSegment.tweenPercentage);
|
|
73
|
+
setFocusedCollidingAnimationTargets(focusedSegment.collidingAnimationTargets);
|
|
65
74
|
onFocusSegmentConsumed?.();
|
|
66
75
|
}, [focusedSegment, onFocusSegmentConsumed]);
|
|
67
76
|
|
|
@@ -288,9 +297,21 @@ export const AnimationCard = memo(function AnimationCard({
|
|
|
288
297
|
keyframes={animation.keyframes.keyframes}
|
|
289
298
|
globalEase={animation.keyframes.easeEach ?? animation.ease ?? "none"}
|
|
290
299
|
expandedPct={expandedKfPct}
|
|
291
|
-
|
|
300
|
+
collidingAnimationTargets={focusedCollidingAnimationTargets}
|
|
301
|
+
onToggle={(pct) => {
|
|
302
|
+
setExpandedKfPct(pct);
|
|
303
|
+
setFocusedCollidingAnimationTargets(undefined);
|
|
304
|
+
}}
|
|
292
305
|
onEaseCommit={(pct, ease) => {
|
|
293
|
-
|
|
306
|
+
if (
|
|
307
|
+
focusedCollidingAnimationTargets &&
|
|
308
|
+
focusedCollidingAnimationTargets.length > 1 &&
|
|
309
|
+
onUpdateSegmentEase
|
|
310
|
+
) {
|
|
311
|
+
onUpdateSegmentEase(focusedCollidingAnimationTargets, ease);
|
|
312
|
+
} else {
|
|
313
|
+
onUpdateKeyframeEase(animation.id, pct, ease);
|
|
314
|
+
}
|
|
294
315
|
trackStudioSegmentEaseEdit({ action: "commit", ease });
|
|
295
316
|
}}
|
|
296
317
|
onApplyAll={
|
|
@@ -3,7 +3,11 @@
|
|
|
3
3
|
import React, { act, useState } from "react";
|
|
4
4
|
import { createRoot } from "react-dom/client";
|
|
5
5
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
6
|
+
import { parseSpringBounce } from "@hyperframes/core/spring-ease";
|
|
7
|
+
import { parseWiggleEase } from "@hyperframes/core/wiggle-ease";
|
|
6
8
|
import { EaseCurveSection, MiniCurveSvg } from "./EaseCurveSection";
|
|
9
|
+
import { resolveEaseCurveTuple } from "./gsapAnimationConstants";
|
|
10
|
+
import type { AnimationKeyframeTarget } from "../../hooks/gsapTweenSynth";
|
|
7
11
|
|
|
8
12
|
(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
|
9
13
|
|
|
@@ -11,12 +15,22 @@ afterEach(() => {
|
|
|
11
15
|
document.body.innerHTML = "";
|
|
12
16
|
});
|
|
13
17
|
|
|
14
|
-
function renderSection(
|
|
18
|
+
function renderSection(
|
|
19
|
+
ease = "none",
|
|
20
|
+
onCustomEaseCommit = vi.fn(),
|
|
21
|
+
collidingAnimationTargets?: AnimationKeyframeTarget[],
|
|
22
|
+
) {
|
|
15
23
|
const host = document.createElement("div");
|
|
16
24
|
document.body.append(host);
|
|
17
25
|
const root = createRoot(host);
|
|
18
26
|
act(() => {
|
|
19
|
-
root.render(
|
|
27
|
+
root.render(
|
|
28
|
+
<EaseCurveSection
|
|
29
|
+
ease={ease}
|
|
30
|
+
onCustomEaseCommit={onCustomEaseCommit}
|
|
31
|
+
collidingAnimationTargets={collidingAnimationTargets}
|
|
32
|
+
/>,
|
|
33
|
+
);
|
|
20
34
|
});
|
|
21
35
|
return { host, root, onCustomEaseCommit };
|
|
22
36
|
}
|
|
@@ -65,6 +79,19 @@ function renderStatefulSection(initialEase = "none", onCustomEaseCommit = vi.fn(
|
|
|
65
79
|
return { host, root, onCustomEaseCommit };
|
|
66
80
|
}
|
|
67
81
|
|
|
82
|
+
function renderControlledSection(initialEase = "none", onCustomEaseCommit = vi.fn()) {
|
|
83
|
+
const host = document.createElement("div");
|
|
84
|
+
document.body.append(host);
|
|
85
|
+
const root = createRoot(host);
|
|
86
|
+
const renderEase = (ease: string) => {
|
|
87
|
+
act(() =>
|
|
88
|
+
root.render(<EaseCurveSection ease={ease} onCustomEaseCommit={onCustomEaseCommit} />),
|
|
89
|
+
);
|
|
90
|
+
};
|
|
91
|
+
renderEase(initialEase);
|
|
92
|
+
return { host, root, onCustomEaseCommit, renderEase };
|
|
93
|
+
}
|
|
94
|
+
|
|
68
95
|
function clickMode(host: HTMLElement, mode: "curve" | "spring" | "wiggle"): void {
|
|
69
96
|
const toggle = host.querySelector<HTMLButtonElement>(`[data-ease-mode="${mode}"]`);
|
|
70
97
|
expect(toggle).not.toBeNull();
|
|
@@ -82,6 +109,29 @@ function editorLabel(host: HTMLElement): string | null {
|
|
|
82
109
|
}
|
|
83
110
|
|
|
84
111
|
describe("EaseCurveSection preset grid", () => {
|
|
112
|
+
it("shows the number of animations for a multi-id segment", () => {
|
|
113
|
+
const { host, root } = renderSection("power2.out", vi.fn(), [
|
|
114
|
+
{ animationId: "move-x", tweenPercentage: 20 },
|
|
115
|
+
{ animationId: "move-y", tweenPercentage: 50 },
|
|
116
|
+
{ animationId: "fade", tweenPercentage: 80 },
|
|
117
|
+
]);
|
|
118
|
+
|
|
119
|
+
expect(host.textContent).toContain("Applies to 3 animations");
|
|
120
|
+
|
|
121
|
+
act(() => root.unmount());
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
it.each([undefined, [{ animationId: "move-x", tweenPercentage: 20 }]])(
|
|
125
|
+
"does not show a property count for a non-colliding segment",
|
|
126
|
+
(collidingAnimationTargets) => {
|
|
127
|
+
const { host, root } = renderSection("power2.out", vi.fn(), collidingAnimationTargets);
|
|
128
|
+
|
|
129
|
+
expect(host.textContent).not.toContain("Applies to");
|
|
130
|
+
|
|
131
|
+
act(() => root.unmount());
|
|
132
|
+
},
|
|
133
|
+
);
|
|
134
|
+
|
|
85
135
|
it.each([
|
|
86
136
|
["curve", "none", "linear", ["flow-7", "spring-bouncy"]],
|
|
87
137
|
["spring", "spring(0.42)", "spring-bouncy", ["linear", "flow-7"]],
|
|
@@ -257,12 +307,107 @@ describe("EaseCurveSection preset grid", () => {
|
|
|
257
307
|
|
|
258
308
|
clickMode(host, "spring");
|
|
259
309
|
expect(onCustomEaseCommit).toHaveBeenLastCalledWith("spring(0.42)");
|
|
310
|
+
expect(parseSpringBounce(onCustomEaseCommit.mock.lastCall![0])).toBe(0.42);
|
|
260
311
|
|
|
261
312
|
clickMode(host, "curve");
|
|
262
313
|
expect(onCustomEaseCommit).toHaveBeenLastCalledWith("custom(M0,0 C0.16,1 0.3,1 1,1)");
|
|
314
|
+
expect(resolveEaseCurveTuple(onCustomEaseCommit.mock.lastCall![0])).toEqual([0.16, 1, 0.3, 1]);
|
|
263
315
|
|
|
264
316
|
clickMode(host, "wiggle");
|
|
265
317
|
expect(onCustomEaseCommit).toHaveBeenLastCalledWith("wiggle(3,easeInOut,0.12)");
|
|
318
|
+
expect(parseWiggleEase(onCustomEaseCommit.mock.lastCall![0])).toEqual({
|
|
319
|
+
wiggles: 3,
|
|
320
|
+
type: "easeInOut",
|
|
321
|
+
amplitude: 0.12,
|
|
322
|
+
});
|
|
323
|
+
expect(onCustomEaseCommit).toHaveBeenCalledTimes(3);
|
|
324
|
+
|
|
325
|
+
act(() => root.unmount());
|
|
326
|
+
});
|
|
327
|
+
|
|
328
|
+
it("keeps an optimistic mode visible through its canonical prop round-trip", () => {
|
|
329
|
+
const { host, root, onCustomEaseCommit, renderEase } = renderControlledSection();
|
|
330
|
+
|
|
331
|
+
clickMode(host, "spring");
|
|
332
|
+
expect(host.querySelector('[data-ease-mode="spring"]')?.getAttribute("aria-checked")).toBe(
|
|
333
|
+
"true",
|
|
334
|
+
);
|
|
335
|
+
expect(host.querySelector('[aria-label="Spring bounce"]')).not.toBeNull();
|
|
336
|
+
|
|
337
|
+
renderEase("spring(0.42)");
|
|
338
|
+
expect(host.querySelector('[data-ease-mode="spring"]')?.getAttribute("aria-checked")).toBe(
|
|
339
|
+
"true",
|
|
340
|
+
);
|
|
341
|
+
expect(host.querySelector('[aria-label="Spring bounce"]')).not.toBeNull();
|
|
342
|
+
expect(onCustomEaseCommit).toHaveBeenCalledExactlyOnceWith("spring(0.42)");
|
|
343
|
+
|
|
344
|
+
act(() => root.unmount());
|
|
345
|
+
});
|
|
346
|
+
|
|
347
|
+
// Two switches before the first commit round-trips: the commits serialize, so
|
|
348
|
+
// the older value arrives while the newer one is still in flight. Repainting
|
|
349
|
+
// it would flash wiggle, spring, wiggle in the panel.
|
|
350
|
+
it("ignores an older in-flight commit arriving after a newer switch", () => {
|
|
351
|
+
const { host, root, renderEase } = renderControlledSection();
|
|
352
|
+
|
|
353
|
+
clickMode(host, "spring");
|
|
354
|
+
clickMode(host, "wiggle");
|
|
355
|
+
renderEase("spring(0.42)");
|
|
356
|
+
|
|
357
|
+
expect(host.querySelector('[data-ease-mode="wiggle"]')?.getAttribute("aria-checked")).toBe(
|
|
358
|
+
"true",
|
|
359
|
+
);
|
|
360
|
+
|
|
361
|
+
renderEase("wiggle(3,easeInOut,0.12)");
|
|
362
|
+
expect(host.querySelector('[data-ease-mode="wiggle"]')?.getAttribute("aria-checked")).toBe(
|
|
363
|
+
"true",
|
|
364
|
+
);
|
|
365
|
+
|
|
366
|
+
act(() => root.unmount());
|
|
367
|
+
});
|
|
368
|
+
|
|
369
|
+
// The commit is fire-and-forget: a rejected write or one that lands as a
|
|
370
|
+
// no-op never changes `ease`, so nothing else can retire the optimistic
|
|
371
|
+
// value and the panel would keep claiming a curve that was never saved.
|
|
372
|
+
it("falls back to the committed ease when the commit never round-trips", () => {
|
|
373
|
+
vi.useFakeTimers();
|
|
374
|
+
try {
|
|
375
|
+
const { host, root } = renderControlledSection("power2.out");
|
|
376
|
+
|
|
377
|
+
clickMode(host, "spring");
|
|
378
|
+
expect(host.querySelector('[data-ease-mode="spring"]')?.getAttribute("aria-checked")).toBe(
|
|
379
|
+
"true",
|
|
380
|
+
);
|
|
381
|
+
|
|
382
|
+
act(() => vi.advanceTimersByTime(2000));
|
|
383
|
+
|
|
384
|
+
expect(host.querySelector('[data-ease-mode="spring"]')?.getAttribute("aria-checked")).toBe(
|
|
385
|
+
"false",
|
|
386
|
+
);
|
|
387
|
+
expect(host.querySelector('[data-ease-mode="curve"]')?.getAttribute("aria-checked")).toBe(
|
|
388
|
+
"true",
|
|
389
|
+
);
|
|
390
|
+
|
|
391
|
+
act(() => root.unmount());
|
|
392
|
+
} finally {
|
|
393
|
+
vi.useRealTimers();
|
|
394
|
+
}
|
|
395
|
+
});
|
|
396
|
+
|
|
397
|
+
it("replaces an optimistic mode when the canonical prop changes externally", () => {
|
|
398
|
+
const { host, root, renderEase } = renderControlledSection();
|
|
399
|
+
|
|
400
|
+
clickMode(host, "spring");
|
|
401
|
+
renderEase("wiggle(2,uniform,0.3)");
|
|
402
|
+
|
|
403
|
+
expect(host.querySelector('[data-ease-mode="spring"]')?.getAttribute("aria-checked")).toBe(
|
|
404
|
+
"false",
|
|
405
|
+
);
|
|
406
|
+
expect(host.querySelector('[data-ease-mode="wiggle"]')?.getAttribute("aria-checked")).toBe(
|
|
407
|
+
"true",
|
|
408
|
+
);
|
|
409
|
+
expect(host.querySelector('[aria-label="Wiggle count"]')).not.toBeNull();
|
|
410
|
+
expect(host.querySelector('[aria-label="Spring bounce"]')).toBeNull();
|
|
266
411
|
|
|
267
412
|
act(() => root.unmount());
|
|
268
413
|
});
|