@hyperframes/studio 0.7.58 → 0.7.60
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-CtTDO63S.js → hyperframes-player-3XTTaVNf.js} +1 -1
- package/dist/assets/{index-C47jAC3Q.js → index-D6etaey-.js} +1 -1
- package/dist/assets/index-DXbu6IPT.css +1 -0
- package/dist/assets/{index-DeQPzqwH.js → index-Dh_WhagG.js} +1 -1
- package/dist/assets/index-cH6NfVV_.js +426 -0
- package/dist/index.d.ts +12 -3
- package/dist/index.html +2 -2
- package/dist/index.js +13755 -8627
- package/dist/index.js.map +1 -1
- package/dist/styles/tailwind-preset.d.ts +5 -0
- package/dist/styles/tailwind-preset.js +8 -1
- package/dist/styles/tailwind-preset.js.map +1 -1
- package/package.json +7 -7
- package/src/App.tsx +4 -4
- package/src/components/DesignPanelPromoteProvider.tsx +15 -2
- package/src/components/StudioRightPanel.tsx +42 -53
- package/src/components/editor/AnimationCard.test.tsx +134 -0
- package/src/components/editor/AnimationCard.tsx +19 -4
- package/src/components/editor/ArcPathControls.tsx +1 -0
- package/src/components/editor/GestureRecordControl.tsx +7 -1
- package/src/components/editor/GsapAnimationSection.tsx +109 -14
- package/src/components/editor/InspectorHeaderActions.tsx +42 -4
- package/src/components/editor/PropertyPanel.test.tsx +948 -0
- package/src/components/editor/PropertyPanel.tsx +144 -148
- package/src/components/editor/PropertyPanelEmptyState.test.tsx +74 -0
- package/src/components/editor/PropertyPanelEmptyState.tsx +179 -1
- package/src/components/editor/PropertyPanelFlat.tsx +580 -0
- package/src/components/editor/PropertyPanelFlatFooter.test.tsx +83 -0
- package/src/components/editor/PropertyPanelFlatFooter.tsx +73 -0
- package/src/components/editor/PropertyPanelFlatHeader.test.tsx +79 -0
- package/src/components/editor/PropertyPanelFlatHeader.tsx +112 -0
- package/src/components/editor/gsapAnimationCallbacks.ts +27 -0
- package/src/components/editor/gsapLivePreview.ts +34 -0
- package/src/components/editor/manualEditingAvailability.test.ts +10 -0
- package/src/components/editor/manualEditingAvailability.ts +9 -0
- package/src/components/editor/propertyPanel3dTransform.tsx +5 -0
- package/src/components/editor/propertyPanelColor.test.tsx +28 -0
- package/src/components/editor/propertyPanelColor.tsx +31 -1
- package/src/components/editor/propertyPanelColorGradingControls.tsx +11 -2
- package/src/components/editor/propertyPanelColorGradingSection.tsx +45 -365
- package/src/components/editor/propertyPanelColorGradingSlider.tsx +19 -5
- package/src/components/editor/propertyPanelFill.tsx +22 -5
- package/src/components/editor/propertyPanelFlatColorGradingSection.test.tsx +622 -0
- package/src/components/editor/propertyPanelFlatColorGradingSection.tsx +570 -0
- package/src/components/editor/propertyPanelFlatLayoutSection.test.tsx +293 -0
- package/src/components/editor/propertyPanelFlatLayoutSection.tsx +383 -0
- package/src/components/editor/propertyPanelFlatMaskInsetRows.tsx +102 -0
- package/src/components/editor/propertyPanelFlatMediaSection.test.tsx +436 -0
- package/src/components/editor/propertyPanelFlatMediaSection.tsx +286 -0
- package/src/components/editor/propertyPanelFlatMotionSection.test.tsx +272 -0
- package/src/components/editor/propertyPanelFlatMotionSection.tsx +315 -0
- package/src/components/editor/propertyPanelFlatPrimitives.test.tsx +1174 -0
- package/src/components/editor/propertyPanelFlatPrimitives.tsx +563 -0
- package/src/components/editor/propertyPanelFlatSelectRow.tsx +107 -0
- package/src/components/editor/propertyPanelFlatStyleHelpers.test.ts +23 -0
- package/src/components/editor/propertyPanelFlatStyleHelpers.ts +27 -0
- package/src/components/editor/propertyPanelFlatStyleSections.test.tsx +598 -0
- package/src/components/editor/propertyPanelFlatStyleSections.tsx +516 -0
- package/src/components/editor/propertyPanelFlatTextSection.test.tsx +465 -0
- package/src/components/editor/propertyPanelFlatTextSection.tsx +407 -0
- package/src/components/editor/propertyPanelFlatTimingDerivation.test.ts +71 -0
- package/src/components/editor/propertyPanelFlatTimingDerivation.ts +59 -0
- package/src/components/editor/propertyPanelFlatToggle.test.tsx +63 -0
- package/src/components/editor/propertyPanelFlatToggle.tsx +53 -0
- package/src/components/editor/propertyPanelFont.test.tsx +30 -0
- package/src/components/editor/propertyPanelFont.tsx +136 -95
- package/src/components/editor/propertyPanelHelpers.ts +73 -0
- package/src/components/editor/propertyPanelInputCoverage.test.tsx +587 -0
- package/src/components/editor/propertyPanelMediaSection.tsx +10 -0
- package/src/components/editor/propertyPanelPrimitives.tsx +60 -29
- package/src/components/editor/propertyPanelSections.test.tsx +161 -0
- package/src/components/editor/propertyPanelSections.tsx +154 -90
- package/src/components/editor/propertyPanelStyleSections.tsx +12 -1
- package/src/components/editor/propertyPanelTimingSection.tsx +1 -1
- package/src/components/editor/propertyPanelTypes.ts +14 -1
- package/src/components/editor/propertyPanelValueTier.test.ts +32 -0
- package/src/components/editor/propertyPanelValueTier.ts +28 -0
- package/src/components/editor/useColorGradingController.test.ts +418 -0
- package/src/components/editor/useColorGradingController.ts +596 -0
- package/src/components/panels/VariablesPanel.tsx +4 -0
- package/src/components/sidebar/CompositionsTab.test.ts +28 -2
- package/src/components/sidebar/CompositionsTab.tsx +3 -1
- package/src/components/storyboard/FramePoster.tsx +24 -4
- package/src/components/storyboard/StoryboardFrameFocus.tsx +4 -0
- package/src/components/storyboard/StoryboardFrameTile.tsx +30 -1
- package/src/components/storyboard/StoryboardGrid.tsx +23 -1
- package/src/components/storyboard/StoryboardLoaded.tsx +58 -2
- package/src/components/storyboard/StoryboardView.tsx +9 -2
- package/src/components/storyboard/frameComments.test.ts +95 -0
- package/src/components/storyboard/frameComments.ts +115 -0
- package/src/components/storyboard/useFrameComments.ts +82 -0
- package/src/contexts/DesignPanelInputContext.test.tsx +81 -0
- package/src/contexts/DesignPanelInputContext.tsx +48 -0
- package/src/contexts/DomEditContext.tsx +4 -0
- package/src/contexts/FileManagerContext.tsx +3 -0
- package/src/contexts/PanelLayoutContext.tsx +3 -0
- package/src/contexts/VariablePromoteContext.test.tsx +67 -0
- package/src/contexts/VariablePromoteContext.tsx +10 -5
- package/src/hooks/domEditCommitRunner.ts +11 -0
- package/src/hooks/gsapScriptCommitTypes.ts +6 -4
- package/src/hooks/timelineEditingHelpers.ts +2 -2
- package/src/hooks/timelineTrackVisibility.test.ts +52 -0
- package/src/hooks/timelineTrackVisibility.ts +27 -10
- package/src/hooks/useAppHotkeys.ts +9 -0
- package/src/hooks/useDomEditAttributeCommits.ts +123 -1
- package/src/hooks/useDomEditCommits.test.tsx +41 -1
- package/src/hooks/useDomEditCommits.ts +31 -177
- package/src/hooks/useDomEditCommitsHelpers.ts +158 -0
- package/src/hooks/useDomEditSession.ts +18 -6
- package/src/hooks/useDomEditTextCommits.ts +14 -9
- package/src/hooks/useDomEditWiring.ts +10 -9
- package/src/hooks/useEditorSave.ts +1 -1
- package/src/hooks/useElementLifecycleOps.ts +8 -3
- package/src/hooks/useFileManager.projectOwnership.test.tsx +109 -0
- package/src/hooks/useFileManager.ts +151 -65
- package/src/hooks/useGsapAnimationOps.ts +7 -6
- package/src/hooks/useGsapKeyframeOps.ts +6 -5
- package/src/hooks/useGsapPropertyDebounce.ts +43 -28
- package/src/hooks/useGsapPropertyDebounceFlush.test.ts +35 -0
- package/src/hooks/useGsapScriptCommits.test.tsx +48 -2
- package/src/hooks/useGsapScriptCommits.ts +150 -50
- package/src/hooks/useGsapSelectionHandlers.test.tsx +116 -0
- package/src/hooks/useGsapSelectionHandlers.ts +94 -31
- package/src/hooks/useInspectorSplitResize.ts +51 -0
- package/src/hooks/usePanelLayout.test.ts +118 -0
- package/src/hooks/usePanelLayout.ts +24 -1
- package/src/hooks/usePersistentEditHistory.projectOwnership.test.tsx +60 -0
- package/src/hooks/usePersistentEditHistory.test.ts +49 -1
- package/src/hooks/usePersistentEditHistory.ts +71 -31
- package/src/hooks/usePreviewPersistence.ts +4 -1
- package/src/hooks/useProjectCompositionVariables.ts +10 -10
- package/src/hooks/useProjectSignaturePoll.test.tsx +135 -0
- package/src/hooks/useProjectSignaturePoll.ts +68 -0
- package/src/hooks/useRazorSplit.history.test.tsx +12 -4
- package/src/hooks/useRazorSplit.test.tsx +88 -0
- package/src/hooks/useRazorSplit.testHelpers.ts +3 -2
- package/src/hooks/useRazorSplit.ts +50 -16
- package/src/hooks/useSdkSession.lifecycle.test.tsx +137 -0
- package/src/hooks/useSdkSession.ts +155 -14
- package/src/hooks/useSlideshowPersist.ts +7 -1
- package/src/hooks/useStoryboard.ts +23 -4
- package/src/hooks/useTimelineEditing.test.tsx +4 -1
- package/src/hooks/useTimelineEditing.ts +18 -10
- package/src/hooks/useTimelineEditingTypes.ts +5 -1
- package/src/hooks/useTimelineGroupEditing.ts +17 -3
- package/src/hooks/useVariablesPersist.ts +9 -7
- package/src/icons/SystemIcons.tsx +2 -0
- package/src/styles/studio.css +22 -0
- package/src/styles/tailwind-preset.shared.js +7 -0
- package/src/utils/designInputTracking.test.ts +97 -0
- package/src/utils/designInputTracking.ts +80 -0
- package/src/utils/domEditSaveQueue.test.ts +19 -0
- package/src/utils/domEditSaveQueue.ts +2 -1
- package/src/utils/sdkCutover.gate.test.ts +7 -7
- package/src/utils/sdkCutover.test.ts +511 -62
- package/src/utils/sdkCutover.ts +181 -219
- package/src/utils/sdkEditTransaction.ts +293 -0
- package/src/utils/sdkResolverAttempts.ts +94 -0
- package/src/utils/sdkResolverShadow.test.ts +148 -0
- package/src/utils/sdkResolverShadow.ts +139 -106
- package/src/utils/setSlideshowManifest.test.ts +0 -9
- package/src/utils/setSlideshowManifest.ts +20 -27
- package/src/utils/studioFileHistory.test.ts +41 -0
- package/src/utils/studioFileHistory.ts +34 -29
- package/src/utils/studioFileMutationCoordinator.ts +45 -0
- package/src/utils/studioFileVersion.test.ts +36 -0
- package/src/utils/studioFileVersion.ts +23 -0
- package/src/utils/studioHelpers.test.ts +45 -0
- package/src/utils/studioHelpers.ts +45 -14
- package/src/utils/studioSaveDiagnostics.test.ts +18 -0
- package/src/utils/studioSaveDiagnostics.ts +38 -0
- package/dist/assets/index-B_UvTX3E.js +0 -423
- package/dist/assets/index-uahwWkgw.css +0 -1
|
@@ -0,0 +1,563 @@
|
|
|
1
|
+
import { useEffect, useRef, useState, type ReactNode } from "react";
|
|
2
|
+
import { useTrackDesignInput } from "../../contexts/DesignPanelInputContext";
|
|
3
|
+
import { RotateCcw } from "../../icons/SystemIcons";
|
|
4
|
+
import { CommitField } from "./propertyPanelPrimitives";
|
|
5
|
+
import {
|
|
6
|
+
VALUE_TIER_LABEL_CLASS,
|
|
7
|
+
VALUE_TIER_VALUE_CLASS,
|
|
8
|
+
type PropertyValueTier,
|
|
9
|
+
} from "./propertyPanelValueTier";
|
|
10
|
+
|
|
11
|
+
/* ------------------------------------------------------------------ */
|
|
12
|
+
/* FlatRow — single-column label/value property row */
|
|
13
|
+
/* ------------------------------------------------------------------ */
|
|
14
|
+
|
|
15
|
+
export function FlatRow({
|
|
16
|
+
label,
|
|
17
|
+
value,
|
|
18
|
+
tier,
|
|
19
|
+
disabled,
|
|
20
|
+
liveCommit,
|
|
21
|
+
suffix,
|
|
22
|
+
dropdown,
|
|
23
|
+
onCommit,
|
|
24
|
+
onReset,
|
|
25
|
+
}: {
|
|
26
|
+
label: string;
|
|
27
|
+
value: string;
|
|
28
|
+
tier: PropertyValueTier;
|
|
29
|
+
disabled?: boolean;
|
|
30
|
+
liveCommit?: boolean;
|
|
31
|
+
suffix?: ReactNode;
|
|
32
|
+
/** Renders a trailing 10px caret-down, for select-backed rows. */
|
|
33
|
+
dropdown?: boolean;
|
|
34
|
+
onCommit: (nextValue: string) => void;
|
|
35
|
+
onReset?: () => void;
|
|
36
|
+
}) {
|
|
37
|
+
const track = useTrackDesignInput();
|
|
38
|
+
return (
|
|
39
|
+
<div className="group flex min-h-[30px] items-center justify-between gap-3">
|
|
40
|
+
<span className={`text-[11px] ${VALUE_TIER_LABEL_CLASS[tier]}`}>{label}</span>
|
|
41
|
+
<span className="flex min-w-0 flex-shrink-0 items-center gap-1.5">
|
|
42
|
+
<span
|
|
43
|
+
data-flat-row-value="true"
|
|
44
|
+
className={`min-w-0 border-b pb-px font-mono text-[11px] ${VALUE_TIER_VALUE_CLASS[tier]} ${
|
|
45
|
+
tier === "explicitCustom"
|
|
46
|
+
? "border-panel-accent/30 group-hover:border-panel-accent/70"
|
|
47
|
+
: "border-panel-border-input/50 group-hover:border-panel-border-input"
|
|
48
|
+
}`}
|
|
49
|
+
>
|
|
50
|
+
<CommitField
|
|
51
|
+
value={value}
|
|
52
|
+
disabled={disabled}
|
|
53
|
+
liveCommit={liveCommit}
|
|
54
|
+
onCommit={(nextValue) => {
|
|
55
|
+
track("metric", label);
|
|
56
|
+
onCommit(nextValue);
|
|
57
|
+
}}
|
|
58
|
+
/>
|
|
59
|
+
</span>
|
|
60
|
+
{suffix}
|
|
61
|
+
{tier === "explicitCustom" && onReset && (
|
|
62
|
+
<button
|
|
63
|
+
type="button"
|
|
64
|
+
data-flat-row-reset="true"
|
|
65
|
+
title="Remove — fall back to default"
|
|
66
|
+
onClick={() => {
|
|
67
|
+
track("button", `Reset ${label}`);
|
|
68
|
+
onReset();
|
|
69
|
+
}}
|
|
70
|
+
className="flex-shrink-0 text-panel-text-3 opacity-0 transition-opacity hover:text-panel-text-1 group-hover:opacity-100"
|
|
71
|
+
>
|
|
72
|
+
<RotateCcw size={11} />
|
|
73
|
+
</button>
|
|
74
|
+
)}
|
|
75
|
+
{dropdown && (
|
|
76
|
+
<svg
|
|
77
|
+
width="10"
|
|
78
|
+
height="10"
|
|
79
|
+
viewBox="0 0 10 10"
|
|
80
|
+
fill="currentColor"
|
|
81
|
+
className="flex-shrink-0 text-panel-text-5"
|
|
82
|
+
>
|
|
83
|
+
<path d="M2 3l3 4 3-4z" />
|
|
84
|
+
</svg>
|
|
85
|
+
)}
|
|
86
|
+
</span>
|
|
87
|
+
</div>
|
|
88
|
+
);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/* ------------------------------------------------------------------ */
|
|
92
|
+
/* FlatSegmentedRow — inline glyph runs, no container background */
|
|
93
|
+
/* ------------------------------------------------------------------ */
|
|
94
|
+
|
|
95
|
+
export interface FlatSegmentOption {
|
|
96
|
+
key: string;
|
|
97
|
+
node: ReactNode;
|
|
98
|
+
/** Accessible name — the glyph alone (e.g. two indistinguishable "A"
|
|
99
|
+
* buttons for upright vs. italic) isn't a valid accessible name on its
|
|
100
|
+
* own. */
|
|
101
|
+
label: string;
|
|
102
|
+
active: boolean;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export function FlatSegmentedRow({
|
|
106
|
+
label,
|
|
107
|
+
options,
|
|
108
|
+
disabled,
|
|
109
|
+
/** Index (0-based) after which to render a 12px spacer — for combined rows
|
|
110
|
+
* like Text's "Case · Style", which pack two independent option groups. */
|
|
111
|
+
spacerAfterIndex,
|
|
112
|
+
onChange,
|
|
113
|
+
}: {
|
|
114
|
+
label: string;
|
|
115
|
+
options: FlatSegmentOption[];
|
|
116
|
+
disabled?: boolean;
|
|
117
|
+
spacerAfterIndex?: number;
|
|
118
|
+
onChange: (nextKey: string) => void;
|
|
119
|
+
}) {
|
|
120
|
+
const track = useTrackDesignInput();
|
|
121
|
+
return (
|
|
122
|
+
<div className="flex min-h-[32px] items-center justify-between">
|
|
123
|
+
<span className="text-[11px] text-panel-text-3">{label}</span>
|
|
124
|
+
<span className="flex items-center gap-0.5">
|
|
125
|
+
{options.map((option, index) => (
|
|
126
|
+
<span key={option.key} className="flex items-center">
|
|
127
|
+
<button
|
|
128
|
+
type="button"
|
|
129
|
+
data-flat-segment="true"
|
|
130
|
+
aria-label={option.label}
|
|
131
|
+
aria-pressed={option.active}
|
|
132
|
+
disabled={disabled}
|
|
133
|
+
onClick={() => {
|
|
134
|
+
if (!option.active) track("segmented", label);
|
|
135
|
+
onChange(option.key);
|
|
136
|
+
}}
|
|
137
|
+
className={`px-1.5 py-1 text-[11px] transition-colors disabled:cursor-not-allowed ${
|
|
138
|
+
option.active
|
|
139
|
+
? "border-b-2 border-panel-accent text-panel-text-0"
|
|
140
|
+
: "border-b-2 border-transparent text-panel-text-4 hover:text-panel-text-2"
|
|
141
|
+
}`}
|
|
142
|
+
>
|
|
143
|
+
{option.node}
|
|
144
|
+
</button>
|
|
145
|
+
{spacerAfterIndex === index && <span className="w-3" aria-hidden="true" />}
|
|
146
|
+
</span>
|
|
147
|
+
))}
|
|
148
|
+
</span>
|
|
149
|
+
</div>
|
|
150
|
+
);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/* ------------------------------------------------------------------ */
|
|
154
|
+
/* FlatGroupHeader — one-open-at-a-time accordion group header */
|
|
155
|
+
/* (fixed-headers + scrollable-open-section layout, design_handoff */
|
|
156
|
+
/* scrollable-open-section): renders ONLY the header bar — collapsed */
|
|
157
|
+
/* button, or open-state title bar with the collapse control. Never */
|
|
158
|
+
/* positioned (no sticky, no stacking offsets) — it always sits in */
|
|
159
|
+
/* normal document flow. The open group's body content is rendered by */
|
|
160
|
+
/* PropertyPanelFlat.tsx directly, in a dedicated scrollable region, */
|
|
161
|
+
/* not as children here. */
|
|
162
|
+
/* ------------------------------------------------------------------ */
|
|
163
|
+
|
|
164
|
+
export function FlatGroupHeader({
|
|
165
|
+
title,
|
|
166
|
+
isOpen,
|
|
167
|
+
onToggleOpen,
|
|
168
|
+
accessory,
|
|
169
|
+
summary,
|
|
170
|
+
animateEntrance,
|
|
171
|
+
}: {
|
|
172
|
+
title: string;
|
|
173
|
+
isOpen: boolean;
|
|
174
|
+
onToggleOpen: () => void;
|
|
175
|
+
accessory?: ReactNode;
|
|
176
|
+
summary?: string;
|
|
177
|
+
/** Play the fast entrance animation on this render — set only for the one
|
|
178
|
+
* group(s) actually transitioning (see PropertyPanelFlat's justToggledIds).
|
|
179
|
+
* Not derived from `isOpen`/remounting alone: React's key-based diffing
|
|
180
|
+
* can still shift an unrelated collapsed sibling's position in the
|
|
181
|
+
* before/after-open arrays (e.g. when the newly opened group isn't
|
|
182
|
+
* adjacent to the previously open one), and Chromium restarts a CSS
|
|
183
|
+
* entrance animation on such a position change even though nothing about
|
|
184
|
+
* that sibling actually changed — gating explicitly avoids that replay. */
|
|
185
|
+
animateEntrance?: boolean;
|
|
186
|
+
}) {
|
|
187
|
+
if (!isOpen) {
|
|
188
|
+
return (
|
|
189
|
+
<button
|
|
190
|
+
type="button"
|
|
191
|
+
data-flat-group-collapsed="true"
|
|
192
|
+
onClick={onToggleOpen}
|
|
193
|
+
className={`${animateEntrance ? "hf-flat-group-enter " : ""}flex min-h-10 w-full flex-shrink-0 items-center justify-between gap-2 border-b border-panel-hairline bg-panel-bg px-4 text-left`}
|
|
194
|
+
>
|
|
195
|
+
<span className="flex min-w-0 items-center gap-2">
|
|
196
|
+
<span className="text-[12px] font-medium text-panel-text-2">{title}</span>
|
|
197
|
+
{summary && (
|
|
198
|
+
<span className="min-w-0 truncate font-mono text-[9px] text-panel-text-4">
|
|
199
|
+
{summary}
|
|
200
|
+
</span>
|
|
201
|
+
)}
|
|
202
|
+
</span>
|
|
203
|
+
<svg
|
|
204
|
+
width="12"
|
|
205
|
+
height="12"
|
|
206
|
+
viewBox="0 0 12 12"
|
|
207
|
+
fill="currentColor"
|
|
208
|
+
className="flex-shrink-0 text-panel-text-5"
|
|
209
|
+
>
|
|
210
|
+
<path d="M4 2l4 4-4 4z" />
|
|
211
|
+
</svg>
|
|
212
|
+
</button>
|
|
213
|
+
);
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
return (
|
|
217
|
+
<div
|
|
218
|
+
className={`${animateEntrance ? "hf-flat-group-enter " : ""}flex min-h-10 flex-shrink-0 items-center justify-between bg-panel-bg px-4`}
|
|
219
|
+
>
|
|
220
|
+
<span className="text-[12px] font-semibold text-panel-text-0">{title}</span>
|
|
221
|
+
<span className="flex items-center gap-2.5 text-panel-text-5">
|
|
222
|
+
{accessory}
|
|
223
|
+
<button type="button" onClick={onToggleOpen} title="Collapse" className="text-panel-text-3">
|
|
224
|
+
<svg width="12" height="12" viewBox="0 0 12 12" fill="currentColor">
|
|
225
|
+
<path d="M2 4l4 4 4-4z" />
|
|
226
|
+
</svg>
|
|
227
|
+
</button>
|
|
228
|
+
</span>
|
|
229
|
+
</div>
|
|
230
|
+
);
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/* ------------------------------------------------------------------ */
|
|
234
|
+
/* FlatSlider — full-width label/track/value row */
|
|
235
|
+
/* ------------------------------------------------------------------ */
|
|
236
|
+
|
|
237
|
+
/** Keyboard target for a slider keydown, or null for keys we don't handle. */
|
|
238
|
+
function sliderKeyTarget(
|
|
239
|
+
key: string,
|
|
240
|
+
current: number,
|
|
241
|
+
min: number,
|
|
242
|
+
max: number,
|
|
243
|
+
step: number,
|
|
244
|
+
): number | null {
|
|
245
|
+
if (key === "Home") return min;
|
|
246
|
+
if (key === "End") return max;
|
|
247
|
+
const deltas: Record<string, number> = {
|
|
248
|
+
ArrowLeft: -step,
|
|
249
|
+
ArrowDown: -step,
|
|
250
|
+
ArrowRight: step,
|
|
251
|
+
ArrowUp: step,
|
|
252
|
+
PageDown: -step * 10,
|
|
253
|
+
PageUp: step * 10,
|
|
254
|
+
};
|
|
255
|
+
const delta = deltas[key];
|
|
256
|
+
if (delta === undefined) return null;
|
|
257
|
+
return Math.max(min, Math.min(max, current + delta));
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
export function FlatSlider({
|
|
261
|
+
label,
|
|
262
|
+
value,
|
|
263
|
+
min,
|
|
264
|
+
max,
|
|
265
|
+
step = 1,
|
|
266
|
+
tier,
|
|
267
|
+
displayValue,
|
|
268
|
+
disabled,
|
|
269
|
+
centerTick,
|
|
270
|
+
onReset,
|
|
271
|
+
onCommit,
|
|
272
|
+
}: {
|
|
273
|
+
label: string;
|
|
274
|
+
value: number;
|
|
275
|
+
min: number;
|
|
276
|
+
max: number;
|
|
277
|
+
step?: number;
|
|
278
|
+
tier: "default" | "explicitCustom";
|
|
279
|
+
displayValue: string;
|
|
280
|
+
disabled?: boolean;
|
|
281
|
+
centerTick?: boolean;
|
|
282
|
+
onReset?: () => void;
|
|
283
|
+
onCommit: (nextValue: number) => void;
|
|
284
|
+
}) {
|
|
285
|
+
const track = useTrackDesignInput();
|
|
286
|
+
// `draft` gives the knob instant, drag-local visual feedback. `onCommit` is
|
|
287
|
+
// throttled (not debounced) to at most once per 40ms: a real drag fires
|
|
288
|
+
// pointermove faster than that, and a pure debounce (reset the timer on
|
|
289
|
+
// every move) never commits until the pointer pauses or lifts — which kills
|
|
290
|
+
// live preview updates during a continuous drag. Throttling still fires on
|
|
291
|
+
// the leading edge and on a trailing timer, so the preview keeps updating
|
|
292
|
+
// while dragging, with an immediate flush on release for the final value.
|
|
293
|
+
const [draft, setDraft] = useState(value);
|
|
294
|
+
const commitTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
295
|
+
const lastCommitAtRef = useRef(0);
|
|
296
|
+
const pendingRef = useRef<number | null>(null);
|
|
297
|
+
// True from pointerdown to pointerup/cancel. While dragging, the committed
|
|
298
|
+
// prop echoing back through the parent must NOT reset `draft` — the echo is
|
|
299
|
+
// up to 40ms stale (throttled commit), and syncing it mid-drag snaps the
|
|
300
|
+
// knob backwards under the user's pointer.
|
|
301
|
+
const draggingRef = useRef(false);
|
|
302
|
+
// Tracks the last value actually sent to onCommit — separate from `value`
|
|
303
|
+
// (the committed prop) because in a single pointerdown+pointerup click the
|
|
304
|
+
// leading-edge commit fires before the parent has re-rendered with the new
|
|
305
|
+
// prop, so the release flush must dedupe against what we just sent, not
|
|
306
|
+
// against the stale prop, or the same value commits twice.
|
|
307
|
+
const lastCommittedRef = useRef(value);
|
|
308
|
+
// Always the current render's onCommit — read inside the throttle timer
|
|
309
|
+
// instead of closing over the callback at schedule time. A caller whose
|
|
310
|
+
// onCommit spreads other current state (e.g. Grade's "...grading, details:
|
|
311
|
+
// {...}") would otherwise let a queued trailing commit fire ~40ms later
|
|
312
|
+
// with a stale snapshot and silently revert whatever the user changed on a
|
|
313
|
+
// different control in between.
|
|
314
|
+
const onCommitRef = useRef(onCommit);
|
|
315
|
+
onCommitRef.current = onCommit;
|
|
316
|
+
// Always this render's committed value — read directly (not via the
|
|
317
|
+
// effect below) by onLostPointerCapture, so the resync there doesn't
|
|
318
|
+
// depend on ordering between the native event and the [value] effect.
|
|
319
|
+
const latestValueRef = useRef(value);
|
|
320
|
+
latestValueRef.current = value;
|
|
321
|
+
// releasePointerCapture() (called explicitly below in onPointerUp/
|
|
322
|
+
// onPointerCancel) fires lostpointercapture SYNCHRONOUSLY in real
|
|
323
|
+
// browsers — i.e. onLostPointerCapture runs mid-onPointerUp, BEFORE
|
|
324
|
+
// onPointerUp's own draggingRef check and final commitDraft. Without this
|
|
325
|
+
// flag, a NORMAL release would have onLostPointerCapture reset
|
|
326
|
+
// draggingRef/draft to the stale value first, making onPointerUp's own
|
|
327
|
+
// "if (!draggingRef.current) return" bail out and silently drop the
|
|
328
|
+
// real final-position commit. Set right before each explicit release
|
|
329
|
+
// call so onLostPointerCapture can tell "our own release, the caller's
|
|
330
|
+
// own logic already handles it" apart from a genuine EXTERNAL capture
|
|
331
|
+
// loss (another element steals it, or the browser reclaims it for a
|
|
332
|
+
// scroll/touch gesture) where no other handler is about to run.
|
|
333
|
+
const explicitReleaseRef = useRef(false);
|
|
334
|
+
// The committed value when the current drag began — Escape and right-click
|
|
335
|
+
// both cancel an in-progress drag by reverting to this, not by leaving
|
|
336
|
+
// whatever position the pointer last reached committed.
|
|
337
|
+
const dragStartValueRef = useRef(value);
|
|
338
|
+
const activePointerIdRef = useRef<number | null>(null);
|
|
339
|
+
|
|
340
|
+
useEffect(() => {
|
|
341
|
+
if (draggingRef.current) return;
|
|
342
|
+
setDraft(value);
|
|
343
|
+
lastCommittedRef.current = value;
|
|
344
|
+
}, [value]);
|
|
345
|
+
useEffect(
|
|
346
|
+
() => () => {
|
|
347
|
+
if (commitTimerRef.current) {
|
|
348
|
+
clearTimeout(commitTimerRef.current);
|
|
349
|
+
// Flush rather than drop a still-queued edit — this only fires if the
|
|
350
|
+
// component unmounts mid-drag (e.g. selection changes away), and
|
|
351
|
+
// silently discarding the user's last dragged position would look
|
|
352
|
+
// like data loss.
|
|
353
|
+
if (pendingRef.current !== null) onCommitRef.current(pendingRef.current);
|
|
354
|
+
}
|
|
355
|
+
},
|
|
356
|
+
[],
|
|
357
|
+
);
|
|
358
|
+
|
|
359
|
+
const clampedPct = Math.max(0, Math.min(100, ((draft - min) / Math.max(max - min, 1e-6)) * 100));
|
|
360
|
+
|
|
361
|
+
const stepFromClientX = (clientX: number, rect: DOMRect) => {
|
|
362
|
+
const ratio = Math.max(0, Math.min(1, (clientX - rect.left) / Math.max(rect.width, 1)));
|
|
363
|
+
const raw = min + ratio * (max - min);
|
|
364
|
+
const stepped = Math.round(raw / step) * step;
|
|
365
|
+
return Math.max(min, Math.min(max, stepped));
|
|
366
|
+
};
|
|
367
|
+
const commitDraft = (nextDraft: number) => {
|
|
368
|
+
if (commitTimerRef.current) {
|
|
369
|
+
clearTimeout(commitTimerRef.current);
|
|
370
|
+
commitTimerRef.current = null;
|
|
371
|
+
}
|
|
372
|
+
pendingRef.current = null;
|
|
373
|
+
lastCommitAtRef.current = Date.now();
|
|
374
|
+
if (nextDraft !== lastCommittedRef.current) {
|
|
375
|
+
lastCommittedRef.current = nextDraft;
|
|
376
|
+
onCommitRef.current(nextDraft);
|
|
377
|
+
}
|
|
378
|
+
};
|
|
379
|
+
const scheduleCommit = (nextDraft: number) => {
|
|
380
|
+
const elapsed = Date.now() - lastCommitAtRef.current;
|
|
381
|
+
if (elapsed >= 40) {
|
|
382
|
+
commitDraft(nextDraft);
|
|
383
|
+
return;
|
|
384
|
+
}
|
|
385
|
+
pendingRef.current = nextDraft;
|
|
386
|
+
if (!commitTimerRef.current) {
|
|
387
|
+
commitTimerRef.current = setTimeout(() => {
|
|
388
|
+
commitTimerRef.current = null;
|
|
389
|
+
if (pendingRef.current !== null) commitDraft(pendingRef.current);
|
|
390
|
+
}, 40 - elapsed);
|
|
391
|
+
}
|
|
392
|
+
};
|
|
393
|
+
// Reverts to the pre-drag value instead of leaving whatever position the
|
|
394
|
+
// pointer last reached committed — the drag's own leading-edge commit (in
|
|
395
|
+
// onPointerDown) may already have applied an intermediate value, so this
|
|
396
|
+
// must go through commitDraft (not just a visual setDraft) to actually
|
|
397
|
+
// undo it.
|
|
398
|
+
const cancelDrag = (target: HTMLDivElement) => {
|
|
399
|
+
if (!draggingRef.current) return;
|
|
400
|
+
draggingRef.current = false;
|
|
401
|
+
const pointerId = activePointerIdRef.current;
|
|
402
|
+
if (pointerId !== null && target.hasPointerCapture(pointerId)) {
|
|
403
|
+
explicitReleaseRef.current = true;
|
|
404
|
+
target.releasePointerCapture(pointerId);
|
|
405
|
+
}
|
|
406
|
+
setDraft(dragStartValueRef.current);
|
|
407
|
+
commitDraft(dragStartValueRef.current);
|
|
408
|
+
};
|
|
409
|
+
|
|
410
|
+
return (
|
|
411
|
+
<div className="flex min-h-[28px] items-center gap-2.5">
|
|
412
|
+
<span className="w-[86px] flex-shrink-0 text-[11px] text-panel-text-3">{label}</span>
|
|
413
|
+
<div
|
|
414
|
+
data-flat-slider-track="true"
|
|
415
|
+
role="slider"
|
|
416
|
+
aria-label={label}
|
|
417
|
+
aria-valuenow={draft}
|
|
418
|
+
aria-valuemin={min}
|
|
419
|
+
aria-valuemax={max}
|
|
420
|
+
aria-disabled={disabled}
|
|
421
|
+
tabIndex={disabled ? -1 : 0}
|
|
422
|
+
style={{ touchAction: "none" }}
|
|
423
|
+
className={`relative h-5 flex-1 ${disabled ? "cursor-not-allowed" : "cursor-pointer"}`}
|
|
424
|
+
onPointerDown={(e) => {
|
|
425
|
+
if (disabled) return;
|
|
426
|
+
draggingRef.current = true;
|
|
427
|
+
dragStartValueRef.current = latestValueRef.current;
|
|
428
|
+
activePointerIdRef.current = e.pointerId;
|
|
429
|
+
e.currentTarget.setPointerCapture(e.pointerId);
|
|
430
|
+
const stepped = stepFromClientX(e.clientX, e.currentTarget.getBoundingClientRect());
|
|
431
|
+
setDraft(stepped);
|
|
432
|
+
scheduleCommit(stepped);
|
|
433
|
+
}}
|
|
434
|
+
onPointerMove={(e) => {
|
|
435
|
+
if (disabled || !e.currentTarget.hasPointerCapture(e.pointerId)) return;
|
|
436
|
+
const stepped = stepFromClientX(e.clientX, e.currentTarget.getBoundingClientRect());
|
|
437
|
+
setDraft(stepped);
|
|
438
|
+
scheduleCommit(stepped);
|
|
439
|
+
}}
|
|
440
|
+
onPointerUp={(e) => {
|
|
441
|
+
if (e.currentTarget.hasPointerCapture(e.pointerId)) {
|
|
442
|
+
explicitReleaseRef.current = true;
|
|
443
|
+
e.currentTarget.releasePointerCapture(e.pointerId);
|
|
444
|
+
}
|
|
445
|
+
if (disabled) return;
|
|
446
|
+
if (!draggingRef.current) return;
|
|
447
|
+
draggingRef.current = false;
|
|
448
|
+
// Recompute from the event itself rather than reading the `draft`
|
|
449
|
+
// closure — if pointerdown+pointerup land in the same React batch
|
|
450
|
+
// (e.g. a very fast click), the onPointerUp handler can still be
|
|
451
|
+
// bound to the pre-drag render, making `draft` stale.
|
|
452
|
+
const stepped = stepFromClientX(e.clientX, e.currentTarget.getBoundingClientRect());
|
|
453
|
+
setDraft(stepped);
|
|
454
|
+
commitDraft(stepped);
|
|
455
|
+
if (stepped !== dragStartValueRef.current) track("slider", label);
|
|
456
|
+
}}
|
|
457
|
+
onPointerCancel={(e) => {
|
|
458
|
+
// A native pointercancel means the platform aborted the gesture (a
|
|
459
|
+
// scroll/touch takeover, pen leaving range, etc.) — that must cancel
|
|
460
|
+
// the drag the same way Escape/right-click do (revert to the
|
|
461
|
+
// pre-drag value), not just stop dragging and leave whatever
|
|
462
|
+
// intermediate position the pointer last reached committed.
|
|
463
|
+
cancelDrag(e.currentTarget);
|
|
464
|
+
}}
|
|
465
|
+
onLostPointerCapture={() => {
|
|
466
|
+
if (explicitReleaseRef.current) {
|
|
467
|
+
// Our own onPointerUp/onPointerCancel just released capture —
|
|
468
|
+
// their own logic already handles (or intentionally leaves)
|
|
469
|
+
// draggingRef/draft correctly. Resyncing here too would race
|
|
470
|
+
// onPointerUp's still-pending final commitDraft(stepped) below
|
|
471
|
+
// this call, since draggingRef flipping false would make its
|
|
472
|
+
// own "if (!draggingRef.current) return" bail out first.
|
|
473
|
+
explicitReleaseRef.current = false;
|
|
474
|
+
return;
|
|
475
|
+
}
|
|
476
|
+
// A genuine EXTERNAL capture loss (another element steals it, or
|
|
477
|
+
// the browser reclaims it for a scroll/touch gesture) — no other
|
|
478
|
+
// handler is about to run, so resync immediately and directly
|
|
479
|
+
// from latestValueRef rather than only clearing draggingRef and
|
|
480
|
+
// waiting for the [value] effect to notice (that effect depends
|
|
481
|
+
// on `value` actually changing again to re-run).
|
|
482
|
+
draggingRef.current = false;
|
|
483
|
+
setDraft(latestValueRef.current);
|
|
484
|
+
lastCommittedRef.current = latestValueRef.current;
|
|
485
|
+
}}
|
|
486
|
+
onKeyDown={(e) => {
|
|
487
|
+
if (disabled) return;
|
|
488
|
+
if (e.key === "Escape" && draggingRef.current) {
|
|
489
|
+
e.preventDefault();
|
|
490
|
+
cancelDrag(e.currentTarget);
|
|
491
|
+
return;
|
|
492
|
+
}
|
|
493
|
+
const next = sliderKeyTarget(e.key, draft, min, max, step);
|
|
494
|
+
if (next === null) return;
|
|
495
|
+
e.preventDefault();
|
|
496
|
+
setDraft(next);
|
|
497
|
+
commitDraft(next);
|
|
498
|
+
if (next !== draft) track("slider", label);
|
|
499
|
+
}}
|
|
500
|
+
onContextMenu={(e) => {
|
|
501
|
+
// Right-click during a drag must cancel it (revert to the pre-drag
|
|
502
|
+
// value), not leave the last dragged-to position committed while
|
|
503
|
+
// the native context menu opens on top of the slider.
|
|
504
|
+
if (!draggingRef.current) return;
|
|
505
|
+
e.preventDefault();
|
|
506
|
+
cancelDrag(e.currentTarget);
|
|
507
|
+
}}
|
|
508
|
+
>
|
|
509
|
+
<div className="absolute inset-x-0 top-1/2 h-0.5 -translate-y-1/2 rounded-full bg-panel-hover">
|
|
510
|
+
{centerTick && (
|
|
511
|
+
<div
|
|
512
|
+
data-flat-slider-center-tick="true"
|
|
513
|
+
className="absolute left-1/2 top-[-1px] h-1 w-px -translate-x-1/2 bg-panel-text-5"
|
|
514
|
+
/>
|
|
515
|
+
)}
|
|
516
|
+
{tier === "explicitCustom" && (
|
|
517
|
+
<div
|
|
518
|
+
data-flat-slider-fill="true"
|
|
519
|
+
className="absolute inset-y-0 left-0 rounded-full bg-panel-text-5"
|
|
520
|
+
style={{ width: `${clampedPct}%` }}
|
|
521
|
+
/>
|
|
522
|
+
)}
|
|
523
|
+
</div>
|
|
524
|
+
<div
|
|
525
|
+
data-flat-slider-knob="true"
|
|
526
|
+
className={`absolute top-1/2 -translate-x-1/2 -translate-y-1/2 rounded-full ${
|
|
527
|
+
tier === "explicitCustom" ? "h-2 w-2 bg-white" : "h-[7px] w-[7px] bg-panel-text-4"
|
|
528
|
+
}`}
|
|
529
|
+
style={{ left: `${clampedPct}%` }}
|
|
530
|
+
/>
|
|
531
|
+
</div>
|
|
532
|
+
<span
|
|
533
|
+
data-flat-slider-value="true"
|
|
534
|
+
className={`w-11 flex-shrink-0 text-right font-mono text-[10px] ${
|
|
535
|
+
tier === "explicitCustom" ? "text-panel-text-0" : "text-panel-text-3"
|
|
536
|
+
}`}
|
|
537
|
+
>
|
|
538
|
+
{displayValue}
|
|
539
|
+
</span>
|
|
540
|
+
{(centerTick || onReset) && (
|
|
541
|
+
<span data-flat-slider-reset-slot="true" className="w-3.5 flex-shrink-0">
|
|
542
|
+
{tier === "explicitCustom" && onReset && (
|
|
543
|
+
<button
|
|
544
|
+
type="button"
|
|
545
|
+
data-flat-slider-reset="true"
|
|
546
|
+
title="Remove — fall back to default"
|
|
547
|
+
disabled={disabled}
|
|
548
|
+
onClick={() => {
|
|
549
|
+
track("button", `Reset ${label}`);
|
|
550
|
+
onReset();
|
|
551
|
+
}}
|
|
552
|
+
className="text-panel-text-3 hover:text-panel-text-1 disabled:cursor-not-allowed disabled:opacity-40"
|
|
553
|
+
>
|
|
554
|
+
<RotateCcw size={11} />
|
|
555
|
+
</button>
|
|
556
|
+
)}
|
|
557
|
+
</span>
|
|
558
|
+
)}
|
|
559
|
+
</div>
|
|
560
|
+
);
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
export { FlatSelectRow } from "./propertyPanelFlatSelectRow";
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import { RotateCcw } from "../../icons/SystemIcons";
|
|
2
|
+
import { useTrackDesignInput } from "../../contexts/DesignPanelInputContext";
|
|
3
|
+
import {
|
|
4
|
+
VALUE_TIER_LABEL_CLASS,
|
|
5
|
+
VALUE_TIER_VALUE_CLASS,
|
|
6
|
+
type PropertyValueTier,
|
|
7
|
+
} from "./propertyPanelValueTier";
|
|
8
|
+
|
|
9
|
+
/* ------------------------------------------------------------------ */
|
|
10
|
+
/* FlatSelectRow — label/value row backed by a native <select> */
|
|
11
|
+
/* ------------------------------------------------------------------ */
|
|
12
|
+
|
|
13
|
+
export function FlatSelectRow({
|
|
14
|
+
label,
|
|
15
|
+
ariaLabel,
|
|
16
|
+
value,
|
|
17
|
+
options,
|
|
18
|
+
tier,
|
|
19
|
+
disabled,
|
|
20
|
+
onChange,
|
|
21
|
+
onReset,
|
|
22
|
+
}: {
|
|
23
|
+
label: string;
|
|
24
|
+
/** Accessible name when a caller renders the visible label OUTSIDE this
|
|
25
|
+
* row (label="" to avoid a duplicate) — e.g. Grade's "Preset" row, which
|
|
26
|
+
* shows its own label span and would otherwise leave the <select>
|
|
27
|
+
* unnamed. Falls back to `label` when omitted. */
|
|
28
|
+
ariaLabel?: string;
|
|
29
|
+
value: string;
|
|
30
|
+
options: Array<string | { value: string; label: string }>;
|
|
31
|
+
tier: PropertyValueTier;
|
|
32
|
+
disabled?: boolean;
|
|
33
|
+
onChange: (nextValue: string) => void;
|
|
34
|
+
onReset?: () => void;
|
|
35
|
+
}) {
|
|
36
|
+
const track = useTrackDesignInput();
|
|
37
|
+
const trackName = ariaLabel || label;
|
|
38
|
+
const normalizedOptions = options.map((option) =>
|
|
39
|
+
typeof option === "string" ? { value: option, label: option } : option,
|
|
40
|
+
);
|
|
41
|
+
// A valid authored value outside the preset list (e.g. a `mix-blend-mode`
|
|
42
|
+
// or `object-position` this row doesn't offer as a preset) must not be
|
|
43
|
+
// silently misrepresented as the first option — the native <select> falls
|
|
44
|
+
// back to selectedIndex 0 when `value` matches no <option>, and reselecting
|
|
45
|
+
// that visible-but-wrong preset overwrites the real persisted value. Prepend
|
|
46
|
+
// the current value so it's always representable, matching legacy
|
|
47
|
+
// `SelectField`'s same guard.
|
|
48
|
+
const renderedOptions =
|
|
49
|
+
value && !normalizedOptions.some((option) => option.value === value)
|
|
50
|
+
? [{ value, label: value }, ...normalizedOptions]
|
|
51
|
+
: normalizedOptions;
|
|
52
|
+
return (
|
|
53
|
+
<div className="group flex min-h-[30px] items-center justify-between">
|
|
54
|
+
<span className={`text-[11px] ${VALUE_TIER_LABEL_CLASS[tier]}`}>{label}</span>
|
|
55
|
+
<span className="flex items-center gap-2">
|
|
56
|
+
<label
|
|
57
|
+
className={`flex items-center gap-1.5 border-b pb-px ${
|
|
58
|
+
tier === "explicitCustom"
|
|
59
|
+
? "border-panel-accent/30 group-hover:border-panel-accent/70"
|
|
60
|
+
: "border-panel-border-input/50 group-hover:border-panel-border-input"
|
|
61
|
+
}`}
|
|
62
|
+
>
|
|
63
|
+
<select
|
|
64
|
+
value={value}
|
|
65
|
+
disabled={disabled}
|
|
66
|
+
aria-label={ariaLabel || label || undefined}
|
|
67
|
+
onChange={(e) => {
|
|
68
|
+
track("select", trackName);
|
|
69
|
+
onChange(e.target.value);
|
|
70
|
+
}}
|
|
71
|
+
className={`appearance-none bg-transparent text-right font-mono text-[11px] outline-none disabled:cursor-not-allowed ${VALUE_TIER_VALUE_CLASS[tier]}`}
|
|
72
|
+
>
|
|
73
|
+
{renderedOptions.map((option) => (
|
|
74
|
+
<option key={option.value} value={option.value}>
|
|
75
|
+
{option.label}
|
|
76
|
+
</option>
|
|
77
|
+
))}
|
|
78
|
+
</select>
|
|
79
|
+
<svg
|
|
80
|
+
width="10"
|
|
81
|
+
height="10"
|
|
82
|
+
viewBox="0 0 10 10"
|
|
83
|
+
fill="currentColor"
|
|
84
|
+
className="flex-shrink-0 text-panel-text-5"
|
|
85
|
+
>
|
|
86
|
+
<path d="M2 3l3 4 3-4z" />
|
|
87
|
+
</svg>
|
|
88
|
+
</label>
|
|
89
|
+
{tier === "explicitCustom" && onReset && (
|
|
90
|
+
<button
|
|
91
|
+
type="button"
|
|
92
|
+
data-flat-select-reset="true"
|
|
93
|
+
title="Remove — fall back to default"
|
|
94
|
+
disabled={disabled}
|
|
95
|
+
onClick={() => {
|
|
96
|
+
track("button", `Reset ${trackName}`);
|
|
97
|
+
onReset();
|
|
98
|
+
}}
|
|
99
|
+
className="flex-shrink-0 text-panel-text-3 opacity-0 transition-opacity hover:text-panel-text-1 group-hover:opacity-100 disabled:cursor-not-allowed disabled:opacity-40"
|
|
100
|
+
>
|
|
101
|
+
<RotateCcw size={11} />
|
|
102
|
+
</button>
|
|
103
|
+
)}
|
|
104
|
+
</span>
|
|
105
|
+
</div>
|
|
106
|
+
);
|
|
107
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { formatStrokeSummary, parseStrokeSummary } from "./propertyPanelFlatStyleHelpers";
|
|
3
|
+
|
|
4
|
+
describe("formatStrokeSummary", () => {
|
|
5
|
+
it("formats width and style into one string", () => {
|
|
6
|
+
expect(formatStrokeSummary(1, "solid")).toBe("1px solid");
|
|
7
|
+
expect(formatStrokeSummary(2.5, "dashed")).toBe("2.5px dashed");
|
|
8
|
+
expect(formatStrokeSummary(0, "none")).toBe("0px none");
|
|
9
|
+
});
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
describe("parseStrokeSummary", () => {
|
|
13
|
+
it("parses a well-formed summary back into width and style", () => {
|
|
14
|
+
expect(parseStrokeSummary("1px solid")).toEqual({ widthPx: 1, style: "solid" });
|
|
15
|
+
expect(parseStrokeSummary(" 2.5px dashed ")).toEqual({ widthPx: 2.5, style: "dashed" });
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
it("returns null for unparseable input", () => {
|
|
19
|
+
expect(parseStrokeSummary("garbage")).toBeNull();
|
|
20
|
+
expect(parseStrokeSummary("")).toBeNull();
|
|
21
|
+
expect(parseStrokeSummary("1px")).toBeNull();
|
|
22
|
+
});
|
|
23
|
+
});
|