@hyperframes/studio 0.7.60 → 0.7.62
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-CWpmBjzv.js +459 -0
- package/dist/assets/{index-D6etaey-.js → index-BJzSiIBj.js} +1 -1
- package/dist/assets/index-Ceyz8Qt2.css +1 -0
- package/dist/assets/{index-Dh_WhagG.js → index-DJQPB78e.js} +1 -1
- package/dist/assets/index-vDRVQtTK.js +426 -0
- package/dist/index.d.ts +12 -1
- package/dist/index.html +2 -2
- package/dist/index.js +4392 -3314
- package/dist/index.js.map +1 -1
- package/package.json +7 -7
- package/src/App.tsx +15 -15
- package/src/components/DesignPanelPromoteProvider.tsx +27 -1
- package/src/components/EditorShell.tsx +12 -0
- package/src/components/StudioHeader.tsx +2 -3
- package/src/components/StudioLeftSidebar.tsx +3 -0
- package/src/components/StudioRightPanel.tsx +34 -26
- package/src/components/editor/DomEditSelectionChrome.test.tsx +51 -0
- package/src/components/editor/PromotableControl.tsx +4 -2
- package/src/components/editor/PropertyPanel.tsx +151 -149
- package/src/components/editor/PropertyPanelFlat.tsx +50 -42
- package/src/components/editor/compositionReliabilityFixture.integration.test.ts +105 -0
- package/src/components/editor/domEditing.test.ts +34 -0
- package/src/components/editor/manualEditingAvailability.test.ts +6 -6
- package/src/components/editor/manualEditingAvailability.ts +5 -3
- package/src/components/editor/propertyPanelColor.test.tsx +31 -0
- package/src/components/editor/propertyPanelColor.tsx +150 -28
- package/src/components/editor/propertyPanelCommitField.tsx +178 -0
- package/src/components/editor/propertyPanelFlatPrimitives.test.tsx +44 -0
- package/src/components/editor/propertyPanelFlatPrimitives.tsx +4 -0
- package/src/components/editor/propertyPanelFlatStyleHelpers.ts +0 -13
- package/src/components/editor/propertyPanelFlatStyleSections.test.tsx +26 -10
- package/src/components/editor/propertyPanelFlatStyleSections.tsx +21 -32
- package/src/components/editor/propertyPanelFlatTextSection.test.tsx +47 -4
- package/src/components/editor/propertyPanelFlatTextSection.tsx +10 -2
- package/src/components/editor/propertyPanelInputCoverage.test.tsx +18 -1
- package/src/components/editor/propertyPanelPrimitives.tsx +3 -92
- package/src/components/editor/propertyPanelSections.tsx +2 -2
- package/src/components/editor/propertyPanelTypes.ts +2 -0
- package/src/components/editor/useInspectorGestureTransaction.test.tsx +37 -0
- package/src/components/editor/useInspectorGestureTransaction.ts +60 -0
- package/src/components/nle/TimelinePane.tsx +6 -0
- package/src/components/sidebar/CompositionsTab.drag.test.tsx +87 -0
- package/src/components/sidebar/CompositionsTab.tsx +45 -2
- package/src/components/sidebar/LeftSidebar.tsx +3 -0
- package/src/components/storyboard/AgentChatMessageButton.test.tsx +47 -0
- package/src/components/storyboard/AgentChatMessageButton.tsx +45 -0
- package/src/components/storyboard/StoryboardFrameFocus.tsx +189 -66
- package/src/components/storyboard/StoryboardLoaded.tsx +121 -23
- package/src/components/storyboard/StoryboardReviewGuide.tsx +300 -0
- package/src/components/storyboard/StoryboardViewModeGuard.test.tsx +170 -0
- package/src/components/storyboard/storyboardReviewStage.test.ts +40 -0
- package/src/components/storyboard/storyboardReviewStage.ts +45 -0
- package/src/components/storyboard/useFrameComments.ts +22 -6
- package/src/contexts/StudioContext.tsx +4 -0
- package/src/contexts/ViewModeContext.tsx +60 -7
- package/src/hooks/timelineEditingHelpers.ts +10 -4
- package/src/hooks/useAddAssetAtPlayhead.test.ts +44 -0
- package/src/hooks/useAddAssetAtPlayhead.ts +21 -0
- package/src/hooks/usePreviewPersistence.ts +7 -1
- package/src/hooks/useRazorSplit.history.test.tsx +21 -28
- package/src/hooks/useRazorSplit.test.tsx +20 -9
- package/src/hooks/useRazorSplit.testHelpers.ts +34 -22
- package/src/hooks/useRazorSplit.ts +78 -350
- package/src/hooks/useSlideshowTabState.test.ts +96 -0
- package/src/hooks/useSlideshowTabState.ts +61 -0
- package/src/hooks/useTimelineAddAtPlayhead.ts +15 -0
- package/src/hooks/useTimelineAssetDropOps.ts +52 -2
- package/src/hooks/useTimelineEditing.ts +19 -17
- package/src/hooks/useTimelineGroupEditing.ts +6 -7
- package/src/player/components/Timeline.tsx +12 -1
- package/src/player/components/timelineAuthoredMoveTarget.ts +77 -0
- package/src/player/components/timelineAuthoredTrack.ts +29 -0
- package/src/player/components/timelineCallbacks.ts +4 -0
- package/src/player/components/timelineClipDragCommit.test.ts +176 -0
- package/src/player/components/timelineClipDragCommit.ts +77 -105
- package/src/player/components/timelineClipDragPreview.test.ts +33 -1
- package/src/player/components/timelineClipDragPreview.ts +2 -1
- package/src/player/components/timelineCollision.test.ts +18 -0
- package/src/player/components/timelineCollision.ts +12 -9
- package/src/player/components/timelineDragDrop.ts +28 -7
- package/src/player/components/timelineEditCapabilities.ts +4 -1
- package/src/player/components/timelineGroupEditing.test.ts +10 -0
- package/src/player/components/timelineGroupEditing.ts +4 -3
- package/src/player/components/timelineLaneMoveRefresh.ts +14 -0
- package/src/player/components/timelineLayout.ts +3 -1
- package/src/player/components/timelineZMirror.ts +1 -1
- package/src/player/components/useTimelineClipDrag.ts +5 -0
- package/src/player/components/useTimelineEditPinning.ts +12 -0
- package/src/player/hooks/useExpandedTimelineElements.test.ts +42 -0
- package/src/player/hooks/useExpandedTimelineElements.ts +3 -0
- package/src/player/lib/playbackTypes.ts +9 -0
- package/src/player/lib/runtimeProtocol.test.ts +6 -1
- package/src/player/lib/timelineDOM.test.ts +58 -0
- package/src/player/lib/timelineDOM.ts +26 -12
- package/src/player/lib/timelineElementHelpers.ts +21 -11
- package/src/player/lib/timelineIframeHelpers.ts +153 -107
- package/src/player/store/playerStore.ts +2 -1
- package/src/utils/razorSplitTransaction.test.ts +175 -0
- package/src/utils/razorSplitTransaction.ts +200 -0
- package/src/utils/studioPreviewHelpers.test.ts +27 -0
- package/src/utils/timelineCompositionDrop.test.ts +12 -0
- package/src/utils/timelineCompositionDrop.ts +16 -0
- package/src/utils/timelineCompositionInsert.test.ts +142 -0
- package/src/utils/timelineCompositionInsert.ts +90 -0
- package/src/utils/timelineElementSplit.test.ts +14 -2
- package/src/utils/timelineElementSplit.ts +5 -1
- package/dist/assets/hyperframes-player-3XTTaVNf.js +0 -459
- package/dist/assets/index-DXbu6IPT.css +0 -1
- package/dist/assets/index-cH6NfVV_.js +0 -426
- package/src/components/editor/propertyPanelFlatStyleHelpers.test.ts +0 -23
|
@@ -98,12 +98,14 @@ export const STUDIO_SDK_RESOLVER_SHADOW_ENABLED = resolveStudioBooleanEnvFlag(
|
|
|
98
98
|
);
|
|
99
99
|
|
|
100
100
|
// Studio inspector redesign ("Ledger, flat" — design_handoff_studio_inspector):
|
|
101
|
-
// flat identity header/footer/groups
|
|
102
|
-
//
|
|
101
|
+
// flat identity header/footer/groups. Default true as of v0.7.59+ bug-fix pass
|
|
102
|
+
// (right-aligned values, Stroke select-only, promote-badge overlap, Layout/
|
|
103
|
+
// Style section gating); disable via VITE_STUDIO_FLAT_INSPECTOR_ENABLED=false
|
|
104
|
+
// to fall back to the legacy panel.
|
|
103
105
|
export const STUDIO_FLAT_INSPECTOR_ENABLED = resolveStudioBooleanEnvFlag(
|
|
104
106
|
env,
|
|
105
107
|
["VITE_STUDIO_ENABLE_FLAT_INSPECTOR", "VITE_STUDIO_FLAT_INSPECTOR_ENABLED"],
|
|
106
|
-
|
|
108
|
+
true,
|
|
107
109
|
);
|
|
108
110
|
|
|
109
111
|
export const STUDIO_MANUAL_EDITING_DISABLED_TITLE = "Manual editing is temporarily disabled";
|
|
@@ -11,6 +11,15 @@ afterEach(() => {
|
|
|
11
11
|
document.body.innerHTML = "";
|
|
12
12
|
});
|
|
13
13
|
|
|
14
|
+
function renderColorField(onCommit: (value: string) => void): void {
|
|
15
|
+
const host = document.createElement("div");
|
|
16
|
+
document.body.append(host);
|
|
17
|
+
const root = createRoot(host);
|
|
18
|
+
act(() => {
|
|
19
|
+
root.render(<ColorField flat label="Color" value="rgb(255, 176, 32)" onCommit={onCommit} />);
|
|
20
|
+
});
|
|
21
|
+
}
|
|
22
|
+
|
|
14
23
|
describe("ColorField flat trigger", () => {
|
|
15
24
|
it("renders label and value inline with a small swatch, no boxed border", () => {
|
|
16
25
|
const host = document.createElement("div");
|
|
@@ -25,4 +34,26 @@ describe("ColorField flat trigger", () => {
|
|
|
25
34
|
expect(host.textContent).toContain("Color");
|
|
26
35
|
act(() => root.unmount());
|
|
27
36
|
});
|
|
37
|
+
|
|
38
|
+
it("persists one keyboard slider gesture on keyup", () => {
|
|
39
|
+
const onCommit = vi.fn();
|
|
40
|
+
renderColorField(onCommit);
|
|
41
|
+
const trigger = document.querySelector<HTMLButtonElement>('[data-flat-color-trigger="true"]');
|
|
42
|
+
if (!trigger) throw new Error("Color trigger was not rendered");
|
|
43
|
+
act(() => {
|
|
44
|
+
trigger.click();
|
|
45
|
+
});
|
|
46
|
+
const hue = document.querySelector<HTMLElement>('[role="slider"][aria-label="Hue"]');
|
|
47
|
+
if (!hue) throw new Error("Hue slider was not rendered");
|
|
48
|
+
|
|
49
|
+
act(() => {
|
|
50
|
+
hue.dispatchEvent(new KeyboardEvent("keydown", { bubbles: true, key: "ArrowRight" }));
|
|
51
|
+
});
|
|
52
|
+
expect(onCommit).not.toHaveBeenCalled();
|
|
53
|
+
act(() => {
|
|
54
|
+
hue.dispatchEvent(new KeyboardEvent("keyup", { bubbles: true, key: "ArrowRight" }));
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
expect(onCommit).toHaveBeenCalledOnce();
|
|
58
|
+
});
|
|
28
59
|
});
|
|
@@ -12,6 +12,7 @@ import {
|
|
|
12
12
|
import { resolveFloatingPanelPosition, type FloatingPosition } from "./floatingPanel";
|
|
13
13
|
import { colorFromCss, FIELD, LABEL } from "./propertyPanelHelpers";
|
|
14
14
|
import { useTrackDesignInput } from "../../contexts/DesignPanelInputContext";
|
|
15
|
+
import { useInspectorGestureTransaction } from "./useInspectorGestureTransaction";
|
|
15
16
|
|
|
16
17
|
const COLOR_PICKER_SIZE = { width: 292, height: 386 };
|
|
17
18
|
|
|
@@ -29,7 +30,10 @@ function ColorSlider({
|
|
|
29
30
|
background,
|
|
30
31
|
thumbColor,
|
|
31
32
|
disabled,
|
|
32
|
-
|
|
33
|
+
onPreview,
|
|
34
|
+
onInteractionStart,
|
|
35
|
+
onInteractionEnd,
|
|
36
|
+
onInteractionCancel,
|
|
33
37
|
}: {
|
|
34
38
|
label: string;
|
|
35
39
|
value: number;
|
|
@@ -40,21 +44,24 @@ function ColorSlider({
|
|
|
40
44
|
background: string;
|
|
41
45
|
thumbColor: string;
|
|
42
46
|
disabled?: boolean;
|
|
43
|
-
|
|
47
|
+
onPreview: (nextValue: number) => void;
|
|
48
|
+
onInteractionStart: () => void;
|
|
49
|
+
onInteractionEnd: () => void;
|
|
50
|
+
onInteractionCancel: () => void;
|
|
44
51
|
}) {
|
|
45
52
|
const trackRef = useRef<HTMLDivElement | null>(null);
|
|
46
53
|
const percent = ((value - min) / (max - min)) * 100;
|
|
47
54
|
|
|
48
|
-
const
|
|
55
|
+
const previewFromClientX = (clientX: number) => {
|
|
49
56
|
const rect = trackRef.current?.getBoundingClientRect();
|
|
50
57
|
if (!rect || rect.width <= 0) return;
|
|
51
58
|
const rawValue = min + ((clientX - rect.left) / rect.width) * (max - min);
|
|
52
59
|
const stepped = Math.round(rawValue / step) * step;
|
|
53
|
-
|
|
60
|
+
onPreview(Math.max(min, Math.min(max, stepped)));
|
|
54
61
|
};
|
|
55
62
|
|
|
56
|
-
const
|
|
57
|
-
|
|
63
|
+
const previewKeyboardValue = (nextValue: number) => {
|
|
64
|
+
onPreview(Math.max(min, Math.min(max, nextValue)));
|
|
58
65
|
};
|
|
59
66
|
|
|
60
67
|
return (
|
|
@@ -78,32 +85,52 @@ function ColorSlider({
|
|
|
78
85
|
style={{ background }}
|
|
79
86
|
onPointerDown={(event) => {
|
|
80
87
|
if (disabled) return;
|
|
88
|
+
onInteractionStart();
|
|
81
89
|
event.currentTarget.setPointerCapture(event.pointerId);
|
|
82
|
-
|
|
90
|
+
previewFromClientX(event.clientX);
|
|
83
91
|
}}
|
|
84
92
|
onPointerUp={(event) => {
|
|
93
|
+
onInteractionEnd();
|
|
85
94
|
event.currentTarget.blur();
|
|
86
95
|
}}
|
|
96
|
+
onPointerCancel={onInteractionCancel}
|
|
87
97
|
onPointerMove={(event) => {
|
|
88
98
|
if (disabled || event.buttons !== 1) return;
|
|
89
|
-
|
|
99
|
+
previewFromClientX(event.clientX);
|
|
90
100
|
}}
|
|
91
101
|
onKeyDown={(event) => {
|
|
92
102
|
if (disabled) return;
|
|
103
|
+
if (event.key === "Escape") {
|
|
104
|
+
event.preventDefault();
|
|
105
|
+
onInteractionCancel();
|
|
106
|
+
return;
|
|
107
|
+
}
|
|
93
108
|
if (event.key === "ArrowRight" || event.key === "ArrowUp") {
|
|
94
109
|
event.preventDefault();
|
|
95
|
-
|
|
110
|
+
onInteractionStart();
|
|
111
|
+
previewKeyboardValue(value + step);
|
|
96
112
|
} else if (event.key === "ArrowLeft" || event.key === "ArrowDown") {
|
|
97
113
|
event.preventDefault();
|
|
98
|
-
|
|
114
|
+
onInteractionStart();
|
|
115
|
+
previewKeyboardValue(value - step);
|
|
99
116
|
} else if (event.key === "Home") {
|
|
100
117
|
event.preventDefault();
|
|
101
|
-
|
|
118
|
+
onInteractionStart();
|
|
119
|
+
previewKeyboardValue(min);
|
|
102
120
|
} else if (event.key === "End") {
|
|
103
121
|
event.preventDefault();
|
|
104
|
-
|
|
122
|
+
onInteractionStart();
|
|
123
|
+
previewKeyboardValue(max);
|
|
124
|
+
}
|
|
125
|
+
}}
|
|
126
|
+
onKeyUp={(event) => {
|
|
127
|
+
if (
|
|
128
|
+
["ArrowRight", "ArrowUp", "ArrowLeft", "ArrowDown", "Home", "End"].includes(event.key)
|
|
129
|
+
) {
|
|
130
|
+
onInteractionEnd();
|
|
105
131
|
}
|
|
106
132
|
}}
|
|
133
|
+
onBlur={onInteractionEnd}
|
|
107
134
|
>
|
|
108
135
|
<div
|
|
109
136
|
className="pointer-events-none absolute top-1/2 h-6 w-6 -translate-x-1/2 -translate-y-1/2 rounded-full border-2 border-white shadow-[0_0_0_1px_rgba(0,0,0,0.85),0_6px_14px_rgba(0,0,0,0.5)]"
|
|
@@ -122,13 +149,19 @@ export function ColorField({
|
|
|
122
149
|
label,
|
|
123
150
|
value,
|
|
124
151
|
disabled,
|
|
152
|
+
onReset,
|
|
125
153
|
flat,
|
|
154
|
+
mixed,
|
|
155
|
+
onPreview,
|
|
126
156
|
onCommit,
|
|
127
157
|
}: {
|
|
128
158
|
label: string;
|
|
129
159
|
value: string;
|
|
130
160
|
disabled?: boolean;
|
|
161
|
+
onReset?: () => void;
|
|
131
162
|
flat?: boolean;
|
|
163
|
+
mixed?: boolean;
|
|
164
|
+
onPreview?: (nextValue: string) => void;
|
|
132
165
|
onCommit: (nextValue: string) => void;
|
|
133
166
|
}) {
|
|
134
167
|
const track = useTrackDesignInput();
|
|
@@ -137,6 +170,8 @@ export function ColorField({
|
|
|
137
170
|
const [open, setOpen] = useState(false);
|
|
138
171
|
const [panelPosition, setPanelPosition] = useState<FloatingPosition | null>(null);
|
|
139
172
|
const [draftColor, setDraftColor] = useState<ParsedColor>(() => colorFromCss(value));
|
|
173
|
+
const draftColorRef = useRef(draftColor);
|
|
174
|
+
draftColorRef.current = draftColor;
|
|
140
175
|
const [hexDraft, setHexDraft] = useState(() => toHexColor(colorFromCss(value)).toUpperCase());
|
|
141
176
|
const hsv = rgbToHsv(draftColor);
|
|
142
177
|
const hueColor = formatCssColor({
|
|
@@ -149,6 +184,33 @@ export function ColorField({
|
|
|
149
184
|
const brightnessPercent = Math.round(hsv.value * 100);
|
|
150
185
|
const alphaPercent = Math.round(draftColor.alpha * 100);
|
|
151
186
|
|
|
187
|
+
const updateColorDraft = useCallback((nextValue: string) => {
|
|
188
|
+
const nextColor = parseCssColor(nextValue);
|
|
189
|
+
if (!nextColor) return;
|
|
190
|
+
setDraftColor(nextColor);
|
|
191
|
+
setHexDraft(toHexColor(nextColor).toUpperCase());
|
|
192
|
+
}, []);
|
|
193
|
+
const persistColorValue = useCallback(
|
|
194
|
+
(nextValue: string) => {
|
|
195
|
+
if (nextValue !== value) track("color", label);
|
|
196
|
+
onCommit(nextValue);
|
|
197
|
+
},
|
|
198
|
+
[label, onCommit, track, value],
|
|
199
|
+
);
|
|
200
|
+
const {
|
|
201
|
+
begin: beginColorGesture,
|
|
202
|
+
preview: previewColorGesture,
|
|
203
|
+
settle: settleColorGesture,
|
|
204
|
+
cancel: cancelColorGesture,
|
|
205
|
+
} = useInspectorGestureTransaction({
|
|
206
|
+
sourceValue: value,
|
|
207
|
+
onPreview: (nextValue) => {
|
|
208
|
+
updateColorDraft(nextValue);
|
|
209
|
+
onPreview?.(nextValue);
|
|
210
|
+
},
|
|
211
|
+
onCommit: persistColorValue,
|
|
212
|
+
});
|
|
213
|
+
|
|
152
214
|
useEffect(() => {
|
|
153
215
|
const nextColor = colorFromCss(value);
|
|
154
216
|
setDraftColor(nextColor);
|
|
@@ -189,10 +251,14 @@ export function ColorField({
|
|
|
189
251
|
const target = event.target as Node | null;
|
|
190
252
|
if (!target) return;
|
|
191
253
|
if (panelRef.current?.contains(target) || buttonRef.current?.contains(target)) return;
|
|
254
|
+
settleColorGesture();
|
|
192
255
|
setOpen(false);
|
|
193
256
|
};
|
|
194
257
|
const handleKeyDown = (event: KeyboardEvent) => {
|
|
195
|
-
if (event.key === "Escape")
|
|
258
|
+
if (event.key === "Escape") {
|
|
259
|
+
cancelColorGesture();
|
|
260
|
+
setOpen(false);
|
|
261
|
+
}
|
|
196
262
|
};
|
|
197
263
|
document.addEventListener("pointerdown", handlePointerDown);
|
|
198
264
|
document.addEventListener("keydown", handleKeyDown);
|
|
@@ -200,14 +266,10 @@ export function ColorField({
|
|
|
200
266
|
document.removeEventListener("pointerdown", handlePointerDown);
|
|
201
267
|
document.removeEventListener("keydown", handleKeyDown);
|
|
202
268
|
};
|
|
203
|
-
}, [open]);
|
|
269
|
+
}, [cancelColorGesture, open, settleColorGesture]);
|
|
204
270
|
|
|
205
|
-
const
|
|
206
|
-
|
|
207
|
-
setHexDraft(toHexColor(nextColor).toUpperCase());
|
|
208
|
-
const nextValue = formatCssColor(nextColor);
|
|
209
|
-
if (nextValue !== value) track("color", label);
|
|
210
|
-
onCommit(nextValue);
|
|
271
|
+
const previewColor = (nextColor: ParsedColor) => {
|
|
272
|
+
previewColorGesture(formatCssColor(nextColor));
|
|
211
273
|
};
|
|
212
274
|
|
|
213
275
|
const commitHsv = (nextHsv: { hue?: number; saturation?: number; value?: number }) => {
|
|
@@ -216,7 +278,7 @@ export function ColorField({
|
|
|
216
278
|
saturation: nextHsv.saturation ?? hsv.saturation,
|
|
217
279
|
value: nextHsv.value ?? hsv.value,
|
|
218
280
|
});
|
|
219
|
-
|
|
281
|
+
previewColor({ ...rgb, alpha: draftColorRef.current.alpha });
|
|
220
282
|
};
|
|
221
283
|
|
|
222
284
|
const updateSaturationValue = (clientX: number, clientY: number, target: HTMLDivElement) => {
|
|
@@ -231,7 +293,8 @@ export function ColorField({
|
|
|
231
293
|
const normalized = nextHex.trim().startsWith("#") ? nextHex.trim() : `#${nextHex.trim()}`;
|
|
232
294
|
const parsed = parseCssColor(normalized);
|
|
233
295
|
if (!parsed) return;
|
|
234
|
-
|
|
296
|
+
const nextValue = formatCssColor({ ...parsed, alpha: draftColorRef.current.alpha });
|
|
297
|
+
updateColorDraft(nextValue);
|
|
235
298
|
};
|
|
236
299
|
|
|
237
300
|
const picker = open
|
|
@@ -251,7 +314,10 @@ export function ColorField({
|
|
|
251
314
|
</div>
|
|
252
315
|
<button
|
|
253
316
|
type="button"
|
|
254
|
-
onClick={() =>
|
|
317
|
+
onClick={() => {
|
|
318
|
+
settleColorGesture();
|
|
319
|
+
setOpen(false);
|
|
320
|
+
}}
|
|
255
321
|
className="flex h-7 w-7 items-center justify-center rounded-lg text-neutral-500 transition-colors hover:bg-neutral-900 hover:text-neutral-200"
|
|
256
322
|
aria-label="Close color picker"
|
|
257
323
|
>
|
|
@@ -263,6 +329,7 @@ export function ColorField({
|
|
|
263
329
|
className="relative h-36 cursor-crosshair overflow-hidden rounded-xl border border-neutral-700 shadow-[inset_0_0_0_1px_rgba(255,255,255,0.06)]"
|
|
264
330
|
style={{ backgroundColor: hueColor }}
|
|
265
331
|
onPointerDown={(event) => {
|
|
332
|
+
beginColorGesture();
|
|
266
333
|
event.currentTarget.setPointerCapture(event.pointerId);
|
|
267
334
|
updateSaturationValue(event.clientX, event.clientY, event.currentTarget);
|
|
268
335
|
}}
|
|
@@ -270,6 +337,8 @@ export function ColorField({
|
|
|
270
337
|
if (event.buttons !== 1) return;
|
|
271
338
|
updateSaturationValue(event.clientX, event.clientY, event.currentTarget);
|
|
272
339
|
}}
|
|
340
|
+
onPointerUp={settleColorGesture}
|
|
341
|
+
onPointerCancel={cancelColorGesture}
|
|
273
342
|
>
|
|
274
343
|
<div className="absolute inset-0 bg-gradient-to-r from-white to-transparent" />
|
|
275
344
|
<div className="absolute inset-0 bg-gradient-to-t from-black to-transparent" />
|
|
@@ -316,7 +385,10 @@ export function ColorField({
|
|
|
316
385
|
background="linear-gradient(90deg, #f00, #ff0, #0f0, #0ff, #00f, #f0f, #f00)"
|
|
317
386
|
thumbColor={hueColor}
|
|
318
387
|
disabled={disabled}
|
|
319
|
-
|
|
388
|
+
onInteractionStart={beginColorGesture}
|
|
389
|
+
onPreview={(nextHue) => commitHsv({ hue: nextHue })}
|
|
390
|
+
onInteractionEnd={settleColorGesture}
|
|
391
|
+
onInteractionCancel={cancelColorGesture}
|
|
320
392
|
/>
|
|
321
393
|
|
|
322
394
|
<ColorSlider
|
|
@@ -329,7 +401,12 @@ export function ColorField({
|
|
|
329
401
|
background={`linear-gradient(90deg, transparent, ${opaqueColor})`}
|
|
330
402
|
thumbColor={currentColor}
|
|
331
403
|
disabled={disabled}
|
|
332
|
-
|
|
404
|
+
onInteractionStart={beginColorGesture}
|
|
405
|
+
onPreview={(nextAlpha) =>
|
|
406
|
+
previewColor({ ...draftColorRef.current, alpha: nextAlpha })
|
|
407
|
+
}
|
|
408
|
+
onInteractionEnd={settleColorGesture}
|
|
409
|
+
onInteractionCancel={cancelColorGesture}
|
|
333
410
|
/>
|
|
334
411
|
|
|
335
412
|
<label className="grid gap-1.5">
|
|
@@ -337,6 +414,20 @@ export function ColorField({
|
|
|
337
414
|
<input
|
|
338
415
|
value={hexDraft}
|
|
339
416
|
onChange={(event) => handleHexCommit(event.target.value)}
|
|
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
|
+
}}
|
|
340
431
|
className={`${FIELD} h-10 w-full text-[11px] font-medium outline-none`}
|
|
341
432
|
spellCheck={false}
|
|
342
433
|
/>
|
|
@@ -349,6 +440,7 @@ export function ColorField({
|
|
|
349
440
|
|
|
350
441
|
const openPicker = () => {
|
|
351
442
|
if (disabled) return;
|
|
443
|
+
if (open) settleColorGesture();
|
|
352
444
|
setOpen((current) => !current);
|
|
353
445
|
if (!open) {
|
|
354
446
|
requestAnimationFrame(updatePanelPosition);
|
|
@@ -370,9 +462,19 @@ export function ColorField({
|
|
|
370
462
|
>
|
|
371
463
|
<span
|
|
372
464
|
className="h-4 w-4 flex-shrink-0 rounded-[4px]"
|
|
373
|
-
style={{ backgroundColor: value || "transparent" }}
|
|
465
|
+
style={{ backgroundColor: open ? currentColor : value || "transparent" }}
|
|
374
466
|
/>
|
|
375
|
-
<span className="font-mono text-[11px] text-panel-text-0">
|
|
467
|
+
<span className="font-mono text-[11px] text-panel-text-0">
|
|
468
|
+
{open ? currentColor : value}
|
|
469
|
+
</span>
|
|
470
|
+
{mixed && (
|
|
471
|
+
<span
|
|
472
|
+
data-color-mixed-indicator="true"
|
|
473
|
+
className="rounded bg-panel-hover px-1.5 py-0.5 text-[9px] font-medium text-panel-text-4"
|
|
474
|
+
>
|
|
475
|
+
Mixed
|
|
476
|
+
</span>
|
|
477
|
+
)}
|
|
376
478
|
</button>
|
|
377
479
|
{picker}
|
|
378
480
|
</div>
|
|
@@ -381,7 +483,19 @@ export function ColorField({
|
|
|
381
483
|
|
|
382
484
|
return (
|
|
383
485
|
<div className="grid min-w-0 gap-1.5">
|
|
384
|
-
<
|
|
486
|
+
<div className="flex items-center justify-between gap-2">
|
|
487
|
+
<span className={LABEL}>{label}</span>
|
|
488
|
+
{onReset && (
|
|
489
|
+
<button
|
|
490
|
+
type="button"
|
|
491
|
+
disabled={disabled}
|
|
492
|
+
onClick={onReset}
|
|
493
|
+
className="rounded bg-panel-hover px-1.5 py-0.5 text-[9px] font-medium text-panel-text-4 transition-colors hover:text-panel-text-0 disabled:cursor-not-allowed disabled:opacity-40"
|
|
494
|
+
>
|
|
495
|
+
Reset
|
|
496
|
+
</button>
|
|
497
|
+
)}
|
|
498
|
+
</div>
|
|
385
499
|
<button
|
|
386
500
|
type="button"
|
|
387
501
|
disabled={disabled}
|
|
@@ -397,6 +511,14 @@ export function ColorField({
|
|
|
397
511
|
<span className="min-w-0 flex-1 truncate text-[11px] font-medium text-neutral-100">
|
|
398
512
|
{value}
|
|
399
513
|
</span>
|
|
514
|
+
{mixed && (
|
|
515
|
+
<span
|
|
516
|
+
data-color-mixed-indicator="true"
|
|
517
|
+
className="rounded bg-panel-hover px-1.5 py-0.5 text-[9px] font-medium text-panel-text-4"
|
|
518
|
+
>
|
|
519
|
+
Mixed
|
|
520
|
+
</span>
|
|
521
|
+
)}
|
|
400
522
|
</button>
|
|
401
523
|
{picker}
|
|
402
524
|
</div>
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
import { useEffect, useRef, useState } from "react";
|
|
2
|
+
import { adjustNumericToken, parseNumericToken } from "./propertyPanelHelpers";
|
|
3
|
+
import { useInspectorGestureTransaction } from "./useInspectorGestureTransaction";
|
|
4
|
+
|
|
5
|
+
function arrowDirection(key: string): 1 | -1 | null {
|
|
6
|
+
if (key === "ArrowUp") return 1;
|
|
7
|
+
if (key === "ArrowDown") return -1;
|
|
8
|
+
return null;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function CommitField({
|
|
12
|
+
value,
|
|
13
|
+
disabled,
|
|
14
|
+
liveCommit,
|
|
15
|
+
align = "left",
|
|
16
|
+
onPreview,
|
|
17
|
+
onCommit,
|
|
18
|
+
}: {
|
|
19
|
+
value: string;
|
|
20
|
+
disabled?: boolean;
|
|
21
|
+
liveCommit?: boolean;
|
|
22
|
+
align?: "left" | "right";
|
|
23
|
+
onPreview?: (nextValue: string) => void;
|
|
24
|
+
onCommit: (nextValue: string) => void;
|
|
25
|
+
}) {
|
|
26
|
+
const [draft, setDraft] = useState(value);
|
|
27
|
+
const valueRef = useRef(value);
|
|
28
|
+
const draftRef = useRef(draft);
|
|
29
|
+
const inputRef = useRef<HTMLInputElement>(null);
|
|
30
|
+
const focusedRef = useRef(false);
|
|
31
|
+
const dirtyRef = useRef(false);
|
|
32
|
+
valueRef.current = value;
|
|
33
|
+
draftRef.current = draft;
|
|
34
|
+
|
|
35
|
+
const gestureActiveRef = useRef(false);
|
|
36
|
+
const gestureSettleTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
37
|
+
const gestureTransaction = useInspectorGestureTransaction({
|
|
38
|
+
sourceValue: value,
|
|
39
|
+
onPreview: (nextValue) => {
|
|
40
|
+
setDraft(nextValue);
|
|
41
|
+
onPreview?.(nextValue);
|
|
42
|
+
},
|
|
43
|
+
onCommit,
|
|
44
|
+
});
|
|
45
|
+
const gestureTransactionRef = useRef(gestureTransaction);
|
|
46
|
+
gestureTransactionRef.current = gestureTransaction;
|
|
47
|
+
|
|
48
|
+
const clearGestureSettleTimer = () => {
|
|
49
|
+
if (!gestureSettleTimerRef.current) return;
|
|
50
|
+
clearTimeout(gestureSettleTimerRef.current);
|
|
51
|
+
gestureSettleTimerRef.current = null;
|
|
52
|
+
};
|
|
53
|
+
const settleGesture = () => {
|
|
54
|
+
clearGestureSettleTimer();
|
|
55
|
+
if (!gestureActiveRef.current) return false;
|
|
56
|
+
gestureActiveRef.current = false;
|
|
57
|
+
gestureTransaction.settle();
|
|
58
|
+
return true;
|
|
59
|
+
};
|
|
60
|
+
const scheduleGestureSettle = () => {
|
|
61
|
+
clearGestureSettleTimer();
|
|
62
|
+
gestureSettleTimerRef.current = setTimeout(() => {
|
|
63
|
+
gestureSettleTimerRef.current = null;
|
|
64
|
+
if (!gestureActiveRef.current) return;
|
|
65
|
+
gestureActiveRef.current = false;
|
|
66
|
+
gestureTransactionRef.current.settle();
|
|
67
|
+
}, 250);
|
|
68
|
+
};
|
|
69
|
+
const cancelGesture = () => {
|
|
70
|
+
clearGestureSettleTimer();
|
|
71
|
+
gestureActiveRef.current = false;
|
|
72
|
+
gestureTransaction.cancel();
|
|
73
|
+
};
|
|
74
|
+
const commitDraft = (nextValue: string) => {
|
|
75
|
+
setDraft(nextValue);
|
|
76
|
+
onPreview?.(nextValue);
|
|
77
|
+
if (nextValue !== valueRef.current) onCommit(nextValue);
|
|
78
|
+
};
|
|
79
|
+
const cancelGestureFromKeyEvent = (event: React.KeyboardEvent<HTMLInputElement>) => {
|
|
80
|
+
if (!gestureActiveRef.current) return false;
|
|
81
|
+
event.preventDefault();
|
|
82
|
+
event.stopPropagation();
|
|
83
|
+
cancelGesture();
|
|
84
|
+
return true;
|
|
85
|
+
};
|
|
86
|
+
const previewNumericKeyStep = (event: React.KeyboardEvent<HTMLInputElement>) => {
|
|
87
|
+
const direction = arrowDirection(event.key);
|
|
88
|
+
if (direction === null) return;
|
|
89
|
+
const nextDraft = adjustNumericToken(draftRef.current, direction, event);
|
|
90
|
+
if (!nextDraft) return;
|
|
91
|
+
event.preventDefault();
|
|
92
|
+
dirtyRef.current = false;
|
|
93
|
+
gestureActiveRef.current = true;
|
|
94
|
+
gestureTransaction.preview(nextDraft);
|
|
95
|
+
scheduleGestureSettle();
|
|
96
|
+
};
|
|
97
|
+
const handleKeyDown = (event: React.KeyboardEvent<HTMLInputElement>) => {
|
|
98
|
+
if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === "z") {
|
|
99
|
+
cancelGestureFromKeyEvent(event);
|
|
100
|
+
return;
|
|
101
|
+
}
|
|
102
|
+
if (event.key === "Escape") {
|
|
103
|
+
cancelGestureFromKeyEvent(event);
|
|
104
|
+
return;
|
|
105
|
+
}
|
|
106
|
+
if (event.key === "Enter") {
|
|
107
|
+
event.currentTarget.blur();
|
|
108
|
+
return;
|
|
109
|
+
}
|
|
110
|
+
previewNumericKeyStep(event);
|
|
111
|
+
};
|
|
112
|
+
|
|
113
|
+
useEffect(() => {
|
|
114
|
+
if (focusedRef.current && dirtyRef.current) return;
|
|
115
|
+
setDraft(value);
|
|
116
|
+
}, [value]);
|
|
117
|
+
|
|
118
|
+
useEffect(() => {
|
|
119
|
+
const el = inputRef.current;
|
|
120
|
+
if (!el) return;
|
|
121
|
+
const handler = (event: WheelEvent) => {
|
|
122
|
+
if (disabled || document.activeElement !== el) return;
|
|
123
|
+
const delta = event.deltaY === 0 ? event.deltaX : event.deltaY;
|
|
124
|
+
if (delta === 0) return;
|
|
125
|
+
const nextDraft = adjustNumericToken(draftRef.current, delta < 0 ? 1 : -1, event);
|
|
126
|
+
if (!nextDraft) return;
|
|
127
|
+
event.preventDefault();
|
|
128
|
+
event.stopPropagation();
|
|
129
|
+
dirtyRef.current = false;
|
|
130
|
+
gestureActiveRef.current = true;
|
|
131
|
+
gestureTransactionRef.current.preview(nextDraft);
|
|
132
|
+
scheduleGestureSettle();
|
|
133
|
+
};
|
|
134
|
+
el.addEventListener("wheel", handler, { passive: false });
|
|
135
|
+
return () => {
|
|
136
|
+
el.removeEventListener("wheel", handler);
|
|
137
|
+
clearGestureSettleTimer();
|
|
138
|
+
};
|
|
139
|
+
}, [disabled]);
|
|
140
|
+
|
|
141
|
+
return (
|
|
142
|
+
<input
|
|
143
|
+
ref={inputRef}
|
|
144
|
+
type="text"
|
|
145
|
+
value={draft}
|
|
146
|
+
disabled={disabled}
|
|
147
|
+
onFocus={() => {
|
|
148
|
+
focusedRef.current = true;
|
|
149
|
+
}}
|
|
150
|
+
onChange={(event) => {
|
|
151
|
+
settleGesture();
|
|
152
|
+
dirtyRef.current = true;
|
|
153
|
+
setDraft(event.target.value);
|
|
154
|
+
if (liveCommit) onPreview?.(event.target.value);
|
|
155
|
+
}}
|
|
156
|
+
onBlur={() => {
|
|
157
|
+
if (settleGesture()) {
|
|
158
|
+
focusedRef.current = false;
|
|
159
|
+
return;
|
|
160
|
+
}
|
|
161
|
+
const wasDirty = dirtyRef.current;
|
|
162
|
+
focusedRef.current = false;
|
|
163
|
+
dirtyRef.current = false;
|
|
164
|
+
if (wasDirty && (!liveCommit || parseNumericToken(draft))) {
|
|
165
|
+
commitDraft(draft);
|
|
166
|
+
} else {
|
|
167
|
+
setDraft(valueRef.current);
|
|
168
|
+
if (wasDirty && liveCommit) onPreview?.(valueRef.current);
|
|
169
|
+
}
|
|
170
|
+
}}
|
|
171
|
+
onKeyDown={handleKeyDown}
|
|
172
|
+
title={parseNumericToken(value) ? "Scroll or use Arrow keys to adjust" : undefined}
|
|
173
|
+
className={`min-w-0 w-full bg-transparent text-[11px] font-medium text-neutral-100 outline-none disabled:cursor-not-allowed disabled:text-neutral-600 ${
|
|
174
|
+
align === "right" ? "text-right" : "text-left"
|
|
175
|
+
}`}
|
|
176
|
+
/>
|
|
177
|
+
);
|
|
178
|
+
}
|
|
@@ -38,6 +38,16 @@ describe("FlatRow", () => {
|
|
|
38
38
|
act(() => root.unmount());
|
|
39
39
|
});
|
|
40
40
|
|
|
41
|
+
it("right-aligns the value input — the flat inspector's justify-between row layout leaves a left-aligned value looking stranded at the edge of its own box", () => {
|
|
42
|
+
const { host, root } = renderInto(
|
|
43
|
+
<FlatRow label="Size" value="72px" tier="explicitCustom" onCommit={vi.fn()} />,
|
|
44
|
+
);
|
|
45
|
+
const input = host.querySelector("input");
|
|
46
|
+
expect(input?.className).toContain("text-right");
|
|
47
|
+
expect(input?.className).not.toContain("text-left");
|
|
48
|
+
act(() => root.unmount());
|
|
49
|
+
});
|
|
50
|
+
|
|
41
51
|
it("renders the explicitCustom tier with a mint value and a reset button", () => {
|
|
42
52
|
const onReset = vi.fn();
|
|
43
53
|
const { host, root } = renderInto(
|
|
@@ -79,6 +89,40 @@ describe("FlatRow", () => {
|
|
|
79
89
|
expect(onCommit).toHaveBeenCalledWith("24px");
|
|
80
90
|
act(() => root.unmount());
|
|
81
91
|
});
|
|
92
|
+
|
|
93
|
+
it("persists a rapid numeric arrow-key burst as one commit", () => {
|
|
94
|
+
vi.useFakeTimers();
|
|
95
|
+
const onCommit = vi.fn();
|
|
96
|
+
const onPreview = vi.fn();
|
|
97
|
+
const { host, root } = renderInto(
|
|
98
|
+
<FlatRow
|
|
99
|
+
label="Size"
|
|
100
|
+
value="22px"
|
|
101
|
+
tier="explicitDefault"
|
|
102
|
+
liveCommit
|
|
103
|
+
onPreview={onPreview}
|
|
104
|
+
onCommit={onCommit}
|
|
105
|
+
/>,
|
|
106
|
+
);
|
|
107
|
+
const input = host.querySelector<HTMLInputElement>("input");
|
|
108
|
+
if (!input) throw new Error("expected an input");
|
|
109
|
+
|
|
110
|
+
for (let step = 0; step < 8; step += 1) {
|
|
111
|
+
act(() => {
|
|
112
|
+
input.dispatchEvent(new KeyboardEvent("keydown", { bubbles: true, key: "ArrowUp" }));
|
|
113
|
+
});
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
expect(input.value).toBe("30px");
|
|
117
|
+
expect(onPreview).toHaveBeenLastCalledWith("30px");
|
|
118
|
+
expect(onCommit).not.toHaveBeenCalled();
|
|
119
|
+
act(() => vi.advanceTimersByTime(250));
|
|
120
|
+
expect(onCommit).toHaveBeenCalledOnce();
|
|
121
|
+
expect(onCommit).toHaveBeenCalledWith("30px");
|
|
122
|
+
|
|
123
|
+
act(() => root.unmount());
|
|
124
|
+
vi.useRealTimers();
|
|
125
|
+
});
|
|
82
126
|
});
|
|
83
127
|
|
|
84
128
|
describe("FlatSegmentedRow", () => {
|
|
@@ -20,6 +20,7 @@ export function FlatRow({
|
|
|
20
20
|
liveCommit,
|
|
21
21
|
suffix,
|
|
22
22
|
dropdown,
|
|
23
|
+
onPreview,
|
|
23
24
|
onCommit,
|
|
24
25
|
onReset,
|
|
25
26
|
}: {
|
|
@@ -31,6 +32,7 @@ export function FlatRow({
|
|
|
31
32
|
suffix?: ReactNode;
|
|
32
33
|
/** Renders a trailing 10px caret-down, for select-backed rows. */
|
|
33
34
|
dropdown?: boolean;
|
|
35
|
+
onPreview?: (nextValue: string) => void;
|
|
34
36
|
onCommit: (nextValue: string) => void;
|
|
35
37
|
onReset?: () => void;
|
|
36
38
|
}) {
|
|
@@ -51,6 +53,8 @@ export function FlatRow({
|
|
|
51
53
|
value={value}
|
|
52
54
|
disabled={disabled}
|
|
53
55
|
liveCommit={liveCommit}
|
|
56
|
+
align="right"
|
|
57
|
+
onPreview={onPreview}
|
|
54
58
|
onCommit={(nextValue) => {
|
|
55
59
|
track("metric", label);
|
|
56
60
|
onCommit(nextValue);
|