@hyperframes/studio 0.7.77 → 0.7.79
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/assets/{hyperframes-player-CEggaaxR.js → hyperframes-player-mFah2TZE.js} +1 -1
- package/dist/assets/{index-B1Tjjdse.js → index-Bqj3h_1a.js} +1 -1
- package/dist/assets/{index-Bp4jAYZG.js → index-DlZMDyYs.js} +194 -194
- package/dist/assets/index-gGVKuFg5.css +1 -0
- package/dist/assets/{index-B1INQNmj.js → index-hmnoiSEV.js} +1 -1
- package/dist/index.html +2 -2
- package/dist/index.js +1432 -1181
- package/dist/index.js.map +1 -1
- package/package.json +7 -7
- package/src/components/StudioLeftSidebar.tsx +10 -5
- package/src/components/StudioRightPanel.tsx +5 -4
- package/src/components/TimelineToolbar.tsx +3 -1
- package/src/components/editor/KeyframeNavigation.tsx +5 -0
- package/src/components/editor/MotionPathOverlay.tsx +4 -1
- package/src/components/editor/PropertyPanel.tsx +3 -7
- package/src/components/editor/keyframeRetime.test.ts +42 -0
- package/src/components/editor/keyframeRetime.ts +4 -1
- package/src/components/editor/motionPathSelection.test.ts +65 -0
- package/src/components/editor/motionPathSelection.ts +13 -3
- package/src/components/editor/propertyPanelColor.test.tsx +181 -12
- package/src/components/editor/propertyPanelColor.tsx +32 -26
- package/src/components/editor/propertyPanelPrimitives.tsx +3 -1
- package/src/components/nle/TimelineResizeDivider.tsx +9 -4
- package/src/components/pointerTargetSize.test.tsx +65 -0
- package/src/components/sidebar/CompositionsTab.drag.test.tsx +41 -0
- package/src/components/sidebar/CompositionsTab.tsx +70 -33
- package/src/hooks/gsapDragCommit.ts +43 -10
- package/src/hooks/gsapDragStaticSetHelpers.ts +16 -6
- package/src/hooks/gsapKeyframeCacheHelpers.test.ts +63 -0
- package/src/hooks/gsapKeyframeCacheHelpers.ts +45 -38
- package/src/hooks/gsapKeyframeCommit.ts +11 -2
- package/src/hooks/gsapResizeIntercept.ts +2 -2
- package/src/hooks/gsapRuntimeBridge.ts +3 -2
- package/src/hooks/gsapScriptCommitHelpers.ts +17 -2
- package/src/hooks/gsapShared.test.ts +144 -0
- package/src/hooks/gsapShared.ts +264 -0
- package/src/hooks/gsapShared.writeTarget.test.ts +289 -0
- package/src/hooks/keyframeCacheAstLoad.ts +5 -71
- package/src/hooks/newTweenTarget.test.ts +364 -0
- package/src/hooks/newTweenTargetHooks.test.tsx +255 -0
- package/src/hooks/timelineTrackVisibility.test.ts +61 -1
- package/src/hooks/timelineTrackVisibility.ts +11 -1
- package/src/hooks/useAnimatedPropertyCommit.ts +24 -8
- package/src/hooks/useEnableKeyframes.test.ts +44 -0
- package/src/hooks/useEnableKeyframes.ts +18 -5
- package/src/hooks/useGestureCommit.ts +21 -4
- package/src/hooks/useGsapScriptCommits.ts +3 -0
- package/src/hooks/useGsapTweenCache.test.ts +11 -3
- package/src/hooks/useGsapTweenCache.ts +4 -11
- package/src/player/components/LayerDisclosureRow.tsx +4 -1
- package/src/player/components/Player.test.ts +143 -12
- package/src/player/components/Player.tsx +84 -43
- package/src/player/components/ShortcutsPanel.test.tsx +158 -0
- package/src/player/components/ShortcutsPanel.tsx +11 -15
- package/src/player/components/Timeline.test.ts +3 -1
- package/src/player/components/TimelineCanvas.tsx +2 -1
- package/src/player/components/TimelineClipDiamonds.test.tsx +17 -2
- package/src/player/components/TimelineClipDiamonds.tsx +11 -1
- package/src/player/components/TimelineDiamondConnectors.tsx +14 -1
- package/src/player/components/TimelineLanes.test.tsx +281 -0
- package/src/player/components/TimelineLanes.tsx +43 -97
- package/src/player/components/TimelinePropertyLanes.test.tsx +217 -0
- package/src/player/components/TimelinePropertyLanes.tsx +80 -11
- package/src/player/components/TimelineTrackHeader.test.tsx +75 -5
- package/src/player/components/TimelineTrackHeader.tsx +52 -22
- package/src/player/components/timelineLaneProps.ts +86 -0
- package/src/player/components/timelineTrackDisplay.test.ts +35 -0
- package/src/player/components/timelineTrackDisplay.ts +38 -0
- package/src/player/components/useTimelineRangeSelection.ts +5 -0
- package/src/player/components/useTimelineRangeSelectionScrub.test.tsx +127 -0
- package/src/player/components/useTimelineTrackLayout.test.ts +35 -0
- package/src/player/components/useTimelineTrackLayout.ts +4 -4
- package/src/player/hooks/useExpandedTimelineElements.test.ts +151 -2
- package/src/player/hooks/useExpandedTimelineElements.ts +46 -7
- package/src/telemetry/client.test.ts +12 -3
- package/src/telemetry/client.ts +3 -2
- package/dist/assets/index-CBAfprvx.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.79",
|
|
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/core": "0.7.
|
|
50
|
-
"@hyperframes/
|
|
51
|
-
"@hyperframes/
|
|
52
|
-
"@hyperframes/
|
|
53
|
-
"@hyperframes/
|
|
49
|
+
"@hyperframes/core": "0.7.79",
|
|
50
|
+
"@hyperframes/player": "0.7.79",
|
|
51
|
+
"@hyperframes/sdk": "0.7.79",
|
|
52
|
+
"@hyperframes/studio-server": "0.7.79",
|
|
53
|
+
"@hyperframes/parsers": "0.7.79"
|
|
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.79"
|
|
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>
|
|
@@ -446,8 +446,7 @@ export function StudioRightPanel({
|
|
|
446
446
|
|
|
447
447
|
return (
|
|
448
448
|
<>
|
|
449
|
-
{/* Vertical resize divider: 3px visible seam,
|
|
450
|
-
the absolutely-positioned inner hit area. */}
|
|
449
|
+
{/* Vertical resize divider: 3px visible seam, 13px hit zone via the inner div. */}
|
|
451
450
|
<div
|
|
452
451
|
role="separator"
|
|
453
452
|
aria-label="Resize inspector panel"
|
|
@@ -467,8 +466,10 @@ export function StudioRightPanel({
|
|
|
467
466
|
adjustPanelWidth("right", delta);
|
|
468
467
|
}}
|
|
469
468
|
>
|
|
470
|
-
{/*
|
|
471
|
-
|
|
469
|
+
{/* Asymmetric hit zone: 8px into the preview's p-2 gutter (the only dead
|
|
470
|
+
space), the 3px seam, 2px into the card. Stops short of the 24px WCAG
|
|
471
|
+
2.5.8 target because the next pixel each way is live. */}
|
|
472
|
+
<div className="absolute inset-y-0 -left-[8px] w-[13px]" />
|
|
472
473
|
{/* Visible hairline */}
|
|
473
474
|
<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
475
|
</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
|
|
@@ -167,6 +167,11 @@ export const KeyframeNavigation = memo(function KeyframeNavigation({
|
|
|
167
167
|
};
|
|
168
168
|
|
|
169
169
|
return (
|
|
170
|
+
// The two 12x20 steppers sit gap-0.5 apart with a 9px diamond between them,
|
|
171
|
+
// so they stay below the 24px WCAG 2.2 (2.5.8) minimum under that criterion's
|
|
172
|
+
// own spacing/inline exception: centred 24px targets here would overlap each
|
|
173
|
+
// other AND the diamond, and one control swallowing its neighbour's clicks is
|
|
174
|
+
// a worse 2.5.8 failure than a small target. Do not "fix" these to 24.
|
|
170
175
|
<div className="flex h-5 items-center gap-0.5">
|
|
171
176
|
<button
|
|
172
177
|
type="button"
|
|
@@ -132,7 +132,10 @@ export const MotionPathOverlay = memo(function MotionPathOverlay({
|
|
|
132
132
|
const createMode = geometryResolved && !geometry && Boolean(selection?.element) && !isPlaying;
|
|
133
133
|
const createSelector = createMode ? selectorFor(selection) : null;
|
|
134
134
|
const compW = compositionSize?.width ?? null;
|
|
135
|
-
|
|
135
|
+
// No one-element selector means the path could only be authored onto the
|
|
136
|
+
// element's class siblings, so the toolbar toggle stays hidden instead of
|
|
137
|
+
// arming a press that the effect below would silently drop.
|
|
138
|
+
const canCreate = createMode && !!createSelector && hasMotionPathPlugin(iframeRef.current);
|
|
136
139
|
|
|
137
140
|
// Publish whether the selected element can take a path so the preview toolbar
|
|
138
141
|
// shows its "set destination" toggle. Drops to false when this overlay unmounts
|
|
@@ -16,7 +16,7 @@ import {
|
|
|
16
16
|
} from "./propertyPanelHelpers";
|
|
17
17
|
import { MetricField, Section } from "./propertyPanelPrimitives";
|
|
18
18
|
import { createTransformCommitHandlers } from "./propertyPanelTransformCommit";
|
|
19
|
-
import {
|
|
19
|
+
import { resolveAnimIdForProperty } from "../../player/components/TimelinePropertyLanes";
|
|
20
20
|
import { resolveEditingSections } from "@hyperframes/core/editing";
|
|
21
21
|
import { MediaSection } from "./propertyPanelMediaSection";
|
|
22
22
|
import { ColorGradingSection } from "./propertyPanelColorGradingSection";
|
|
@@ -241,12 +241,8 @@ export const PropertyPanel = memo(function PropertyPanel(props: PropertyPanelPro
|
|
|
241
241
|
const navKeyframes = cacheEntry?.keyframes ?? gsapKeyframes;
|
|
242
242
|
const seekFromKfPct = (pct: number) => onSeekToTime?.(elStart + (pct / 100) * elDuration);
|
|
243
243
|
|
|
244
|
-
const animIdForProp = (prop: string): string =>
|
|
245
|
-
|
|
246
|
-
const groupAnim = gsapAnimations?.find((a) => a.propertyGroup === group);
|
|
247
|
-
if (groupAnim) return groupAnim.id;
|
|
248
|
-
return gsapAnimId ?? "";
|
|
249
|
-
};
|
|
244
|
+
const animIdForProp = (prop: string): string =>
|
|
245
|
+
resolveAnimIdForProperty(prop, gsapAnimations, gsapAnimId);
|
|
250
246
|
|
|
251
247
|
const displayX = gsapRuntimeValues?.x ?? manualOffset.x;
|
|
252
248
|
const displayY = gsapRuntimeValues?.y ?? manualOffset.y;
|
|
@@ -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
|
/>
|
|
@@ -189,7 +189,9 @@ export function SliderControl({
|
|
|
189
189
|
onMouseUp={() => commitDraft(draft)}
|
|
190
190
|
onTouchEnd={() => commitDraft(draft)}
|
|
191
191
|
onBlur={() => commitDraft(draft)}
|
|
192
|
-
|
|
192
|
+
// h-6 is the 24x24 WCAG 2.2 (2.5.8) target: the visible track stays 2px
|
|
193
|
+
// and the thumb 10px, only the pointer box grows.
|
|
194
|
+
className="h-6 min-w-0 w-full cursor-pointer appearance-none bg-transparent disabled:cursor-not-allowed disabled:opacity-50 [&::-webkit-slider-runnable-track]:h-[2px] [&::-webkit-slider-runnable-track]:rounded-full [&::-webkit-slider-runnable-track]:bg-panel-border [&::-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_#0C0C0E,0_1px_3px_rgba(0,0,0,0.5)] [&::-webkit-slider-thumb]:cursor-grab [&::-webkit-slider-thumb:active]:cursor-grabbing"
|
|
193
195
|
/>
|
|
194
196
|
<div className="min-w-[44px] rounded-md bg-panel-input px-2 py-1.5 text-right text-[11px] font-medium text-panel-text-1 tabular-nums">
|
|
195
197
|
{formatDisplayValue?.(draft) ?? displayValue}
|