@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
|
@@ -26,7 +26,6 @@ describe("withTrackedGsapAnimationCallbacks", () => {
|
|
|
26
26
|
const onLivePreviewEnd = vi.fn();
|
|
27
27
|
callbacks.onLivePreview = onLivePreview;
|
|
28
28
|
callbacks.onLivePreviewEnd = onLivePreviewEnd;
|
|
29
|
-
|
|
30
29
|
const tracked = withTrackedGsapAnimationCallbacks(callbacks, vi.fn());
|
|
31
30
|
|
|
32
31
|
expect(tracked.onUpdateFromProperty).toBeUndefined();
|
|
@@ -37,6 +36,7 @@ describe("withTrackedGsapAnimationCallbacks", () => {
|
|
|
37
36
|
expect(tracked.onUpdateKeyframeEase).toBeUndefined();
|
|
38
37
|
expect(tracked.onSetAllKeyframeEases).toBeUndefined();
|
|
39
38
|
expect(tracked.onUnroll).toBeUndefined();
|
|
39
|
+
expect(tracked.onUpdateSegmentEase).toBeUndefined();
|
|
40
40
|
expect(tracked.onLivePreview).toBe(onLivePreview);
|
|
41
41
|
expect(tracked.onLivePreviewEnd).toBe(onLivePreviewEnd);
|
|
42
42
|
});
|
|
@@ -57,6 +57,7 @@ describe("withTrackedGsapAnimationCallbacks", () => {
|
|
|
57
57
|
onUpdateArcSegment: mutation("arc-segment"),
|
|
58
58
|
onUpdateKeyframeEase: mutation("keyframe-ease"),
|
|
59
59
|
onSetAllKeyframeEases: mutation("all-eases"),
|
|
60
|
+
onUpdateSegmentEase: mutation("segment-ease"),
|
|
60
61
|
onUnroll: mutation("unroll"),
|
|
61
62
|
};
|
|
62
63
|
const tracked = withTrackedGsapAnimationCallbacks(callbacks, (control, name) => {
|
|
@@ -79,6 +80,10 @@ describe("withTrackedGsapAnimationCallbacks", () => {
|
|
|
79
80
|
requireCallback(tracked.onUpdateArcSegment)("a1", 1, { curviness: 0.5 });
|
|
80
81
|
requireCallback(tracked.onUpdateKeyframeEase)("a1", 50, "power2.out");
|
|
81
82
|
requireCallback(tracked.onSetAllKeyframeEases)("a1", "none");
|
|
83
|
+
requireCallback(tracked.onUpdateSegmentEase)(
|
|
84
|
+
[{ animationId: "a1", tweenPercentage: 50 }],
|
|
85
|
+
"none",
|
|
86
|
+
);
|
|
82
87
|
requireCallback(tracked.onUnroll)("a1");
|
|
83
88
|
|
|
84
89
|
expect(events).toEqual([
|
|
@@ -115,6 +120,8 @@ describe("withTrackedGsapAnimationCallbacks", () => {
|
|
|
115
120
|
"mutate:keyframe-ease",
|
|
116
121
|
"track:select:All keyframe eases",
|
|
117
122
|
"mutate:all-eases",
|
|
123
|
+
"track:select:Segment ease",
|
|
124
|
+
"mutate:segment-ease",
|
|
118
125
|
"track:button:Unroll animation",
|
|
119
126
|
"mutate:unroll",
|
|
120
127
|
]);
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { ArcPathSegment } from "@hyperframes/parsers/gsap-parser";
|
|
2
2
|
import { usePlayerStore } from "../../player";
|
|
3
|
+
import type { AnimationKeyframeTarget } from "../../hooks/gsapTweenSynth";
|
|
3
4
|
|
|
4
5
|
/**
|
|
5
6
|
* Edit callbacks shared by GsapAnimationSection and each AnimationCard it
|
|
@@ -30,6 +31,7 @@ export interface GsapAnimationEditCallbacks {
|
|
|
30
31
|
update: Partial<ArcPathSegment>,
|
|
31
32
|
) => void;
|
|
32
33
|
onUpdateKeyframeEase?: (animationId: string, percentage: number, ease: string) => void;
|
|
34
|
+
onUpdateSegmentEase?: (targets: AnimationKeyframeTarget[], ease: string) => void;
|
|
33
35
|
/** Apply one ease to every keyframe segment at once (clears per-segment overrides). */
|
|
34
36
|
onSetAllKeyframeEases?: (animationId: string, ease: string) => void;
|
|
35
37
|
/** Unroll a computed (helper/loop) tween into literal tweens so it edits directly. */
|
|
@@ -122,6 +124,12 @@ export function withTrackedGsapAnimationCallbacks(
|
|
|
122
124
|
: undefined,
|
|
123
125
|
onLivePreview: callbacks.onLivePreview,
|
|
124
126
|
onLivePreviewEnd: callbacks.onLivePreviewEnd,
|
|
127
|
+
onUpdateSegmentEase: callbacks.onUpdateSegmentEase
|
|
128
|
+
? (targets, ease) => {
|
|
129
|
+
track("select", "Segment ease");
|
|
130
|
+
callbacks.onUpdateSegmentEase?.(targets, ease);
|
|
131
|
+
}
|
|
132
|
+
: undefined,
|
|
125
133
|
onSetArcPath: callbacks.onSetArcPath
|
|
126
134
|
? (animationId, config) => {
|
|
127
135
|
track("toggle", config.autoRotate !== undefined ? "Auto rotate" : "Arc motion");
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
// Real gsap, not a parseEase stub: this pins the seek behaviour of the `hold`
|
|
2
|
+
// ease against the engine that actually runs it, and gsap is a studio
|
|
3
|
+
// dependency. core's own customEase.test.ts covers the resolver in isolation.
|
|
4
|
+
import { installStudioCustomEase } from "@hyperframes/core/runtime/custom-ease";
|
|
5
|
+
import { gsap } from "gsap";
|
|
6
|
+
import { describe, expect, it } from "vitest";
|
|
7
|
+
|
|
8
|
+
describe("Studio hold ease", () => {
|
|
9
|
+
it("holds the start value under seek until the destination time", () => {
|
|
10
|
+
const runtimeGsap = { parseEase: gsap.parseEase.bind(gsap) };
|
|
11
|
+
expect(installStudioCustomEase(runtimeGsap)).toBe(true);
|
|
12
|
+
const hold = runtimeGsap.parseEase("hold");
|
|
13
|
+
expect(hold).toBeTypeOf("function");
|
|
14
|
+
if (typeof hold !== "function") return;
|
|
15
|
+
|
|
16
|
+
const target = { value: 0 };
|
|
17
|
+
const timeline = gsap.timeline({ paused: true }).to(
|
|
18
|
+
target,
|
|
19
|
+
{
|
|
20
|
+
value: 100,
|
|
21
|
+
duration: 2,
|
|
22
|
+
ease: hold,
|
|
23
|
+
},
|
|
24
|
+
0,
|
|
25
|
+
);
|
|
26
|
+
|
|
27
|
+
timeline.seek(0.5);
|
|
28
|
+
expect(target.value).toBe(0);
|
|
29
|
+
timeline.seek(1);
|
|
30
|
+
expect(target.value).toBe(0);
|
|
31
|
+
timeline.seek(1.99);
|
|
32
|
+
expect(target.value).toBe(0);
|
|
33
|
+
timeline.seek(2);
|
|
34
|
+
expect(target.value).toBe(100);
|
|
35
|
+
timeline.kill();
|
|
36
|
+
});
|
|
37
|
+
});
|
|
@@ -180,3 +180,45 @@ describe("resolveKeyframeRetime — guards", () => {
|
|
|
180
180
|
expect(r.pctRemap).toEqual([]);
|
|
181
181
|
});
|
|
182
182
|
});
|
|
183
|
+
|
|
184
|
+
describe("resolveKeyframeRetime — move percentages are rounded like the resize path", () => {
|
|
185
|
+
// The move branch used to return the raw quotient, so `74.81203007518799%`
|
|
186
|
+
// landed in the user's source and churned the diff on every drag.
|
|
187
|
+
const decimals = (n: number): number => String(n).split(".")[1]?.length ?? 0;
|
|
188
|
+
|
|
189
|
+
it("rounds a repeating quotient to 3dp", () => {
|
|
190
|
+
const r = resolveKeyframeRetime({
|
|
191
|
+
tweenStart: 2,
|
|
192
|
+
tweenDuration: 3,
|
|
193
|
+
keyframes: KEYFRAMES,
|
|
194
|
+
draggedTweenPct: 0,
|
|
195
|
+
dropAbsTime: 3, // (3-2)/3 = 33.333333333333336%
|
|
196
|
+
});
|
|
197
|
+
expect(r.kind).toBe("move");
|
|
198
|
+
expect(r.toTweenPct).toBe(33.333);
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
it("never emits more than 3 decimal places", () => {
|
|
202
|
+
for (const tweenDuration of [3, 7, 9, 11, 133]) {
|
|
203
|
+
const r = resolveKeyframeRetime({
|
|
204
|
+
tweenStart: 2,
|
|
205
|
+
tweenDuration,
|
|
206
|
+
keyframes: KEYFRAMES,
|
|
207
|
+
draggedTweenPct: 0,
|
|
208
|
+
dropAbsTime: 3,
|
|
209
|
+
});
|
|
210
|
+
expect(r.kind).toBe("move");
|
|
211
|
+
expect(decimals(r.toTweenPct ?? 0)).toBeLessThanOrEqual(3);
|
|
212
|
+
}
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
it("leaves an already-short percentage untouched", () => {
|
|
216
|
+
const r = resolveKeyframeRetime({
|
|
217
|
+
...WINDOW,
|
|
218
|
+
keyframes: KEYFRAMES,
|
|
219
|
+
draggedTweenPct: 0,
|
|
220
|
+
dropAbsTime: 3, // (3-2)/4 = exactly 25%
|
|
221
|
+
});
|
|
222
|
+
expect(r.toTweenPct).toBe(25);
|
|
223
|
+
});
|
|
224
|
+
});
|
|
@@ -121,7 +121,10 @@ export function resolveKeyframeRetime(opts: {
|
|
|
121
121
|
|
|
122
122
|
// Within the tween window → plain move (re-key the tween-%).
|
|
123
123
|
if (dropAbsTime >= tweenStart - EPSILON_TIME && dropAbsTime <= tweenEnd + EPSILON_TIME) {
|
|
124
|
-
|
|
124
|
+
// Round here, not at the return: the no-op test below and the value written
|
|
125
|
+
// to source must be the same number. The resize branch already rounds, so
|
|
126
|
+
// this keeps both write paths at the authored 3dp precision.
|
|
127
|
+
const toTweenPct = round3(clamp(((dropAbsTime - tweenStart) / tweenDuration) * 100, 0, 100));
|
|
125
128
|
if (Math.abs(toTweenPct - draggedTweenPct) < NOOP_EPSILON_PCT) return { kind: "noop" };
|
|
126
129
|
return { kind: "move", toTweenPct };
|
|
127
130
|
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
import { afterEach, describe, expect, it } from "vitest";
|
|
3
|
+
import type { DomEditSelection } from "./domEditingTypes";
|
|
4
|
+
import { buildStableSelector, getSelectorIndex } from "./domEditingDom";
|
|
5
|
+
import { selectorFor } from "./motionPathSelection";
|
|
6
|
+
|
|
7
|
+
afterEach(() => {
|
|
8
|
+
document.body.innerHTML = "";
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
function selectionFor(el: HTMLElement): DomEditSelection {
|
|
12
|
+
const selector = buildStableSelector(el);
|
|
13
|
+
return {
|
|
14
|
+
element: el,
|
|
15
|
+
id: el.id || undefined,
|
|
16
|
+
hfId: el.getAttribute("data-hf-id") || undefined,
|
|
17
|
+
selector,
|
|
18
|
+
selectorIndex: getSelectorIndex(document, el, selector, "index.html", null),
|
|
19
|
+
sourceFile: "index.html",
|
|
20
|
+
dataAttributes: { start: "0", duration: "2" },
|
|
21
|
+
} as unknown as DomEditSelection;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function mountGroupSiblings(): HTMLElement[] {
|
|
25
|
+
document.body.innerHTML = `
|
|
26
|
+
<div id="scene" class="clip" data-start="0" data-duration="2">
|
|
27
|
+
<div class="group"></div>
|
|
28
|
+
<div class="group"></div>
|
|
29
|
+
<div class="group"></div>
|
|
30
|
+
</div>
|
|
31
|
+
`;
|
|
32
|
+
return Array.from(document.querySelectorAll<HTMLElement>(".group"));
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
describe("selectorFor", () => {
|
|
36
|
+
it("addresses one element for a class-only sibling", () => {
|
|
37
|
+
const groups = mountGroupSiblings();
|
|
38
|
+
const selector = selectorFor(selectionFor(groups[2]!));
|
|
39
|
+
|
|
40
|
+
// The bare ".group" both measured home off the FIRST sibling and wrote the
|
|
41
|
+
// new motion path onto all three.
|
|
42
|
+
expect(selector).not.toBe(".group");
|
|
43
|
+
expect(document.querySelectorAll(selector!)).toHaveLength(1);
|
|
44
|
+
expect(document.querySelector(selector!)).toBe(groups[2]);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it("keeps a unique id target", () => {
|
|
48
|
+
document.body.innerHTML = `<div id="hero"></div>`;
|
|
49
|
+
const el = document.querySelector<HTMLElement>("#hero")!;
|
|
50
|
+
|
|
51
|
+
expect(selectorFor(selectionFor(el))).toBe("#hero");
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it("returns null with no selection", () => {
|
|
55
|
+
expect(selectorFor(null)).toBeNull();
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it("returns null when no rung addresses one element", () => {
|
|
59
|
+
const groups = mountGroupSiblings();
|
|
60
|
+
const selection = selectionFor(groups[1]!);
|
|
61
|
+
groups[1]!.remove();
|
|
62
|
+
|
|
63
|
+
expect(selectorFor(selection)).toBeNull();
|
|
64
|
+
});
|
|
65
|
+
});
|
|
@@ -5,11 +5,21 @@
|
|
|
5
5
|
*/
|
|
6
6
|
import type { GsapAnimation } from "@hyperframes/parsers/gsap-parser";
|
|
7
7
|
import type { DomEditSelection } from "./domEditing";
|
|
8
|
+
import { writeTargetSelector } from "../../hooks/gsapShared";
|
|
8
9
|
|
|
10
|
+
/**
|
|
11
|
+
* The selector the overlay both MEASURES the element by and authors a new
|
|
12
|
+
* motion path against.
|
|
13
|
+
*
|
|
14
|
+
* Both halves need exactly one element. The selection's own selector is a bare
|
|
15
|
+
* class for an id-less element, so a `.group` sibling read its home position off
|
|
16
|
+
* the FIRST sibling (skewing the destination the click computes) and then wrote
|
|
17
|
+
* `add-motion-path` onto all five. `writeTargetSelector` is the same one-element
|
|
18
|
+
* narrowing every other new-tween writer goes through; null means no such form
|
|
19
|
+
* exists, and the overlay hides "set destination" rather than write a wrong one.
|
|
20
|
+
*/
|
|
9
21
|
export function selectorFor(sel: DomEditSelection | null): string | null {
|
|
10
|
-
|
|
11
|
-
if (sel.id) return `#${CSS.escape(sel.id)}`;
|
|
12
|
-
return sel.selector ?? null;
|
|
22
|
+
return sel ? writeTargetSelector(sel) : null;
|
|
13
23
|
}
|
|
14
24
|
|
|
15
25
|
/** The animation whose path is editable on-canvas: literal, statically resolved,
|
|
@@ -1,43 +1,85 @@
|
|
|
1
1
|
// @vitest-environment happy-dom
|
|
2
2
|
|
|
3
3
|
import React, { act } from "react";
|
|
4
|
-
import { createRoot } from "react-dom/client";
|
|
5
|
-
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
4
|
+
import { createRoot, type Root } from "react-dom/client";
|
|
5
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
6
|
+
import { __resetDesignInputThrottle } from "../../utils/designInputTracking";
|
|
6
7
|
import { ColorField } from "./propertyPanelColor";
|
|
7
8
|
|
|
9
|
+
const trackStudioEvent = vi.hoisted(() => vi.fn());
|
|
10
|
+
|
|
11
|
+
vi.mock("../../utils/studioTelemetry", () => ({
|
|
12
|
+
trackStudioEvent: (...args: unknown[]) => trackStudioEvent(...args),
|
|
13
|
+
}));
|
|
14
|
+
|
|
8
15
|
(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
|
9
16
|
|
|
17
|
+
const roots: Root[] = [];
|
|
18
|
+
|
|
19
|
+
beforeEach(() => {
|
|
20
|
+
trackStudioEvent.mockReset();
|
|
21
|
+
__resetDesignInputThrottle();
|
|
22
|
+
});
|
|
23
|
+
|
|
10
24
|
afterEach(() => {
|
|
25
|
+
for (const root of roots) act(() => root.unmount());
|
|
26
|
+
roots.length = 0;
|
|
11
27
|
document.body.innerHTML = "";
|
|
12
28
|
});
|
|
13
29
|
|
|
14
|
-
function renderColorField(
|
|
30
|
+
function renderColorField({
|
|
31
|
+
value = "#333333",
|
|
32
|
+
onPreview,
|
|
33
|
+
onCommit = vi.fn(),
|
|
34
|
+
}: {
|
|
35
|
+
value?: string;
|
|
36
|
+
onPreview?: (value: string) => void;
|
|
37
|
+
onCommit?: (value: string) => void;
|
|
38
|
+
} = {}): HTMLElement {
|
|
15
39
|
const host = document.createElement("div");
|
|
16
40
|
document.body.append(host);
|
|
17
41
|
const root = createRoot(host);
|
|
42
|
+
roots.push(root);
|
|
18
43
|
act(() => {
|
|
19
|
-
root.render(
|
|
44
|
+
root.render(
|
|
45
|
+
<ColorField flat label="Color" value={value} onPreview={onPreview} onCommit={onCommit} />,
|
|
46
|
+
);
|
|
20
47
|
});
|
|
48
|
+
return host;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function changeInput(input: HTMLInputElement, value: string): void {
|
|
52
|
+
const setter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")?.set;
|
|
53
|
+
if (!setter) throw new Error("expected native input value setter");
|
|
54
|
+
setter.call(input, value);
|
|
55
|
+
input.dispatchEvent(new Event("input", { bubbles: true }));
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function openHexInput(host: HTMLElement): HTMLInputElement {
|
|
59
|
+
const trigger = host.querySelector<HTMLButtonElement>('[data-flat-color-trigger="true"]');
|
|
60
|
+
if (!trigger) throw new Error("Color trigger was not rendered");
|
|
61
|
+
act(() => trigger.click());
|
|
62
|
+
const input = document.querySelector<HTMLInputElement>('input[spellcheck="false"]');
|
|
63
|
+
if (!input) throw new Error("Hex input was not rendered");
|
|
64
|
+
return input;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function clickOutside(): void {
|
|
68
|
+
document.body.dispatchEvent(new PointerEvent("pointerdown", { bubbles: true }));
|
|
21
69
|
}
|
|
22
70
|
|
|
23
71
|
describe("ColorField flat trigger", () => {
|
|
24
72
|
it("renders label and value inline with a small swatch, no boxed border", () => {
|
|
25
|
-
const host =
|
|
26
|
-
document.body.append(host);
|
|
27
|
-
const root = createRoot(host);
|
|
28
|
-
act(() => {
|
|
29
|
-
root.render(<ColorField flat label="Color" value="rgb(255, 176, 32)" onCommit={vi.fn()} />);
|
|
30
|
-
});
|
|
73
|
+
const host = renderColorField({ value: "rgb(255, 176, 32)" });
|
|
31
74
|
const trigger = host.querySelector<HTMLButtonElement>('[data-flat-color-trigger="true"]');
|
|
32
75
|
expect(trigger).not.toBeNull();
|
|
33
76
|
expect(trigger?.className).not.toContain("border-neutral-800");
|
|
34
77
|
expect(host.textContent).toContain("Color");
|
|
35
|
-
act(() => root.unmount());
|
|
36
78
|
});
|
|
37
79
|
|
|
38
80
|
it("persists one keyboard slider gesture on keyup", () => {
|
|
39
81
|
const onCommit = vi.fn();
|
|
40
|
-
renderColorField(onCommit);
|
|
82
|
+
renderColorField({ value: "rgb(255, 176, 32)", onCommit });
|
|
41
83
|
const trigger = document.querySelector<HTMLButtonElement>('[data-flat-color-trigger="true"]');
|
|
42
84
|
if (!trigger) throw new Error("Color trigger was not rendered");
|
|
43
85
|
act(() => {
|
|
@@ -57,3 +99,130 @@ describe("ColorField flat trigger", () => {
|
|
|
57
99
|
expect(onCommit).toHaveBeenCalledOnce();
|
|
58
100
|
});
|
|
59
101
|
});
|
|
102
|
+
|
|
103
|
+
describe("ColorField hex editing", () => {
|
|
104
|
+
it("allows #333333 to be backspaced to #3 without snapping", () => {
|
|
105
|
+
const input = openHexInput(renderColorField());
|
|
106
|
+
|
|
107
|
+
for (const value of ["#33333", "#3333", "#333", "#33", "#3"]) {
|
|
108
|
+
act(() => changeInput(input, value));
|
|
109
|
+
expect(input.value).toBe(value);
|
|
110
|
+
}
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
it("does not silently change #22CC66 to #2222CC while backspacing", () => {
|
|
114
|
+
const input = openHexInput(renderColorField({ value: "#22CC66" }));
|
|
115
|
+
|
|
116
|
+
for (const value of ["#22CC6", "#22CC", "#22C"]) {
|
|
117
|
+
act(() => changeInput(input, value));
|
|
118
|
+
expect(input.value).toBe(value);
|
|
119
|
+
expect(input.value).not.toBe("#2222CC");
|
|
120
|
+
}
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
it("commits a full replacement after selecting the existing value", () => {
|
|
124
|
+
const onCommit = vi.fn();
|
|
125
|
+
const input = openHexInput(renderColorField({ onCommit }));
|
|
126
|
+
input.focus();
|
|
127
|
+
input.select();
|
|
128
|
+
|
|
129
|
+
act(() => changeInput(input, "#12AB34"));
|
|
130
|
+
act(() => input.blur());
|
|
131
|
+
|
|
132
|
+
expect(onCommit).toHaveBeenCalledOnce();
|
|
133
|
+
expect(onCommit).toHaveBeenCalledWith("rgb(18, 171, 52)");
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
it("commits a complete pending hex on outside-click", () => {
|
|
137
|
+
const onCommit = vi.fn();
|
|
138
|
+
const input = openHexInput(renderColorField({ onCommit }));
|
|
139
|
+
|
|
140
|
+
act(() => changeInput(input, "#12AB34"));
|
|
141
|
+
act(clickOutside);
|
|
142
|
+
|
|
143
|
+
expect(onCommit).toHaveBeenCalledOnce();
|
|
144
|
+
expect(onCommit).toHaveBeenCalledWith("rgb(18, 171, 52)");
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
it("commits a 3-digit hex shorthand on outside-click", () => {
|
|
148
|
+
// parseCssColor accepts shorthand, so the gesture resolver has to as well;
|
|
149
|
+
// #F00 is ordinary designer input and used to be dropped in silence.
|
|
150
|
+
const onCommit = vi.fn();
|
|
151
|
+
const input = openHexInput(renderColorField({ onCommit }));
|
|
152
|
+
|
|
153
|
+
act(() => changeInput(input, "#F00"));
|
|
154
|
+
act(clickOutside);
|
|
155
|
+
|
|
156
|
+
expect(onCommit).toHaveBeenCalledOnce();
|
|
157
|
+
expect(onCommit).toHaveBeenCalledWith("rgb(255, 0, 0)");
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
it("does not commit an incomplete pending hex on outside-click", () => {
|
|
161
|
+
const onCommit = vi.fn();
|
|
162
|
+
const host = renderColorField({ value: "#224466", onCommit });
|
|
163
|
+
const input = openHexInput(host);
|
|
164
|
+
|
|
165
|
+
act(() => changeInput(input, "#12AB3"));
|
|
166
|
+
act(clickOutside);
|
|
167
|
+
|
|
168
|
+
expect(onCommit).not.toHaveBeenCalled();
|
|
169
|
+
// The settle also has to put the field back, or the panel re-opens showing
|
|
170
|
+
// a value the composition never took.
|
|
171
|
+
expect(openHexInput(host).value).toBe("#224466");
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
it("cancels a pending hex edit on Escape and restores the previous value", () => {
|
|
175
|
+
const onPreview = vi.fn();
|
|
176
|
+
const onCommit = vi.fn();
|
|
177
|
+
const host = renderColorField({ value: "#224466", onPreview, onCommit });
|
|
178
|
+
const input = openHexInput(host);
|
|
179
|
+
|
|
180
|
+
act(() => changeInput(input, "#12AB34"));
|
|
181
|
+
act(() => document.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape" })));
|
|
182
|
+
|
|
183
|
+
expect(onCommit).not.toHaveBeenCalled();
|
|
184
|
+
expect(onPreview).toHaveBeenLastCalledWith("rgb(34, 68, 102)");
|
|
185
|
+
expect(openHexInput(host).value).toBe("#224466");
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
it("still commits a complete hex on Tab-blur", () => {
|
|
189
|
+
const onCommit = vi.fn();
|
|
190
|
+
const input = openHexInput(renderColorField({ onCommit }));
|
|
191
|
+
input.focus();
|
|
192
|
+
|
|
193
|
+
act(() => changeInput(input, "#12AB34"));
|
|
194
|
+
act(() => input.blur());
|
|
195
|
+
|
|
196
|
+
expect(onCommit).toHaveBeenCalledOnce();
|
|
197
|
+
expect(onCommit).toHaveBeenCalledWith("rgb(18, 171, 52)");
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
it("live-previews only a hex length that parses, 3 or 6 digits", () => {
|
|
201
|
+
const onPreview = vi.fn();
|
|
202
|
+
const input = openHexInput(renderColorField({ value: "#112233", onPreview }));
|
|
203
|
+
|
|
204
|
+
// 4 and 5 digits are mid-typing, so they must stay silent.
|
|
205
|
+
act(() => changeInput(input, "#3333"));
|
|
206
|
+
act(() => changeInput(input, "#33333"));
|
|
207
|
+
expect(onPreview).not.toHaveBeenCalled();
|
|
208
|
+
|
|
209
|
+
act(() => changeInput(input, "#333333"));
|
|
210
|
+
expect(onPreview).toHaveBeenCalledOnce();
|
|
211
|
+
expect(onPreview).toHaveBeenCalledWith("rgb(51, 51, 51)");
|
|
212
|
+
|
|
213
|
+
act(() => changeInput(input, "#333"));
|
|
214
|
+
expect(onPreview).toHaveBeenCalledTimes(2);
|
|
215
|
+
expect(onPreview).toHaveBeenLastCalledWith("rgb(51, 51, 51)");
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
it("tracks exactly once per completed edit, not once per keystroke", () => {
|
|
219
|
+
const input = openHexInput(renderColorField());
|
|
220
|
+
|
|
221
|
+
for (const value of ["#", "#1", "#12", "#12A", "#12AB", "#12AB3", "#12AB34"]) {
|
|
222
|
+
act(() => changeInput(input, value));
|
|
223
|
+
}
|
|
224
|
+
act(clickOutside);
|
|
225
|
+
|
|
226
|
+
expect(trackStudioEvent).toHaveBeenCalledOnce();
|
|
227
|
+
});
|
|
228
|
+
});
|
|
@@ -184,11 +184,27 @@ export function ColorField({
|
|
|
184
184
|
const brightnessPercent = Math.round(hsv.value * 100);
|
|
185
185
|
const alphaPercent = Math.round(draftColor.alpha * 100);
|
|
186
186
|
|
|
187
|
-
const updateColorDraft = useCallback((nextValue: string) => {
|
|
187
|
+
const updateColorDraft = useCallback((nextValue: string, source: "hex" | "picker") => {
|
|
188
188
|
const nextColor = parseCssColor(nextValue);
|
|
189
189
|
if (!nextColor) return;
|
|
190
190
|
setDraftColor(nextColor);
|
|
191
|
-
setHexDraft(toHexColor(nextColor).toUpperCase());
|
|
191
|
+
if (source === "picker") setHexDraft(toHexColor(nextColor).toUpperCase());
|
|
192
|
+
}, []);
|
|
193
|
+
const resolveColorGestureValue = useCallback((nextValue: string) => {
|
|
194
|
+
const source = nextValue.startsWith("#") ? "hex" : "picker";
|
|
195
|
+
// Only a COMPLETE hex resolves, so a half-typed one neither previews nor
|
|
196
|
+
// commits. Both lengths parseCssColor accepts count as complete: gating on
|
|
197
|
+
// 6 alone silently dropped #F00 and friends, which the old onBlur committed.
|
|
198
|
+
if (source === "hex" && !/^#([0-9a-f]{3}|[0-9a-f]{6})$/i.test(nextValue)) return null;
|
|
199
|
+
const nextColor = parseCssColor(nextValue);
|
|
200
|
+
if (!nextColor) return null;
|
|
201
|
+
return {
|
|
202
|
+
source,
|
|
203
|
+
value: formatCssColor({
|
|
204
|
+
...nextColor,
|
|
205
|
+
alpha: source === "hex" ? draftColorRef.current.alpha : nextColor.alpha,
|
|
206
|
+
}),
|
|
207
|
+
} as const;
|
|
192
208
|
}, []);
|
|
193
209
|
const persistColorValue = useCallback(
|
|
194
210
|
(nextValue: string) => {
|
|
@@ -203,12 +219,17 @@ export function ColorField({
|
|
|
203
219
|
settle: settleColorGesture,
|
|
204
220
|
cancel: cancelColorGesture,
|
|
205
221
|
} = useInspectorGestureTransaction({
|
|
206
|
-
sourceValue: value,
|
|
222
|
+
sourceValue: formatCssColor(colorFromCss(value)),
|
|
207
223
|
onPreview: (nextValue) => {
|
|
208
|
-
|
|
209
|
-
|
|
224
|
+
const resolved = resolveColorGestureValue(nextValue);
|
|
225
|
+
if (!resolved) return;
|
|
226
|
+
updateColorDraft(resolved.value, resolved.source);
|
|
227
|
+
onPreview?.(resolved.value);
|
|
228
|
+
},
|
|
229
|
+
onCommit: (nextValue) => {
|
|
230
|
+
const resolved = resolveColorGestureValue(nextValue);
|
|
231
|
+
if (resolved) persistColorValue(resolved.value);
|
|
210
232
|
},
|
|
211
|
-
onCommit: persistColorValue,
|
|
212
233
|
});
|
|
213
234
|
|
|
214
235
|
useEffect(() => {
|
|
@@ -288,13 +309,11 @@ export function ColorField({
|
|
|
288
309
|
commitHsv({ saturation, value: nextValue });
|
|
289
310
|
};
|
|
290
311
|
|
|
291
|
-
const
|
|
312
|
+
const handleHexChange = (nextHex: string) => {
|
|
292
313
|
setHexDraft(nextHex);
|
|
293
314
|
const normalized = nextHex.trim().startsWith("#") ? nextHex.trim() : `#${nextHex.trim()}`;
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
const nextValue = formatCssColor({ ...parsed, alpha: draftColorRef.current.alpha });
|
|
297
|
-
updateColorDraft(nextValue);
|
|
315
|
+
beginColorGesture();
|
|
316
|
+
previewColorGesture(normalized);
|
|
298
317
|
};
|
|
299
318
|
|
|
300
319
|
const picker = open
|
|
@@ -413,21 +432,8 @@ export function ColorField({
|
|
|
413
432
|
<span className={LABEL}>Hex</span>
|
|
414
433
|
<input
|
|
415
434
|
value={hexDraft}
|
|
416
|
-
onChange={(event) =>
|
|
417
|
-
onBlur={
|
|
418
|
-
const normalized = hexDraft.trim().startsWith("#")
|
|
419
|
-
? hexDraft.trim()
|
|
420
|
-
: `#${hexDraft.trim()}`;
|
|
421
|
-
const parsed = parseCssColor(normalized);
|
|
422
|
-
if (parsed) {
|
|
423
|
-
const nextValue = formatCssColor({
|
|
424
|
-
...parsed,
|
|
425
|
-
alpha: draftColorRef.current.alpha,
|
|
426
|
-
});
|
|
427
|
-
persistColorValue(nextValue);
|
|
428
|
-
}
|
|
429
|
-
setHexDraft(toHexColor(draftColorRef.current).toUpperCase());
|
|
430
|
-
}}
|
|
435
|
+
onChange={(event) => handleHexChange(event.target.value)}
|
|
436
|
+
onBlur={settleColorGesture}
|
|
431
437
|
className={`${FIELD} h-10 w-full text-[11px] font-medium outline-none`}
|
|
432
438
|
spellCheck={false}
|
|
433
439
|
/>
|
|
@@ -5,11 +5,15 @@ import { createRoot } from "react-dom/client";
|
|
|
5
5
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
6
6
|
import { FlatMotionSection, FlatTimingRow } from "./propertyPanelFlatMotionSection";
|
|
7
7
|
import type { DomEditSelection } from "./domEditing";
|
|
8
|
+
import { usePlayerStore } from "../../player";
|
|
8
9
|
|
|
9
10
|
(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
|
10
11
|
|
|
11
12
|
afterEach(() => {
|
|
12
13
|
document.body.innerHTML = "";
|
|
14
|
+
// The store is module-global, so a test that parks a focused ease segment
|
|
15
|
+
// would otherwise leak it into every test that runs after it.
|
|
16
|
+
usePlayerStore.getState().reset();
|
|
13
17
|
});
|
|
14
18
|
|
|
15
19
|
function baseElement(overrides: Partial<DomEditSelection> = {}): DomEditSelection {
|
|
@@ -270,3 +274,68 @@ describe("FlatMotionSection", () => {
|
|
|
270
274
|
act(() => root.unmount());
|
|
271
275
|
});
|
|
272
276
|
});
|
|
277
|
+
|
|
278
|
+
it("forwards focused bulk segment easing through the flat animation card", () => {
|
|
279
|
+
const onUpdateKeyframeEase = vi.fn();
|
|
280
|
+
const onUpdateSegmentEase = vi.fn();
|
|
281
|
+
usePlayerStore.setState({
|
|
282
|
+
focusedEaseSegment: {
|
|
283
|
+
elementId: "index.html#hero",
|
|
284
|
+
animationId: "a1",
|
|
285
|
+
tweenPercentage: 50,
|
|
286
|
+
collidingAnimationTargets: [
|
|
287
|
+
{ animationId: "a1", tweenPercentage: 50 },
|
|
288
|
+
{ animationId: "a2", tweenPercentage: 75 },
|
|
289
|
+
],
|
|
290
|
+
},
|
|
291
|
+
});
|
|
292
|
+
const { host, root } = renderInto(
|
|
293
|
+
<FlatMotionSection
|
|
294
|
+
element={baseElement()}
|
|
295
|
+
animations={[
|
|
296
|
+
{
|
|
297
|
+
id: "a1",
|
|
298
|
+
method: "to",
|
|
299
|
+
position: 0,
|
|
300
|
+
duration: 1,
|
|
301
|
+
properties: { x: 100 },
|
|
302
|
+
keyframes: {
|
|
303
|
+
format: "percentage",
|
|
304
|
+
keyframes: [
|
|
305
|
+
{ percentage: 0, properties: { x: 0 } },
|
|
306
|
+
{ percentage: 50, properties: { x: 100 } },
|
|
307
|
+
],
|
|
308
|
+
},
|
|
309
|
+
} as never,
|
|
310
|
+
]}
|
|
311
|
+
showTiming={false}
|
|
312
|
+
showEffects
|
|
313
|
+
onSetAttribute={vi.fn()}
|
|
314
|
+
onAddAnimation={vi.fn()}
|
|
315
|
+
onUpdateProperty={vi.fn()}
|
|
316
|
+
onUpdateMeta={vi.fn()}
|
|
317
|
+
onDeleteAnimation={vi.fn()}
|
|
318
|
+
onAddProperty={vi.fn()}
|
|
319
|
+
onRemoveProperty={vi.fn()}
|
|
320
|
+
onUpdateKeyframeEase={onUpdateKeyframeEase}
|
|
321
|
+
onUpdateSegmentEase={onUpdateSegmentEase}
|
|
322
|
+
/>,
|
|
323
|
+
);
|
|
324
|
+
|
|
325
|
+
const dropdown = host.querySelector<HTMLButtonElement>("[data-ease-type-dropdown]");
|
|
326
|
+
expect(dropdown).not.toBeNull();
|
|
327
|
+
act(() => dropdown?.click());
|
|
328
|
+
const preset = host.querySelector<HTMLButtonElement>('[data-ease-preset-id="quad-out"]');
|
|
329
|
+
expect(preset).not.toBeNull();
|
|
330
|
+
act(() => preset?.click());
|
|
331
|
+
|
|
332
|
+
expect(onUpdateSegmentEase).toHaveBeenCalledExactlyOnceWith(
|
|
333
|
+
[
|
|
334
|
+
{ animationId: "a1", tweenPercentage: 50 },
|
|
335
|
+
{ animationId: "a2", tweenPercentage: 75 },
|
|
336
|
+
],
|
|
337
|
+
"power2.out",
|
|
338
|
+
);
|
|
339
|
+
expect(onUpdateKeyframeEase).not.toHaveBeenCalled();
|
|
340
|
+
act(() => root.unmount());
|
|
341
|
+
});
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { scopedElementKey } from "../../hooks/gsapKeyframeCacheHelpers";
|
|
1
2
|
import { useState } from "react";
|
|
2
3
|
import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
|
|
3
4
|
import { useTrackDesignInput } from "../../contexts/DesignPanelInputContext";
|
|
@@ -143,7 +144,7 @@ export function FlatMotionSection({
|
|
|
143
144
|
// the store's selectedElementId, which flips synchronously during async
|
|
144
145
|
// selection resolution), so a shared class-selector animation id can't open
|
|
145
146
|
// the wrong element's editor.
|
|
146
|
-
const renderedElementId =
|
|
147
|
+
const renderedElementId = scopedElementKey(element);
|
|
147
148
|
const focusedHere =
|
|
148
149
|
focusedEaseSegment && focusedEaseSegment.elementId === renderedElementId
|
|
149
150
|
? focusedEaseSegment
|