@hyperframes/studio 0.6.0-alpha.9 → 0.6.0
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-DjsVzYFP.js → hyperframes-player-DOFETgjy.js} +1 -1
- package/dist/assets/index-D1JDq7Gg.css +1 -0
- package/dist/assets/index-DUqUmaoH.js +117 -0
- package/dist/favicon.svg +14 -0
- package/dist/index.html +3 -2
- package/package.json +9 -9
- package/src/App.tsx +428 -4299
- package/src/components/AskAgentModal.tsx +120 -0
- package/src/components/StudioHeader.tsx +133 -0
- package/src/components/StudioLeftSidebar.tsx +125 -0
- package/src/components/StudioPreviewArea.tsx +163 -0
- package/src/components/StudioRightPanel.tsx +198 -0
- package/src/components/TimelineToolbar.tsx +89 -0
- package/src/components/editor/DomEditOverlay.tsx +15 -1
- package/src/components/editor/PropertyPanel.test.ts +0 -49
- package/src/components/editor/PropertyPanel.tsx +132 -2763
- package/src/components/editor/domEditing.ts +38 -5
- package/src/components/editor/manualEditingAvailability.test.ts +2 -2
- package/src/components/editor/manualEditingAvailability.ts +1 -1
- package/src/components/editor/manualEdits.ts +32 -0
- package/src/components/editor/propertyPanelColor.tsx +371 -0
- package/src/components/editor/propertyPanelFill.tsx +421 -0
- package/src/components/editor/propertyPanelFont.tsx +455 -0
- package/src/components/editor/propertyPanelHelpers.ts +401 -0
- package/src/components/editor/propertyPanelPrimitives.tsx +357 -0
- package/src/components/editor/propertyPanelSections.tsx +453 -0
- package/src/components/editor/propertyPanelStyleSections.tsx +411 -0
- package/src/components/nle/NLELayout.tsx +8 -11
- package/src/components/nle/NLEPreview.tsx +3 -0
- package/src/components/renders/RenderQueue.tsx +102 -31
- package/src/components/renders/useRenderQueue.ts +8 -2
- package/src/components/sidebar/LeftSidebar.tsx +186 -186
- package/src/contexts/DomEditContext.tsx +137 -0
- package/src/contexts/FileManagerContext.tsx +110 -0
- package/src/contexts/PanelLayoutContext.tsx +68 -0
- package/src/contexts/StudioContext.tsx +135 -0
- package/src/hooks/useAppHotkeys.ts +326 -0
- package/src/hooks/useAskAgentModal.ts +162 -0
- package/src/hooks/useCaptionDetection.ts +132 -0
- package/src/hooks/useCompositionDimensions.ts +25 -0
- package/src/hooks/useConsoleErrorCapture.ts +60 -0
- package/src/hooks/useDomEditCommits.ts +437 -0
- package/src/hooks/useDomEditSession.ts +342 -0
- package/src/hooks/useDomEditTextCommits.ts +330 -0
- package/src/hooks/useDomSelection.ts +398 -0
- package/src/hooks/useFileManager.ts +431 -0
- package/src/hooks/useFrameCapture.ts +77 -0
- package/src/hooks/useLintModal.ts +35 -0
- package/src/hooks/useManifestPersistence.ts +492 -0
- package/src/hooks/usePanelLayout.ts +68 -0
- package/src/hooks/usePreviewInteraction.ts +153 -0
- package/src/hooks/useRenderClipContent.ts +124 -0
- package/src/hooks/useTimelineEditing.ts +472 -0
- package/src/player/components/Player.tsx +33 -2
- package/src/player/components/Timeline.test.ts +0 -8
- package/src/player/components/Timeline.tsx +10 -103
- package/src/player/components/TimelineClip.tsx +9 -244
- package/src/player/hooks/useTimelinePlayer.ts +140 -103
- package/src/utils/domEditHelpers.ts +50 -0
- package/src/utils/studioFontHelpers.ts +83 -0
- package/src/utils/studioHelpers.ts +214 -0
- package/src/utils/studioPreviewHelpers.ts +185 -0
- package/src/utils/timelineDiscovery.ts +1 -1
- package/dist/assets/index-14zH9lqh.css +0 -1
- package/dist/assets/index-DYCiFGWQ.js +0 -108
- package/src/player/components/TimelineClip.test.ts +0 -92
|
@@ -0,0 +1,421 @@
|
|
|
1
|
+
import { useMemo, useRef, useState } from "react";
|
|
2
|
+
import { Plus, RotateCcw, X } from "../../icons/SystemIcons";
|
|
3
|
+
import {
|
|
4
|
+
buildDefaultGradientModel,
|
|
5
|
+
insertGradientStop,
|
|
6
|
+
parseGradient,
|
|
7
|
+
serializeGradient,
|
|
8
|
+
type GradientModel,
|
|
9
|
+
} from "./gradientValue";
|
|
10
|
+
import { IMAGE_EXT } from "../../utils/mediaTypes";
|
|
11
|
+
import { FIELD, LABEL, RESPONSIVE_GRID } from "./propertyPanelHelpers";
|
|
12
|
+
import {
|
|
13
|
+
DetailField,
|
|
14
|
+
SelectField,
|
|
15
|
+
SegmentedControl,
|
|
16
|
+
SliderControl,
|
|
17
|
+
} from "./propertyPanelPrimitives";
|
|
18
|
+
import { ColorField } from "./propertyPanelColor";
|
|
19
|
+
|
|
20
|
+
/* ------------------------------------------------------------------ */
|
|
21
|
+
/* Asset path helpers */
|
|
22
|
+
/* ------------------------------------------------------------------ */
|
|
23
|
+
|
|
24
|
+
function normalizeProjectPath(value: string): string {
|
|
25
|
+
const trimmed = value.trim();
|
|
26
|
+
const maybeUrl = /^[a-z]+:\/\//i.test(trimmed) ? new URL(trimmed).pathname : trimmed;
|
|
27
|
+
return decodeURIComponent(maybeUrl)
|
|
28
|
+
.replace(/\\/g, "/")
|
|
29
|
+
.replace(/^\.?\//, "");
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function toRelativeProjectAssetPath(sourceFile: string, assetPath: string): string {
|
|
33
|
+
const fromParts = normalizeProjectPath(sourceFile).split("/").filter(Boolean);
|
|
34
|
+
const targetParts = normalizeProjectPath(assetPath).split("/").filter(Boolean);
|
|
35
|
+
fromParts.pop();
|
|
36
|
+
while (fromParts.length > 0 && targetParts.length > 0 && fromParts[0] === targetParts[0]) {
|
|
37
|
+
fromParts.shift();
|
|
38
|
+
targetParts.shift();
|
|
39
|
+
}
|
|
40
|
+
return [...fromParts.map(() => ".."), ...targetParts].join("/") || assetPath;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function toProjectRootAssetPath(assetPath: string): string {
|
|
44
|
+
return normalizeProjectPath(assetPath);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function resolveSelectedAsset(
|
|
48
|
+
imageUrl: string,
|
|
49
|
+
sourceFile: string,
|
|
50
|
+
assets: string[],
|
|
51
|
+
): string | null {
|
|
52
|
+
const normalizedUrl = normalizeProjectPath(imageUrl);
|
|
53
|
+
if (!normalizedUrl) return null;
|
|
54
|
+
for (const asset of assets) {
|
|
55
|
+
const normalizedAsset = normalizeProjectPath(asset);
|
|
56
|
+
const relativeAsset = toRelativeProjectAssetPath(sourceFile, asset);
|
|
57
|
+
if (
|
|
58
|
+
normalizedUrl === normalizedAsset ||
|
|
59
|
+
normalizedUrl === relativeAsset ||
|
|
60
|
+
normalizedUrl.endsWith(`/${normalizedAsset}`) ||
|
|
61
|
+
normalizedUrl.endsWith(`/${relativeAsset}`)
|
|
62
|
+
) {
|
|
63
|
+
return asset;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
return null;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/* ------------------------------------------------------------------ */
|
|
70
|
+
/* ImageFillField */
|
|
71
|
+
/* ------------------------------------------------------------------ */
|
|
72
|
+
|
|
73
|
+
export function ImageFillField({
|
|
74
|
+
projectId,
|
|
75
|
+
sourceFile,
|
|
76
|
+
value,
|
|
77
|
+
assets,
|
|
78
|
+
disabled,
|
|
79
|
+
onCommit,
|
|
80
|
+
onImportAssets,
|
|
81
|
+
}: {
|
|
82
|
+
projectId: string;
|
|
83
|
+
sourceFile: string;
|
|
84
|
+
value: string;
|
|
85
|
+
assets: string[];
|
|
86
|
+
disabled?: boolean;
|
|
87
|
+
onCommit: (nextValue: string) => void;
|
|
88
|
+
onImportAssets?: (files: FileList) => Promise<string[]>;
|
|
89
|
+
}) {
|
|
90
|
+
const fileInputRef = useRef<HTMLInputElement | null>(null);
|
|
91
|
+
const [uploading, setUploading] = useState(false);
|
|
92
|
+
const imageAssets = useMemo(() => assets.filter((a) => IMAGE_EXT.test(a)), [assets]);
|
|
93
|
+
const selectedAsset = useMemo(
|
|
94
|
+
() => resolveSelectedAsset(value, sourceFile, imageAssets),
|
|
95
|
+
[imageAssets, sourceFile, value],
|
|
96
|
+
);
|
|
97
|
+
const externalUrlValue = selectedAsset ? "" : value;
|
|
98
|
+
|
|
99
|
+
const handleUpload = async (files: FileList | null) => {
|
|
100
|
+
if (!files?.length || !onImportAssets) return;
|
|
101
|
+
setUploading(true);
|
|
102
|
+
try {
|
|
103
|
+
const uploaded = await onImportAssets(files);
|
|
104
|
+
const nextImage = uploaded.find((a) => IMAGE_EXT.test(a));
|
|
105
|
+
if (nextImage) onCommit(`url("${toProjectRootAssetPath(nextImage)}")`);
|
|
106
|
+
} finally {
|
|
107
|
+
setUploading(false);
|
|
108
|
+
}
|
|
109
|
+
};
|
|
110
|
+
|
|
111
|
+
return (
|
|
112
|
+
<div className="space-y-4">
|
|
113
|
+
<div className="grid min-w-0 gap-1.5">
|
|
114
|
+
<div className="flex min-w-0 flex-wrap items-center justify-between gap-2">
|
|
115
|
+
<span className={LABEL}>Project asset</span>
|
|
116
|
+
<button
|
|
117
|
+
type="button"
|
|
118
|
+
disabled={disabled || uploading}
|
|
119
|
+
onClick={() => fileInputRef.current?.click()}
|
|
120
|
+
className={`inline-flex h-7 max-w-full items-center gap-1.5 rounded-lg border border-neutral-700 bg-neutral-950 px-2.5 text-[11px] font-medium text-neutral-300 transition-colors ${
|
|
121
|
+
disabled || uploading
|
|
122
|
+
? "cursor-not-allowed text-neutral-600"
|
|
123
|
+
: "cursor-pointer hover:border-neutral-600 hover:text-white"
|
|
124
|
+
}`}
|
|
125
|
+
>
|
|
126
|
+
<Plus size={12} className="flex-shrink-0" />
|
|
127
|
+
<span className="truncate">{uploading ? "Uploading…" : "Upload image"}</span>
|
|
128
|
+
</button>
|
|
129
|
+
<input
|
|
130
|
+
ref={fileInputRef}
|
|
131
|
+
type="file"
|
|
132
|
+
accept="image/*"
|
|
133
|
+
aria-label="Upload image asset"
|
|
134
|
+
disabled={disabled || uploading}
|
|
135
|
+
className="hidden"
|
|
136
|
+
onChange={async (event) => {
|
|
137
|
+
await handleUpload(event.target.files);
|
|
138
|
+
event.target.value = "";
|
|
139
|
+
}}
|
|
140
|
+
/>
|
|
141
|
+
</div>
|
|
142
|
+
{imageAssets.length > 0 ? (
|
|
143
|
+
<div className="space-y-3">
|
|
144
|
+
{selectedAsset && (
|
|
145
|
+
<div className="overflow-hidden rounded-xl border border-neutral-800 bg-neutral-900/80">
|
|
146
|
+
<img
|
|
147
|
+
src={`/api/projects/${projectId}/preview/${selectedAsset}`}
|
|
148
|
+
alt={selectedAsset.split("/").pop() ?? selectedAsset}
|
|
149
|
+
className="h-28 w-full object-contain bg-neutral-950/80"
|
|
150
|
+
/>
|
|
151
|
+
</div>
|
|
152
|
+
)}
|
|
153
|
+
<div className={FIELD}>
|
|
154
|
+
<select
|
|
155
|
+
value={selectedAsset ?? ""}
|
|
156
|
+
disabled={disabled}
|
|
157
|
+
onChange={(e) => {
|
|
158
|
+
const next = e.target.value;
|
|
159
|
+
if (!next) {
|
|
160
|
+
onCommit("none");
|
|
161
|
+
return;
|
|
162
|
+
}
|
|
163
|
+
onCommit(`url("${toProjectRootAssetPath(next)}")`);
|
|
164
|
+
}}
|
|
165
|
+
className="min-w-0 w-full appearance-none bg-transparent text-[11px] font-medium text-neutral-100 outline-none disabled:cursor-not-allowed disabled:text-neutral-600"
|
|
166
|
+
>
|
|
167
|
+
<option value="">None</option>
|
|
168
|
+
{imageAssets.map((asset) => (
|
|
169
|
+
<option key={asset} value={asset}>
|
|
170
|
+
{asset}
|
|
171
|
+
</option>
|
|
172
|
+
))}
|
|
173
|
+
</select>
|
|
174
|
+
</div>
|
|
175
|
+
</div>
|
|
176
|
+
) : (
|
|
177
|
+
<div className="rounded-xl border border-dashed border-neutral-800 bg-neutral-900/50 px-3 py-3 text-[11px] leading-5 text-neutral-500">
|
|
178
|
+
No image assets yet. Upload one here and Studio will also add it to the Assets tab.
|
|
179
|
+
</div>
|
|
180
|
+
)}
|
|
181
|
+
</div>
|
|
182
|
+
|
|
183
|
+
<DetailField
|
|
184
|
+
label="External URL"
|
|
185
|
+
value={externalUrlValue}
|
|
186
|
+
disabled={disabled}
|
|
187
|
+
onCommit={(next) => onCommit(next.trim() ? `url("${next.trim()}")` : "none")}
|
|
188
|
+
/>
|
|
189
|
+
</div>
|
|
190
|
+
);
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/* ------------------------------------------------------------------ */
|
|
194
|
+
/* GradientField */
|
|
195
|
+
/* ------------------------------------------------------------------ */
|
|
196
|
+
|
|
197
|
+
export function GradientField({
|
|
198
|
+
value,
|
|
199
|
+
fallbackColor,
|
|
200
|
+
disabled,
|
|
201
|
+
onCommit,
|
|
202
|
+
}: {
|
|
203
|
+
value: string;
|
|
204
|
+
fallbackColor: string | undefined;
|
|
205
|
+
disabled?: boolean;
|
|
206
|
+
onCommit: (nextValue: string) => void;
|
|
207
|
+
}) {
|
|
208
|
+
const previewRef = useRef<HTMLDivElement | null>(null);
|
|
209
|
+
const parsed = parseGradient(value) ?? buildDefaultGradientModel(fallbackColor);
|
|
210
|
+
|
|
211
|
+
const commit = (next: GradientModel) => onCommit(serializeGradient(next));
|
|
212
|
+
const patch = (partial: Partial<GradientModel>) => commit({ ...parsed, ...partial });
|
|
213
|
+
|
|
214
|
+
const updateStop = (index: number, partial: Partial<GradientModel["stops"][number]>) => {
|
|
215
|
+
const stops = parsed.stops.map((stop, i) => (i === index ? { ...stop, ...partial } : stop));
|
|
216
|
+
commit({ ...parsed, stops });
|
|
217
|
+
};
|
|
218
|
+
|
|
219
|
+
const addStop = (position?: number) => {
|
|
220
|
+
const nextGradient =
|
|
221
|
+
position != null
|
|
222
|
+
? insertGradientStop(parsed, position)
|
|
223
|
+
: insertGradientStop(
|
|
224
|
+
parsed,
|
|
225
|
+
parsed.stops.at(-1)?.position != null
|
|
226
|
+
? Math.min(100, (parsed.stops.at(-1)?.position ?? 90) + 10)
|
|
227
|
+
: 100,
|
|
228
|
+
);
|
|
229
|
+
commit(nextGradient);
|
|
230
|
+
};
|
|
231
|
+
|
|
232
|
+
const removeStop = (index: number) => {
|
|
233
|
+
if (parsed.stops.length <= 2) return;
|
|
234
|
+
commit({ ...parsed, stops: parsed.stops.filter((_, i) => i !== index) });
|
|
235
|
+
};
|
|
236
|
+
|
|
237
|
+
const previewStyle = { backgroundImage: serializeGradient(parsed) };
|
|
238
|
+
|
|
239
|
+
return (
|
|
240
|
+
<div className="space-y-4">
|
|
241
|
+
<div className={`${FIELD} space-y-3 p-3`}>
|
|
242
|
+
<div
|
|
243
|
+
ref={previewRef}
|
|
244
|
+
className="relative h-11 overflow-hidden rounded-lg border border-neutral-700"
|
|
245
|
+
style={previewStyle}
|
|
246
|
+
onClick={(event) => {
|
|
247
|
+
if (disabled) return;
|
|
248
|
+
const rect = previewRef.current?.getBoundingClientRect();
|
|
249
|
+
if (!rect || rect.width <= 0) return;
|
|
250
|
+
addStop(((event.clientX - rect.left) / rect.width) * 100);
|
|
251
|
+
}}
|
|
252
|
+
>
|
|
253
|
+
{parsed.stops.map((stop, index) => (
|
|
254
|
+
<div
|
|
255
|
+
key={`stop-preview-${index}`}
|
|
256
|
+
className="absolute top-1/2 h-4 w-4 -translate-y-1/2 rounded-full border-2 border-white/90 shadow-[0_0_0_1px_rgba(0,0,0,0.35)]"
|
|
257
|
+
style={{
|
|
258
|
+
left: `calc(${stop.position}% - 8px)`,
|
|
259
|
+
backgroundColor: stop.color,
|
|
260
|
+
}}
|
|
261
|
+
/>
|
|
262
|
+
))}
|
|
263
|
+
</div>
|
|
264
|
+
<div className="flex min-w-0 flex-wrap items-center gap-2">
|
|
265
|
+
<SegmentedControl
|
|
266
|
+
disabled={disabled}
|
|
267
|
+
value={parsed.kind}
|
|
268
|
+
onChange={(next) => patch({ kind: next as GradientModel["kind"] })}
|
|
269
|
+
options={[
|
|
270
|
+
{ label: "Linear", value: "linear" },
|
|
271
|
+
{ label: "Radial", value: "radial" },
|
|
272
|
+
{ label: "Conic", value: "conic" },
|
|
273
|
+
]}
|
|
274
|
+
/>
|
|
275
|
+
<label className="flex items-center gap-2 text-[11px] font-medium text-neutral-400">
|
|
276
|
+
<input
|
|
277
|
+
type="checkbox"
|
|
278
|
+
checked={parsed.repeating}
|
|
279
|
+
disabled={disabled}
|
|
280
|
+
onChange={(e) => patch({ repeating: e.target.checked })}
|
|
281
|
+
className="h-4 w-4 rounded border-neutral-700 bg-neutral-950 text-[#3ce6ac] focus:ring-[#3ce6ac]"
|
|
282
|
+
/>
|
|
283
|
+
Repeat
|
|
284
|
+
</label>
|
|
285
|
+
<button
|
|
286
|
+
type="button"
|
|
287
|
+
disabled={disabled}
|
|
288
|
+
onClick={() =>
|
|
289
|
+
commit({
|
|
290
|
+
...parsed,
|
|
291
|
+
stops: [...parsed.stops].reverse().map((stop) => ({
|
|
292
|
+
...stop,
|
|
293
|
+
position: 100 - stop.position,
|
|
294
|
+
})),
|
|
295
|
+
})
|
|
296
|
+
}
|
|
297
|
+
className="inline-flex h-7 items-center gap-1.5 rounded-lg border border-neutral-700 bg-neutral-950 px-2.5 text-[11px] font-medium text-neutral-300 transition-colors hover:border-neutral-600 hover:text-white disabled:cursor-not-allowed disabled:text-neutral-600"
|
|
298
|
+
>
|
|
299
|
+
<RotateCcw size={12} />
|
|
300
|
+
Reverse
|
|
301
|
+
</button>
|
|
302
|
+
</div>
|
|
303
|
+
</div>
|
|
304
|
+
|
|
305
|
+
{(parsed.kind === "linear" || parsed.kind === "conic") && (
|
|
306
|
+
<div className="grid gap-1.5">
|
|
307
|
+
<span className={LABEL}>{parsed.kind === "linear" ? "Angle" : "Start angle"}</span>
|
|
308
|
+
<SliderControl
|
|
309
|
+
value={parsed.angle}
|
|
310
|
+
min={0}
|
|
311
|
+
max={360}
|
|
312
|
+
step={1}
|
|
313
|
+
disabled={disabled}
|
|
314
|
+
displayValue={`${Math.round(parsed.angle)}°`}
|
|
315
|
+
formatDisplayValue={(next) => `${Math.round(next)}°`}
|
|
316
|
+
onCommit={(next) => patch({ angle: next })}
|
|
317
|
+
/>
|
|
318
|
+
</div>
|
|
319
|
+
)}
|
|
320
|
+
|
|
321
|
+
{parsed.kind === "radial" && (
|
|
322
|
+
<div className={RESPONSIVE_GRID}>
|
|
323
|
+
<SelectField
|
|
324
|
+
label="Shape"
|
|
325
|
+
value={parsed.shape}
|
|
326
|
+
disabled={disabled}
|
|
327
|
+
onChange={(next) => patch({ shape: next as GradientModel["shape"] })}
|
|
328
|
+
options={["ellipse", "circle"]}
|
|
329
|
+
/>
|
|
330
|
+
<SelectField
|
|
331
|
+
label="Size"
|
|
332
|
+
value={parsed.radialSize}
|
|
333
|
+
disabled={disabled}
|
|
334
|
+
onChange={(next) => patch({ radialSize: next as GradientModel["radialSize"] })}
|
|
335
|
+
options={["closest-side", "closest-corner", "farthest-side", "farthest-corner"]}
|
|
336
|
+
/>
|
|
337
|
+
</div>
|
|
338
|
+
)}
|
|
339
|
+
|
|
340
|
+
{(parsed.kind === "radial" || parsed.kind === "conic") && (
|
|
341
|
+
<div className={RESPONSIVE_GRID}>
|
|
342
|
+
<div className="grid min-w-0 gap-1.5">
|
|
343
|
+
<span className={LABEL}>Center X</span>
|
|
344
|
+
<SliderControl
|
|
345
|
+
value={parsed.centerX}
|
|
346
|
+
min={0}
|
|
347
|
+
max={100}
|
|
348
|
+
step={1}
|
|
349
|
+
disabled={disabled}
|
|
350
|
+
displayValue={`${Math.round(parsed.centerX)}%`}
|
|
351
|
+
formatDisplayValue={(next) => `${Math.round(next)}%`}
|
|
352
|
+
onCommit={(next) => patch({ centerX: next })}
|
|
353
|
+
/>
|
|
354
|
+
</div>
|
|
355
|
+
<div className="grid min-w-0 gap-1.5">
|
|
356
|
+
<span className={LABEL}>Center Y</span>
|
|
357
|
+
<SliderControl
|
|
358
|
+
value={parsed.centerY}
|
|
359
|
+
min={0}
|
|
360
|
+
max={100}
|
|
361
|
+
step={1}
|
|
362
|
+
disabled={disabled}
|
|
363
|
+
displayValue={`${Math.round(parsed.centerY)}%`}
|
|
364
|
+
formatDisplayValue={(next) => `${Math.round(next)}%`}
|
|
365
|
+
onCommit={(next) => patch({ centerY: next })}
|
|
366
|
+
/>
|
|
367
|
+
</div>
|
|
368
|
+
</div>
|
|
369
|
+
)}
|
|
370
|
+
|
|
371
|
+
<div className="space-y-3">
|
|
372
|
+
<div className="flex items-center justify-between">
|
|
373
|
+
<span className={LABEL}>Stops</span>
|
|
374
|
+
<button
|
|
375
|
+
type="button"
|
|
376
|
+
disabled={disabled || parsed.stops.length >= 6}
|
|
377
|
+
onClick={() => addStop()}
|
|
378
|
+
className="inline-flex h-7 items-center gap-1.5 rounded-lg border border-neutral-700 bg-neutral-950 px-2.5 text-[11px] font-medium text-neutral-300 transition-colors hover:border-neutral-600 hover:text-white disabled:cursor-not-allowed disabled:text-neutral-600"
|
|
379
|
+
>
|
|
380
|
+
<Plus size={12} />
|
|
381
|
+
Add stop
|
|
382
|
+
</button>
|
|
383
|
+
</div>
|
|
384
|
+
<div className="space-y-3">
|
|
385
|
+
{parsed.stops.map((stop, index) => (
|
|
386
|
+
<div
|
|
387
|
+
key={`stop-editor-${index}`}
|
|
388
|
+
className="grid min-w-0 grid-cols-[minmax(0,1fr)_68px_28px] gap-2"
|
|
389
|
+
>
|
|
390
|
+
<ColorField
|
|
391
|
+
label={`Stop ${index + 1}`}
|
|
392
|
+
value={stop.color}
|
|
393
|
+
disabled={disabled}
|
|
394
|
+
onCommit={(next) => updateStop(index, { color: next })}
|
|
395
|
+
/>
|
|
396
|
+
<DetailField
|
|
397
|
+
label="Pos"
|
|
398
|
+
value={`${Math.round(stop.position)}%`}
|
|
399
|
+
disabled={disabled}
|
|
400
|
+
onCommit={(next) =>
|
|
401
|
+
updateStop(index, {
|
|
402
|
+
position: Number.parseFloat(next.replace("%", "")) || 0,
|
|
403
|
+
})
|
|
404
|
+
}
|
|
405
|
+
/>
|
|
406
|
+
<button
|
|
407
|
+
type="button"
|
|
408
|
+
disabled={disabled || parsed.stops.length <= 2}
|
|
409
|
+
onClick={() => removeStop(index)}
|
|
410
|
+
className="mt-[22px] flex h-10 items-center justify-center rounded-lg border border-neutral-700 bg-neutral-950 text-neutral-400 transition-colors hover:border-neutral-600 hover:text-white disabled:cursor-not-allowed disabled:text-neutral-700"
|
|
411
|
+
aria-label={`Remove stop ${index + 1}`}
|
|
412
|
+
>
|
|
413
|
+
<X size={12} />
|
|
414
|
+
</button>
|
|
415
|
+
</div>
|
|
416
|
+
))}
|
|
417
|
+
</div>
|
|
418
|
+
</div>
|
|
419
|
+
</div>
|
|
420
|
+
);
|
|
421
|
+
}
|