@hyperframes/studio 0.6.0-alpha.2 → 0.6.0-alpha.4
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-CEnWY28J.js +417 -0
- package/dist/assets/index-BfnyZllX.js +106 -0
- package/dist/assets/index-pZvEUcY0.css +1 -0
- package/dist/index.html +2 -2
- package/package.json +4 -4
- package/src/App.tsx +217 -98
- package/src/components/editor/PropertyPanel.test.ts +49 -0
- package/src/components/editor/PropertyPanel.tsx +258 -1276
- package/src/components/editor/domEditing.test.ts +248 -0
- package/src/components/editor/domEditing.ts +126 -2
- package/src/components/editor/manualEditingAvailability.test.ts +3 -1
- package/src/components/editor/manualEditingAvailability.ts +4 -2
- package/src/components/editor/manualEdits.ts +15 -3
- package/src/components/nle/NLELayout.test.ts +12 -0
- package/src/components/nle/NLELayout.tsx +49 -24
- package/src/components/nle/NLEPreview.tsx +3 -0
- package/src/components/renders/RenderQueue.tsx +66 -4
- package/src/components/renders/useRenderQueue.ts +30 -6
- package/src/player/components/Player.test.ts +58 -0
- package/src/player/components/Player.tsx +58 -4
- package/src/player/components/PlayerControls.tsx +20 -7
- package/src/player/components/Timeline.tsx +38 -1
- package/dist/assets/hyperframes-player-Cd8vYWxP.js +0 -198
- package/dist/assets/index-UWFaHilT.css +0 -1
- package/dist/assets/index-cPJbxeAk.js +0 -107
|
@@ -15,32 +15,21 @@ import {
|
|
|
15
15
|
MessageSquare,
|
|
16
16
|
Move,
|
|
17
17
|
Palette,
|
|
18
|
-
Plus,
|
|
19
18
|
RotateCcw,
|
|
20
19
|
Settings,
|
|
21
|
-
Square,
|
|
22
20
|
Type,
|
|
23
21
|
X,
|
|
24
|
-
Zap,
|
|
25
22
|
} from "../../icons/SystemIcons";
|
|
26
23
|
import {
|
|
27
24
|
formatCssColor,
|
|
28
25
|
hsvToRgb,
|
|
29
26
|
parseCssColor,
|
|
30
27
|
rgbToHsv,
|
|
31
|
-
toColorPickerValue,
|
|
32
28
|
toHexColor,
|
|
33
29
|
type ParsedColor,
|
|
34
30
|
} from "./colorValue";
|
|
35
|
-
import {
|
|
36
|
-
buildDefaultGradientModel,
|
|
37
|
-
insertGradientStop,
|
|
38
|
-
parseGradient,
|
|
39
|
-
serializeGradient,
|
|
40
|
-
type GradientModel,
|
|
41
|
-
} from "./gradientValue";
|
|
42
31
|
import { isTextEditableSelection, type DomEditSelection } from "./domEditing";
|
|
43
|
-
import { readStudioBoxSize, readStudioPathOffset } from "./manualEdits";
|
|
32
|
+
import { readStudioBoxSize, readStudioPathOffset, readStudioRotation } from "./manualEdits";
|
|
44
33
|
import {
|
|
45
34
|
COMMON_LOCAL_FONT_FAMILIES,
|
|
46
35
|
googleFontStylesheetUrl,
|
|
@@ -48,24 +37,20 @@ import {
|
|
|
48
37
|
} from "./fontCatalog";
|
|
49
38
|
import { fontFamilyFromAssetPath, importedFontFaceCss, type ImportedFontAsset } from "./fontAssets";
|
|
50
39
|
import { resolveFloatingPanelPosition, type FloatingPosition } from "./floatingPanel";
|
|
51
|
-
import { IMAGE_EXT } from "../../utils/mediaTypes";
|
|
52
40
|
|
|
53
41
|
interface PropertyPanelProps {
|
|
54
|
-
projectId: string;
|
|
55
|
-
assets: string[];
|
|
56
42
|
element: DomEditSelection | null;
|
|
43
|
+
multiSelectCount?: number;
|
|
57
44
|
copiedAgentPrompt: boolean;
|
|
58
45
|
onClearSelection: () => void;
|
|
59
46
|
onSetStyle: (prop: string, value: string) => void | Promise<void>;
|
|
60
47
|
onSetManualOffset: (element: DomEditSelection, next: { x: number; y: number }) => void;
|
|
61
48
|
onSetManualSize: (element: DomEditSelection, next: { width: number; height: number }) => void;
|
|
49
|
+
onSetRotation: (element: DomEditSelection, next: { angle: number }) => void;
|
|
62
50
|
onSetText: (value: string, fieldKey?: string) => void;
|
|
63
51
|
onSetTextFieldStyle: (fieldKey: string, property: string, value: string) => void;
|
|
64
|
-
onAddTextField: (afterFieldKey?: string) => string | Promise<string | null> | null;
|
|
65
|
-
onRemoveTextField: (fieldKey: string) => void;
|
|
66
52
|
onResetManualEdits: (element: DomEditSelection) => void;
|
|
67
53
|
onAskAgent: () => void;
|
|
68
|
-
onImportAssets?: (files: FileList) => Promise<string[]>;
|
|
69
54
|
fontAssets?: ImportedFontAsset[];
|
|
70
55
|
onImportFonts?: (files: FileList | File[]) => Promise<ImportedFontAsset[]>;
|
|
71
56
|
}
|
|
@@ -221,18 +206,6 @@ function formatPxMetricValue(value: number): string {
|
|
|
221
206
|
return `${formatNumericValue(value)}px`;
|
|
222
207
|
}
|
|
223
208
|
|
|
224
|
-
function normalizeTextMetricValue(property: "letter-spacing" | "line-height", value: string) {
|
|
225
|
-
const trimmed = value.trim();
|
|
226
|
-
if (!trimmed || trimmed === "normal") return trimmed || "normal";
|
|
227
|
-
const token = parseNumericToken(trimmed);
|
|
228
|
-
if (!token) return trimmed;
|
|
229
|
-
if (property === "letter-spacing") {
|
|
230
|
-
return token.unit ? trimmed : `${formatNumericValue(token.value)}px`;
|
|
231
|
-
}
|
|
232
|
-
if (token.unit) return trimmed;
|
|
233
|
-
return token.value > 4 ? `${formatNumericValue(token.value)}px` : formatNumericValue(token.value);
|
|
234
|
-
}
|
|
235
|
-
|
|
236
209
|
function splitCssFunctions(value: string): string[] {
|
|
237
210
|
const functions: string[] = [];
|
|
238
211
|
let current = "";
|
|
@@ -336,23 +309,6 @@ export function buildStrokeStyleUpdates(
|
|
|
336
309
|
return updates;
|
|
337
310
|
}
|
|
338
311
|
|
|
339
|
-
function buildClipPathValue(
|
|
340
|
-
preset: "none" | "inset" | "circle" | "custom",
|
|
341
|
-
radiusValue: number,
|
|
342
|
-
fallback: string | undefined,
|
|
343
|
-
) {
|
|
344
|
-
if (preset === "custom") return fallback?.trim() || "none";
|
|
345
|
-
if (preset === "circle") return "circle(50% at 50% 50%)";
|
|
346
|
-
if (preset === "inset") {
|
|
347
|
-
return `inset(0 round ${formatNumericValue(Math.max(0, radiusValue))}px)`;
|
|
348
|
-
}
|
|
349
|
-
return "none";
|
|
350
|
-
}
|
|
351
|
-
|
|
352
|
-
function buildInsetClipPathValue(insetPx: number, radiusValue: number): string {
|
|
353
|
-
return `inset(${formatNumericValue(Math.max(0, insetPx))}px round ${formatNumericValue(Math.max(0, radiusValue))}px)`;
|
|
354
|
-
}
|
|
355
|
-
|
|
356
312
|
function adjustNumericToken(
|
|
357
313
|
value: string,
|
|
358
314
|
direction: 1 | -1,
|
|
@@ -366,123 +322,6 @@ function adjustNumericToken(
|
|
|
366
322
|
return `${formatNumericValue(nextValue)}${token.unit}`;
|
|
367
323
|
}
|
|
368
324
|
|
|
369
|
-
function formatColorToken(value: string): string {
|
|
370
|
-
const parsed = parseCssColor(value);
|
|
371
|
-
if (!parsed) return value;
|
|
372
|
-
const hex = toColorPickerValue(value).replace(/^#/, "").toUpperCase();
|
|
373
|
-
return `${hex} / ${Math.round(parsed.alpha * 100)}%`;
|
|
374
|
-
}
|
|
375
|
-
|
|
376
|
-
function extractBackgroundImageUrl(value: string | undefined): string {
|
|
377
|
-
if (!value) return "";
|
|
378
|
-
const lowerValue = value.toLowerCase();
|
|
379
|
-
const urlStart = lowerValue.indexOf("url(");
|
|
380
|
-
if (urlStart < 0) return "";
|
|
381
|
-
|
|
382
|
-
let index = urlStart + 4;
|
|
383
|
-
while (
|
|
384
|
-
index < value.length &&
|
|
385
|
-
(value[index] === " " ||
|
|
386
|
-
value[index] === "\n" ||
|
|
387
|
-
value[index] === "\r" ||
|
|
388
|
-
value[index] === "\t" ||
|
|
389
|
-
value[index] === "\f")
|
|
390
|
-
) {
|
|
391
|
-
index += 1;
|
|
392
|
-
}
|
|
393
|
-
|
|
394
|
-
const quote = value[index] === '"' || value[index] === "'" ? value[index] : null;
|
|
395
|
-
if (quote) {
|
|
396
|
-
index += 1;
|
|
397
|
-
const endQuote = value.indexOf(quote, index);
|
|
398
|
-
return endQuote >= index ? value.slice(index, endQuote) : "";
|
|
399
|
-
}
|
|
400
|
-
|
|
401
|
-
const endParen = value.indexOf(")", index);
|
|
402
|
-
if (endParen < index) return "";
|
|
403
|
-
return value.slice(index, endParen).trim();
|
|
404
|
-
}
|
|
405
|
-
|
|
406
|
-
function normalizeProjectPath(value: string): string {
|
|
407
|
-
const trimmed = value.trim();
|
|
408
|
-
const maybeUrl = /^[a-z]+:\/\//i.test(trimmed) ? new URL(trimmed).pathname : trimmed;
|
|
409
|
-
return decodeURIComponent(maybeUrl)
|
|
410
|
-
.replace(/\\/g, "/")
|
|
411
|
-
.replace(/^\.?\//, "");
|
|
412
|
-
}
|
|
413
|
-
|
|
414
|
-
function toRelativeProjectAssetPath(sourceFile: string, assetPath: string): string {
|
|
415
|
-
const fromParts = normalizeProjectPath(sourceFile).split("/").filter(Boolean);
|
|
416
|
-
const targetParts = normalizeProjectPath(assetPath).split("/").filter(Boolean);
|
|
417
|
-
|
|
418
|
-
fromParts.pop();
|
|
419
|
-
|
|
420
|
-
while (fromParts.length > 0 && targetParts.length > 0 && fromParts[0] === targetParts[0]) {
|
|
421
|
-
fromParts.shift();
|
|
422
|
-
targetParts.shift();
|
|
423
|
-
}
|
|
424
|
-
|
|
425
|
-
return [...fromParts.map(() => ".."), ...targetParts].join("/") || assetPath;
|
|
426
|
-
}
|
|
427
|
-
|
|
428
|
-
function toProjectRootAssetPath(assetPath: string): string {
|
|
429
|
-
return normalizeProjectPath(assetPath);
|
|
430
|
-
}
|
|
431
|
-
|
|
432
|
-
function resolveSelectedAsset(
|
|
433
|
-
imageUrl: string,
|
|
434
|
-
sourceFile: string,
|
|
435
|
-
assets: string[],
|
|
436
|
-
): string | null {
|
|
437
|
-
const normalizedUrl = normalizeProjectPath(imageUrl);
|
|
438
|
-
if (!normalizedUrl) return null;
|
|
439
|
-
|
|
440
|
-
for (const asset of assets) {
|
|
441
|
-
const normalizedAsset = normalizeProjectPath(asset);
|
|
442
|
-
const relativeAsset = toRelativeProjectAssetPath(sourceFile, asset);
|
|
443
|
-
if (
|
|
444
|
-
normalizedUrl === normalizedAsset ||
|
|
445
|
-
normalizedUrl === relativeAsset ||
|
|
446
|
-
normalizedUrl.endsWith(`/${normalizedAsset}`) ||
|
|
447
|
-
normalizedUrl.endsWith(`/${relativeAsset}`)
|
|
448
|
-
) {
|
|
449
|
-
return asset;
|
|
450
|
-
}
|
|
451
|
-
}
|
|
452
|
-
|
|
453
|
-
return null;
|
|
454
|
-
}
|
|
455
|
-
|
|
456
|
-
function collectSelectionColors(styles: Record<string, string>) {
|
|
457
|
-
const candidates = [
|
|
458
|
-
{ source: "Fill", value: styles["background-color"] },
|
|
459
|
-
{ source: "Text", value: styles.color },
|
|
460
|
-
];
|
|
461
|
-
|
|
462
|
-
const deduped = new Map<string, { swatch: string; token: string; sources: string[] }>();
|
|
463
|
-
|
|
464
|
-
for (const candidate of candidates) {
|
|
465
|
-
if (!candidate.value) continue;
|
|
466
|
-
const parsed = parseCssColor(candidate.value);
|
|
467
|
-
if (!parsed || parsed.alpha <= 0) continue;
|
|
468
|
-
|
|
469
|
-
const key = `${toColorPickerValue(candidate.value)}-${Math.round(parsed.alpha * 100)}`;
|
|
470
|
-
const existing = deduped.get(key);
|
|
471
|
-
if (existing) {
|
|
472
|
-
existing.sources.push(candidate.source);
|
|
473
|
-
continue;
|
|
474
|
-
}
|
|
475
|
-
|
|
476
|
-
deduped.set(key, {
|
|
477
|
-
swatch: toColorPickerValue(candidate.value),
|
|
478
|
-
token: formatColorToken(candidate.value),
|
|
479
|
-
sources: [candidate.source],
|
|
480
|
-
});
|
|
481
|
-
}
|
|
482
|
-
|
|
483
|
-
return Array.from(deduped.values());
|
|
484
|
-
}
|
|
485
|
-
|
|
486
325
|
function CommitField({
|
|
487
326
|
value,
|
|
488
327
|
disabled,
|
|
@@ -593,27 +432,6 @@ function MetricField({
|
|
|
593
432
|
);
|
|
594
433
|
}
|
|
595
434
|
|
|
596
|
-
function DetailField({
|
|
597
|
-
label,
|
|
598
|
-
value,
|
|
599
|
-
disabled,
|
|
600
|
-
onCommit,
|
|
601
|
-
}: {
|
|
602
|
-
label: string;
|
|
603
|
-
value: string;
|
|
604
|
-
disabled?: boolean;
|
|
605
|
-
onCommit: (nextValue: string) => void;
|
|
606
|
-
}) {
|
|
607
|
-
return (
|
|
608
|
-
<label className="grid min-w-0 gap-1.5">
|
|
609
|
-
<span className={LABEL}>{label}</span>
|
|
610
|
-
<div className={FIELD}>
|
|
611
|
-
<CommitField value={value} disabled={disabled} onCommit={onCommit} />
|
|
612
|
-
</div>
|
|
613
|
-
</label>
|
|
614
|
-
);
|
|
615
|
-
}
|
|
616
|
-
|
|
617
435
|
function TextAreaField({
|
|
618
436
|
label,
|
|
619
437
|
value,
|
|
@@ -1226,75 +1044,6 @@ function FontFamilyField({
|
|
|
1226
1044
|
);
|
|
1227
1045
|
}
|
|
1228
1046
|
|
|
1229
|
-
function getTextStyleValue(
|
|
1230
|
-
field: DomEditSelection["textFields"][number],
|
|
1231
|
-
inheritedStyles: Record<string, string>,
|
|
1232
|
-
property: string,
|
|
1233
|
-
fallback: string,
|
|
1234
|
-
): string {
|
|
1235
|
-
return field.computedStyles[property] || inheritedStyles[property] || fallback;
|
|
1236
|
-
}
|
|
1237
|
-
|
|
1238
|
-
function AdvancedTextControls({
|
|
1239
|
-
field,
|
|
1240
|
-
inheritedStyles,
|
|
1241
|
-
disabled,
|
|
1242
|
-
onCommit,
|
|
1243
|
-
}: {
|
|
1244
|
-
field: DomEditSelection["textFields"][number];
|
|
1245
|
-
inheritedStyles: Record<string, string>;
|
|
1246
|
-
disabled?: boolean;
|
|
1247
|
-
onCommit: (property: string, value: string) => void;
|
|
1248
|
-
}) {
|
|
1249
|
-
return (
|
|
1250
|
-
<div className="space-y-4">
|
|
1251
|
-
<div className={RESPONSIVE_GRID}>
|
|
1252
|
-
<MetricField
|
|
1253
|
-
label="Line"
|
|
1254
|
-
value={getTextStyleValue(field, inheritedStyles, "line-height", "normal")}
|
|
1255
|
-
disabled={disabled}
|
|
1256
|
-
liveCommit
|
|
1257
|
-
onCommit={(next) =>
|
|
1258
|
-
onCommit("line-height", normalizeTextMetricValue("line-height", next))
|
|
1259
|
-
}
|
|
1260
|
-
/>
|
|
1261
|
-
<MetricField
|
|
1262
|
-
label="Track"
|
|
1263
|
-
value={getTextStyleValue(field, inheritedStyles, "letter-spacing", "0px")}
|
|
1264
|
-
disabled={disabled}
|
|
1265
|
-
liveCommit
|
|
1266
|
-
onCommit={(next) =>
|
|
1267
|
-
onCommit("letter-spacing", normalizeTextMetricValue("letter-spacing", next))
|
|
1268
|
-
}
|
|
1269
|
-
/>
|
|
1270
|
-
</div>
|
|
1271
|
-
<div className={RESPONSIVE_GRID}>
|
|
1272
|
-
<SelectField
|
|
1273
|
-
label="Align"
|
|
1274
|
-
value={getTextStyleValue(field, inheritedStyles, "text-align", "start")}
|
|
1275
|
-
disabled={disabled}
|
|
1276
|
-
onChange={(next) => onCommit("text-align", next)}
|
|
1277
|
-
options={["start", "left", "center", "right", "justify", "end"]}
|
|
1278
|
-
/>
|
|
1279
|
-
<SelectField
|
|
1280
|
-
label="Case"
|
|
1281
|
-
value={getTextStyleValue(field, inheritedStyles, "text-transform", "none")}
|
|
1282
|
-
disabled={disabled}
|
|
1283
|
-
onChange={(next) => onCommit("text-transform", next)}
|
|
1284
|
-
options={["none", "uppercase", "lowercase", "capitalize"]}
|
|
1285
|
-
/>
|
|
1286
|
-
</div>
|
|
1287
|
-
<SelectField
|
|
1288
|
-
label="Style"
|
|
1289
|
-
value={getTextStyleValue(field, inheritedStyles, "font-style", "normal")}
|
|
1290
|
-
disabled={disabled}
|
|
1291
|
-
onChange={(next) => onCommit("font-style", next)}
|
|
1292
|
-
options={["normal", "italic", "oblique"]}
|
|
1293
|
-
/>
|
|
1294
|
-
</div>
|
|
1295
|
-
);
|
|
1296
|
-
}
|
|
1297
|
-
|
|
1298
1047
|
function ColorField({
|
|
1299
1048
|
label,
|
|
1300
1049
|
value,
|
|
@@ -1648,360 +1397,6 @@ function ColorSlider({
|
|
|
1648
1397
|
);
|
|
1649
1398
|
}
|
|
1650
1399
|
|
|
1651
|
-
function ImageFillField({
|
|
1652
|
-
projectId,
|
|
1653
|
-
sourceFile,
|
|
1654
|
-
value,
|
|
1655
|
-
assets,
|
|
1656
|
-
disabled,
|
|
1657
|
-
onCommit,
|
|
1658
|
-
onImportAssets,
|
|
1659
|
-
}: {
|
|
1660
|
-
projectId: string;
|
|
1661
|
-
sourceFile: string;
|
|
1662
|
-
value: string;
|
|
1663
|
-
assets: string[];
|
|
1664
|
-
disabled?: boolean;
|
|
1665
|
-
onCommit: (nextValue: string) => void;
|
|
1666
|
-
onImportAssets?: (files: FileList) => Promise<string[]>;
|
|
1667
|
-
}) {
|
|
1668
|
-
const fileInputRef = useRef<HTMLInputElement | null>(null);
|
|
1669
|
-
const [uploading, setUploading] = useState(false);
|
|
1670
|
-
const imageAssets = useMemo(() => assets.filter((asset) => IMAGE_EXT.test(asset)), [assets]);
|
|
1671
|
-
const selectedAsset = useMemo(
|
|
1672
|
-
() => resolveSelectedAsset(value, sourceFile, imageAssets),
|
|
1673
|
-
[imageAssets, sourceFile, value],
|
|
1674
|
-
);
|
|
1675
|
-
const externalUrlValue = selectedAsset ? "" : value;
|
|
1676
|
-
|
|
1677
|
-
const handleUpload = async (files: FileList | null) => {
|
|
1678
|
-
if (!files?.length || !onImportAssets) return;
|
|
1679
|
-
setUploading(true);
|
|
1680
|
-
try {
|
|
1681
|
-
const uploaded = await onImportAssets(files);
|
|
1682
|
-
const nextImage = uploaded.find((asset) => IMAGE_EXT.test(asset));
|
|
1683
|
-
if (nextImage) {
|
|
1684
|
-
onCommit(`url("${toProjectRootAssetPath(nextImage)}")`);
|
|
1685
|
-
}
|
|
1686
|
-
} finally {
|
|
1687
|
-
setUploading(false);
|
|
1688
|
-
}
|
|
1689
|
-
};
|
|
1690
|
-
|
|
1691
|
-
return (
|
|
1692
|
-
<div className="space-y-4">
|
|
1693
|
-
<div className="grid min-w-0 gap-1.5">
|
|
1694
|
-
<div className="flex min-w-0 flex-wrap items-center justify-between gap-2">
|
|
1695
|
-
<span className={LABEL}>Project asset</span>
|
|
1696
|
-
<button
|
|
1697
|
-
type="button"
|
|
1698
|
-
disabled={disabled || uploading}
|
|
1699
|
-
onClick={() => fileInputRef.current?.click()}
|
|
1700
|
-
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 ${
|
|
1701
|
-
disabled || uploading
|
|
1702
|
-
? "cursor-not-allowed text-neutral-600"
|
|
1703
|
-
: "cursor-pointer hover:border-neutral-600 hover:text-white"
|
|
1704
|
-
}`}
|
|
1705
|
-
>
|
|
1706
|
-
<Plus size={12} className="flex-shrink-0" />
|
|
1707
|
-
<span className="truncate">{uploading ? "Uploading…" : "Upload image"}</span>
|
|
1708
|
-
</button>
|
|
1709
|
-
<input
|
|
1710
|
-
ref={fileInputRef}
|
|
1711
|
-
type="file"
|
|
1712
|
-
accept="image/*"
|
|
1713
|
-
aria-label="Upload image asset"
|
|
1714
|
-
disabled={disabled || uploading}
|
|
1715
|
-
className="hidden"
|
|
1716
|
-
onChange={async (event) => {
|
|
1717
|
-
await handleUpload(event.target.files);
|
|
1718
|
-
event.target.value = "";
|
|
1719
|
-
}}
|
|
1720
|
-
/>
|
|
1721
|
-
</div>
|
|
1722
|
-
{imageAssets.length > 0 ? (
|
|
1723
|
-
<div className="space-y-3">
|
|
1724
|
-
{selectedAsset && (
|
|
1725
|
-
<div className="overflow-hidden rounded-xl border border-neutral-800 bg-neutral-900/80">
|
|
1726
|
-
<img
|
|
1727
|
-
src={`/api/projects/${projectId}/preview/${selectedAsset}`}
|
|
1728
|
-
alt={selectedAsset.split("/").pop() ?? selectedAsset}
|
|
1729
|
-
className="h-28 w-full object-contain bg-neutral-950/80"
|
|
1730
|
-
/>
|
|
1731
|
-
</div>
|
|
1732
|
-
)}
|
|
1733
|
-
<div className={FIELD}>
|
|
1734
|
-
<select
|
|
1735
|
-
value={selectedAsset ?? ""}
|
|
1736
|
-
disabled={disabled}
|
|
1737
|
-
onChange={(e) => {
|
|
1738
|
-
const nextAsset = e.target.value;
|
|
1739
|
-
if (!nextAsset) {
|
|
1740
|
-
onCommit("none");
|
|
1741
|
-
return;
|
|
1742
|
-
}
|
|
1743
|
-
onCommit(`url("${toProjectRootAssetPath(nextAsset)}")`);
|
|
1744
|
-
}}
|
|
1745
|
-
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"
|
|
1746
|
-
>
|
|
1747
|
-
<option value="">None</option>
|
|
1748
|
-
{imageAssets.map((asset) => (
|
|
1749
|
-
<option key={asset} value={asset}>
|
|
1750
|
-
{asset}
|
|
1751
|
-
</option>
|
|
1752
|
-
))}
|
|
1753
|
-
</select>
|
|
1754
|
-
</div>
|
|
1755
|
-
</div>
|
|
1756
|
-
) : (
|
|
1757
|
-
<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">
|
|
1758
|
-
No image assets yet. Upload one here and Studio will also add it to the Assets tab.
|
|
1759
|
-
</div>
|
|
1760
|
-
)}
|
|
1761
|
-
</div>
|
|
1762
|
-
|
|
1763
|
-
<DetailField
|
|
1764
|
-
label="External URL"
|
|
1765
|
-
value={externalUrlValue}
|
|
1766
|
-
disabled={disabled}
|
|
1767
|
-
onCommit={(next) => onCommit(next.trim() ? `url("${next.trim()}")` : "none")}
|
|
1768
|
-
/>
|
|
1769
|
-
</div>
|
|
1770
|
-
);
|
|
1771
|
-
}
|
|
1772
|
-
|
|
1773
|
-
function GradientField({
|
|
1774
|
-
value,
|
|
1775
|
-
fallbackColor,
|
|
1776
|
-
disabled,
|
|
1777
|
-
onCommit,
|
|
1778
|
-
}: {
|
|
1779
|
-
value: string;
|
|
1780
|
-
fallbackColor: string | undefined;
|
|
1781
|
-
disabled?: boolean;
|
|
1782
|
-
onCommit: (nextValue: string) => void;
|
|
1783
|
-
}) {
|
|
1784
|
-
const previewRef = useRef<HTMLDivElement | null>(null);
|
|
1785
|
-
const parsed = parseGradient(value) ?? buildDefaultGradientModel(fallbackColor);
|
|
1786
|
-
|
|
1787
|
-
const commit = (next: GradientModel) => onCommit(serializeGradient(next));
|
|
1788
|
-
|
|
1789
|
-
const patch = (partial: Partial<GradientModel>) => commit({ ...parsed, ...partial });
|
|
1790
|
-
|
|
1791
|
-
const updateStop = (index: number, partial: Partial<GradientModel["stops"][number]>) => {
|
|
1792
|
-
const stops = parsed.stops.map((stop, stopIndex) =>
|
|
1793
|
-
stopIndex === index ? { ...stop, ...partial } : stop,
|
|
1794
|
-
);
|
|
1795
|
-
commit({ ...parsed, stops });
|
|
1796
|
-
};
|
|
1797
|
-
|
|
1798
|
-
const addStop = (position?: number) => {
|
|
1799
|
-
const nextGradient =
|
|
1800
|
-
position != null
|
|
1801
|
-
? insertGradientStop(parsed, position)
|
|
1802
|
-
: insertGradientStop(
|
|
1803
|
-
parsed,
|
|
1804
|
-
parsed.stops.at(-1)?.position != null
|
|
1805
|
-
? Math.min(100, (parsed.stops.at(-1)?.position ?? 90) + 10)
|
|
1806
|
-
: 100,
|
|
1807
|
-
);
|
|
1808
|
-
commit(nextGradient);
|
|
1809
|
-
};
|
|
1810
|
-
|
|
1811
|
-
const removeStop = (index: number) => {
|
|
1812
|
-
if (parsed.stops.length <= 2) return;
|
|
1813
|
-
commit({ ...parsed, stops: parsed.stops.filter((_, stopIndex) => stopIndex !== index) });
|
|
1814
|
-
};
|
|
1815
|
-
|
|
1816
|
-
const previewStyle = {
|
|
1817
|
-
backgroundImage: serializeGradient(parsed),
|
|
1818
|
-
};
|
|
1819
|
-
|
|
1820
|
-
return (
|
|
1821
|
-
<div className="space-y-4">
|
|
1822
|
-
<div className={`${FIELD} space-y-3 p-3`}>
|
|
1823
|
-
<div
|
|
1824
|
-
ref={previewRef}
|
|
1825
|
-
className="relative h-11 overflow-hidden rounded-lg border border-neutral-700"
|
|
1826
|
-
style={previewStyle}
|
|
1827
|
-
onClick={(event) => {
|
|
1828
|
-
if (disabled) return;
|
|
1829
|
-
const rect = previewRef.current?.getBoundingClientRect();
|
|
1830
|
-
if (!rect || rect.width <= 0) return;
|
|
1831
|
-
const position = ((event.clientX - rect.left) / rect.width) * 100;
|
|
1832
|
-
addStop(position);
|
|
1833
|
-
}}
|
|
1834
|
-
>
|
|
1835
|
-
{parsed.stops.map((stop, index) => (
|
|
1836
|
-
<div
|
|
1837
|
-
key={`stop-preview-${index}`}
|
|
1838
|
-
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)]"
|
|
1839
|
-
style={{
|
|
1840
|
-
left: `calc(${stop.position}% - 8px)`,
|
|
1841
|
-
backgroundColor: stop.color,
|
|
1842
|
-
}}
|
|
1843
|
-
/>
|
|
1844
|
-
))}
|
|
1845
|
-
</div>
|
|
1846
|
-
<div className="flex min-w-0 flex-wrap items-center gap-2">
|
|
1847
|
-
<SegmentedControl
|
|
1848
|
-
disabled={disabled}
|
|
1849
|
-
value={parsed.kind}
|
|
1850
|
-
onChange={(next) => patch({ kind: next as GradientModel["kind"] })}
|
|
1851
|
-
options={[
|
|
1852
|
-
{ label: "Linear", value: "linear" },
|
|
1853
|
-
{ label: "Radial", value: "radial" },
|
|
1854
|
-
{ label: "Conic", value: "conic" },
|
|
1855
|
-
]}
|
|
1856
|
-
/>
|
|
1857
|
-
<label className="flex items-center gap-2 text-[11px] font-medium text-neutral-400">
|
|
1858
|
-
<input
|
|
1859
|
-
type="checkbox"
|
|
1860
|
-
checked={parsed.repeating}
|
|
1861
|
-
disabled={disabled}
|
|
1862
|
-
onChange={(e) => patch({ repeating: e.target.checked })}
|
|
1863
|
-
className="h-4 w-4 rounded border-neutral-700 bg-neutral-950 text-[#3ce6ac] focus:ring-[#3ce6ac]"
|
|
1864
|
-
/>
|
|
1865
|
-
Repeat
|
|
1866
|
-
</label>
|
|
1867
|
-
<button
|
|
1868
|
-
type="button"
|
|
1869
|
-
disabled={disabled}
|
|
1870
|
-
onClick={() =>
|
|
1871
|
-
commit({
|
|
1872
|
-
...parsed,
|
|
1873
|
-
stops: [...parsed.stops].reverse().map((stop) => ({
|
|
1874
|
-
...stop,
|
|
1875
|
-
position: 100 - stop.position,
|
|
1876
|
-
})),
|
|
1877
|
-
})
|
|
1878
|
-
}
|
|
1879
|
-
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"
|
|
1880
|
-
>
|
|
1881
|
-
<RotateCcw size={12} />
|
|
1882
|
-
Reverse
|
|
1883
|
-
</button>
|
|
1884
|
-
</div>
|
|
1885
|
-
</div>
|
|
1886
|
-
|
|
1887
|
-
{(parsed.kind === "linear" || parsed.kind === "conic") && (
|
|
1888
|
-
<div className="grid gap-1.5">
|
|
1889
|
-
<span className={LABEL}>{parsed.kind === "linear" ? "Angle" : "Start angle"}</span>
|
|
1890
|
-
<SliderControl
|
|
1891
|
-
value={parsed.angle}
|
|
1892
|
-
min={0}
|
|
1893
|
-
max={360}
|
|
1894
|
-
step={1}
|
|
1895
|
-
disabled={disabled}
|
|
1896
|
-
displayValue={`${Math.round(parsed.angle)}°`}
|
|
1897
|
-
formatDisplayValue={(next) => `${Math.round(next)}°`}
|
|
1898
|
-
onCommit={(next) => patch({ angle: next })}
|
|
1899
|
-
/>
|
|
1900
|
-
</div>
|
|
1901
|
-
)}
|
|
1902
|
-
|
|
1903
|
-
{parsed.kind === "radial" && (
|
|
1904
|
-
<div className={RESPONSIVE_GRID}>
|
|
1905
|
-
<SelectField
|
|
1906
|
-
label="Shape"
|
|
1907
|
-
value={parsed.shape}
|
|
1908
|
-
disabled={disabled}
|
|
1909
|
-
onChange={(next) => patch({ shape: next as GradientModel["shape"] })}
|
|
1910
|
-
options={["ellipse", "circle"]}
|
|
1911
|
-
/>
|
|
1912
|
-
<SelectField
|
|
1913
|
-
label="Size"
|
|
1914
|
-
value={parsed.radialSize}
|
|
1915
|
-
disabled={disabled}
|
|
1916
|
-
onChange={(next) => patch({ radialSize: next as GradientModel["radialSize"] })}
|
|
1917
|
-
options={["closest-side", "closest-corner", "farthest-side", "farthest-corner"]}
|
|
1918
|
-
/>
|
|
1919
|
-
</div>
|
|
1920
|
-
)}
|
|
1921
|
-
|
|
1922
|
-
{(parsed.kind === "radial" || parsed.kind === "conic") && (
|
|
1923
|
-
<div className={RESPONSIVE_GRID}>
|
|
1924
|
-
<div className="grid min-w-0 gap-1.5">
|
|
1925
|
-
<span className={LABEL}>Center X</span>
|
|
1926
|
-
<SliderControl
|
|
1927
|
-
value={parsed.centerX}
|
|
1928
|
-
min={0}
|
|
1929
|
-
max={100}
|
|
1930
|
-
step={1}
|
|
1931
|
-
disabled={disabled}
|
|
1932
|
-
displayValue={`${Math.round(parsed.centerX)}%`}
|
|
1933
|
-
formatDisplayValue={(next) => `${Math.round(next)}%`}
|
|
1934
|
-
onCommit={(next) => patch({ centerX: next })}
|
|
1935
|
-
/>
|
|
1936
|
-
</div>
|
|
1937
|
-
<div className="grid min-w-0 gap-1.5">
|
|
1938
|
-
<span className={LABEL}>Center Y</span>
|
|
1939
|
-
<SliderControl
|
|
1940
|
-
value={parsed.centerY}
|
|
1941
|
-
min={0}
|
|
1942
|
-
max={100}
|
|
1943
|
-
step={1}
|
|
1944
|
-
disabled={disabled}
|
|
1945
|
-
displayValue={`${Math.round(parsed.centerY)}%`}
|
|
1946
|
-
formatDisplayValue={(next) => `${Math.round(next)}%`}
|
|
1947
|
-
onCommit={(next) => patch({ centerY: next })}
|
|
1948
|
-
/>
|
|
1949
|
-
</div>
|
|
1950
|
-
</div>
|
|
1951
|
-
)}
|
|
1952
|
-
|
|
1953
|
-
<div className="space-y-3">
|
|
1954
|
-
<div className="flex items-center justify-between">
|
|
1955
|
-
<span className={LABEL}>Stops</span>
|
|
1956
|
-
<button
|
|
1957
|
-
type="button"
|
|
1958
|
-
disabled={disabled || parsed.stops.length >= 6}
|
|
1959
|
-
onClick={() => addStop()}
|
|
1960
|
-
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"
|
|
1961
|
-
>
|
|
1962
|
-
<Plus size={12} />
|
|
1963
|
-
Add stop
|
|
1964
|
-
</button>
|
|
1965
|
-
</div>
|
|
1966
|
-
<div className="space-y-3">
|
|
1967
|
-
{parsed.stops.map((stop, index) => (
|
|
1968
|
-
<div
|
|
1969
|
-
key={`stop-editor-${index}`}
|
|
1970
|
-
className="grid min-w-0 grid-cols-[minmax(0,1fr)_68px_28px] gap-2"
|
|
1971
|
-
>
|
|
1972
|
-
<ColorField
|
|
1973
|
-
label={`Stop ${index + 1}`}
|
|
1974
|
-
value={stop.color}
|
|
1975
|
-
disabled={disabled}
|
|
1976
|
-
onCommit={(next) => updateStop(index, { color: next })}
|
|
1977
|
-
/>
|
|
1978
|
-
<DetailField
|
|
1979
|
-
label="Pos"
|
|
1980
|
-
value={`${Math.round(stop.position)}%`}
|
|
1981
|
-
disabled={disabled}
|
|
1982
|
-
onCommit={(next) =>
|
|
1983
|
-
updateStop(index, {
|
|
1984
|
-
position: Number.parseFloat(next.replace("%", "")) || 0,
|
|
1985
|
-
})
|
|
1986
|
-
}
|
|
1987
|
-
/>
|
|
1988
|
-
<button
|
|
1989
|
-
type="button"
|
|
1990
|
-
disabled={disabled || parsed.stops.length <= 2}
|
|
1991
|
-
onClick={() => removeStop(index)}
|
|
1992
|
-
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"
|
|
1993
|
-
aria-label={`Remove stop ${index + 1}`}
|
|
1994
|
-
>
|
|
1995
|
-
<X size={12} />
|
|
1996
|
-
</button>
|
|
1997
|
-
</div>
|
|
1998
|
-
))}
|
|
1999
|
-
</div>
|
|
2000
|
-
</div>
|
|
2001
|
-
</div>
|
|
2002
|
-
);
|
|
2003
|
-
}
|
|
2004
|
-
|
|
2005
1400
|
function SliderControl({
|
|
2006
1401
|
value,
|
|
2007
1402
|
min,
|
|
@@ -2082,44 +1477,6 @@ function SliderControl({
|
|
|
2082
1477
|
);
|
|
2083
1478
|
}
|
|
2084
1479
|
|
|
2085
|
-
function SegmentedControl({
|
|
2086
|
-
options,
|
|
2087
|
-
value,
|
|
2088
|
-
disabled,
|
|
2089
|
-
onChange,
|
|
2090
|
-
}: {
|
|
2091
|
-
options: Array<{ label: string; value: string }>;
|
|
2092
|
-
value: string;
|
|
2093
|
-
disabled?: boolean;
|
|
2094
|
-
onChange: (nextValue: string) => void;
|
|
2095
|
-
}) {
|
|
2096
|
-
return (
|
|
2097
|
-
<div
|
|
2098
|
-
className="grid min-w-0 gap-1 rounded-xl bg-neutral-900 p-1 shadow-[inset_0_1px_0_rgba(255,255,255,0.03)]"
|
|
2099
|
-
style={{ gridTemplateColumns: `repeat(${options.length}, minmax(0, 1fr))` }}
|
|
2100
|
-
>
|
|
2101
|
-
{options.map((option) => {
|
|
2102
|
-
const selected = option.value === value;
|
|
2103
|
-
return (
|
|
2104
|
-
<button
|
|
2105
|
-
key={option.value}
|
|
2106
|
-
type="button"
|
|
2107
|
-
disabled={disabled}
|
|
2108
|
-
onClick={() => onChange(option.value)}
|
|
2109
|
-
className={`min-w-0 truncate rounded-lg px-2 py-1.5 text-[11px] font-medium transition-colors disabled:cursor-not-allowed ${
|
|
2110
|
-
selected
|
|
2111
|
-
? "bg-neutral-800 text-white shadow-[0_1px_3px_rgba(0,0,0,0.28)]"
|
|
2112
|
-
: "text-neutral-500 hover:text-neutral-200"
|
|
2113
|
-
}`}
|
|
2114
|
-
>
|
|
2115
|
-
{option.label}
|
|
2116
|
-
</button>
|
|
2117
|
-
);
|
|
2118
|
-
})}
|
|
2119
|
-
</div>
|
|
2120
|
-
);
|
|
2121
|
-
}
|
|
2122
|
-
|
|
2123
1480
|
function SelectField({
|
|
2124
1481
|
label,
|
|
2125
1482
|
value,
|
|
@@ -2183,70 +1540,57 @@ function Section({
|
|
|
2183
1540
|
);
|
|
2184
1541
|
}
|
|
2185
1542
|
|
|
2186
|
-
|
|
2187
|
-
|
|
2188
|
-
|
|
2189
|
-
|
|
2190
|
-
|
|
2191
|
-
|
|
2192
|
-
|
|
2193
|
-
|
|
2194
|
-
|
|
2195
|
-
|
|
2196
|
-
|
|
2197
|
-
|
|
2198
|
-
|
|
2199
|
-
|
|
2200
|
-
|
|
2201
|
-
|
|
2202
|
-
|
|
2203
|
-
|
|
2204
|
-
|
|
2205
|
-
|
|
2206
|
-
|
|
2207
|
-
|
|
2208
|
-
|
|
1543
|
+
export type PropertyPanelSectionId = "Text" | "Layout" | "Colors" | "Radius" | "Shadow";
|
|
1544
|
+
|
|
1545
|
+
export function getPropertyPanelVisibleSections(input: {
|
|
1546
|
+
hasSelection: boolean;
|
|
1547
|
+
canEditStyles: boolean;
|
|
1548
|
+
hasTextControls: boolean;
|
|
1549
|
+
hasColorControls: boolean;
|
|
1550
|
+
}): PropertyPanelSectionId[] {
|
|
1551
|
+
if (!input.hasSelection) return [];
|
|
1552
|
+
|
|
1553
|
+
const sections: PropertyPanelSectionId[] = [];
|
|
1554
|
+
if (input.hasTextControls) sections.push("Text");
|
|
1555
|
+
sections.push("Layout");
|
|
1556
|
+
if (input.canEditStyles) {
|
|
1557
|
+
if (input.hasColorControls) sections.push("Colors");
|
|
1558
|
+
sections.push("Radius", "Shadow");
|
|
1559
|
+
}
|
|
1560
|
+
return sections;
|
|
1561
|
+
}
|
|
1562
|
+
|
|
1563
|
+
export function isPropertyPanelMediaLikeSelection(input: {
|
|
1564
|
+
tagName: string;
|
|
1565
|
+
styles: Record<string, string>;
|
|
1566
|
+
}): boolean {
|
|
1567
|
+
if (["img", "video", "audio", "canvas"].includes(input.tagName)) return true;
|
|
1568
|
+
const backgroundImage = input.styles["background-image"]?.trim();
|
|
1569
|
+
return Boolean(backgroundImage && backgroundImage !== "none");
|
|
2209
1570
|
}
|
|
2210
1571
|
|
|
2211
1572
|
export const PropertyPanel = memo(function PropertyPanel({
|
|
2212
|
-
projectId,
|
|
2213
|
-
assets,
|
|
2214
1573
|
element,
|
|
1574
|
+
multiSelectCount = 0,
|
|
2215
1575
|
copiedAgentPrompt,
|
|
2216
1576
|
onClearSelection,
|
|
2217
1577
|
onSetStyle,
|
|
2218
1578
|
onSetManualOffset,
|
|
2219
1579
|
onSetManualSize,
|
|
1580
|
+
onSetRotation,
|
|
2220
1581
|
onSetText,
|
|
2221
1582
|
onSetTextFieldStyle,
|
|
2222
|
-
onAddTextField,
|
|
2223
|
-
onRemoveTextField,
|
|
2224
1583
|
onResetManualEdits,
|
|
2225
1584
|
onAskAgent,
|
|
2226
|
-
onImportAssets,
|
|
2227
1585
|
fontAssets = [],
|
|
2228
1586
|
onImportFonts,
|
|
2229
1587
|
}: PropertyPanelProps) {
|
|
2230
1588
|
const styles = element?.computedStyles ?? EMPTY_STYLES;
|
|
2231
|
-
const selectionColors = useMemo(() => collectSelectionColors(styles), [styles]);
|
|
2232
|
-
const backgroundImage = styles["background-image"] ?? "none";
|
|
2233
|
-
const fillMode =
|
|
2234
|
-
backgroundImage && backgroundImage !== "none"
|
|
2235
|
-
? backgroundImage.includes("gradient")
|
|
2236
|
-
? "Gradient"
|
|
2237
|
-
: "Image"
|
|
2238
|
-
: "Solid";
|
|
2239
|
-
const [preferredFillMode, setPreferredFillMode] = useState(fillMode);
|
|
2240
|
-
const imageUrl = extractBackgroundImageUrl(backgroundImage);
|
|
2241
1589
|
const [activeTextFieldKey, setActiveTextFieldKey] = useState<string | null>(
|
|
2242
1590
|
element?.textFields[0]?.key ?? null,
|
|
2243
1591
|
);
|
|
2244
1592
|
const hasTextControls = element != null && isTextEditableSelection(element);
|
|
2245
1593
|
|
|
2246
|
-
useEffect(() => {
|
|
2247
|
-
setPreferredFillMode(fillMode);
|
|
2248
|
-
}, [fillMode, element?.id, element?.selector, backgroundImage]);
|
|
2249
|
-
|
|
2250
1594
|
useEffect(() => {
|
|
2251
1595
|
const nextFields = element?.textFields ?? [];
|
|
2252
1596
|
setActiveTextFieldKey((current) => {
|
|
@@ -2258,12 +1602,28 @@ export const PropertyPanel = memo(function PropertyPanel({
|
|
|
2258
1602
|
if (!element) {
|
|
2259
1603
|
return (
|
|
2260
1604
|
<div className="flex h-full flex-col items-center justify-center bg-neutral-900 px-6 text-center">
|
|
2261
|
-
|
|
2262
|
-
|
|
2263
|
-
|
|
2264
|
-
|
|
2265
|
-
|
|
2266
|
-
|
|
1605
|
+
{multiSelectCount > 1 ? (
|
|
1606
|
+
<>
|
|
1607
|
+
<Layers size={18} className="mb-3 text-neutral-600" />
|
|
1608
|
+
<p className="text-sm font-medium text-neutral-200">
|
|
1609
|
+
{multiSelectCount} elements selected
|
|
1610
|
+
</p>
|
|
1611
|
+
<p className="mt-2 max-w-[260px] text-xs leading-5 text-neutral-500">
|
|
1612
|
+
Select a single element to edit its properties. Click an element in the preview or use
|
|
1613
|
+
the timeline layer panel.
|
|
1614
|
+
</p>
|
|
1615
|
+
</>
|
|
1616
|
+
) : (
|
|
1617
|
+
<>
|
|
1618
|
+
<Eye size={18} className="mb-3 text-neutral-600" />
|
|
1619
|
+
<p className="text-sm font-medium text-neutral-200">
|
|
1620
|
+
Select an element in the preview.
|
|
1621
|
+
</p>
|
|
1622
|
+
<p className="mt-2 max-w-[260px] text-xs leading-5 text-neutral-500">
|
|
1623
|
+
Click any element to inspect and edit it. Click deeper to select child elements.
|
|
1624
|
+
</p>
|
|
1625
|
+
</>
|
|
1626
|
+
)}
|
|
2267
1627
|
</div>
|
|
2268
1628
|
);
|
|
2269
1629
|
}
|
|
@@ -2271,26 +1631,31 @@ export const PropertyPanel = memo(function PropertyPanel({
|
|
|
2271
1631
|
const styleEditingDisabled = !element.capabilities.canEditStyles;
|
|
2272
1632
|
const manualOffsetEditingDisabled = !element.capabilities.canApplyManualOffset;
|
|
2273
1633
|
const manualSizeEditingDisabled = !element.capabilities.canApplyManualSize;
|
|
2274
|
-
const isFlex = styles.display === "flex" || styles.display === "inline-flex";
|
|
2275
1634
|
const radiusValue = parseNumericValue(styles["border-radius"]) ?? 0;
|
|
2276
|
-
const opacityValue = Math.round((parseNumericValue(styles.opacity) ?? 1) * 100);
|
|
2277
|
-
const borderWidthValue =
|
|
2278
|
-
parsePxMetricValue(styles["border-width"] ?? "") ??
|
|
2279
|
-
parsePxMetricValue(styles["border-top-width"] ?? "") ??
|
|
2280
|
-
0;
|
|
2281
|
-
const borderStyleValue = styles["border-style"] || styles["border-top-style"] || "none";
|
|
2282
|
-
const borderColorValue =
|
|
2283
|
-
styles["border-color"] || styles["border-top-color"] || "rgba(255, 255, 255, 0.18)";
|
|
2284
1635
|
const boxShadowPreset = inferBoxShadowPreset(styles["box-shadow"]);
|
|
2285
|
-
const filterBlurValue = getCssFilterFunctionPx(styles.filter, "blur");
|
|
2286
|
-
const backdropBlurValue = getCssFilterFunctionPx(styles["backdrop-filter"], "blur");
|
|
2287
|
-
const clipPathValue = styles["clip-path"] || "none";
|
|
2288
|
-
const clipPathPreset = inferClipPathPreset(clipPathValue);
|
|
2289
|
-
const clipInsetValue = getClipPathInsetPx(clipPathValue);
|
|
2290
1636
|
const sourceLabel = element.id ? `#${element.id}` : element.selector;
|
|
2291
1637
|
const showEditableSections = element.capabilities.canEditStyles;
|
|
1638
|
+
const mediaLikeSelection = isPropertyPanelMediaLikeSelection({
|
|
1639
|
+
tagName: element.tagName,
|
|
1640
|
+
styles,
|
|
1641
|
+
});
|
|
1642
|
+
const bgColor = styles["background-color"]?.trim();
|
|
1643
|
+
const hasExplicitFillColor =
|
|
1644
|
+
!mediaLikeSelection &&
|
|
1645
|
+
Boolean(bgColor) &&
|
|
1646
|
+
bgColor !== "rgba(0, 0, 0, 0)" &&
|
|
1647
|
+
bgColor !== "transparent";
|
|
1648
|
+
const canShowFillColor = hasExplicitFillColor;
|
|
1649
|
+
const canShowTextColor = !mediaLikeSelection && Boolean(styles.color?.trim());
|
|
1650
|
+
const visibleSections = getPropertyPanelVisibleSections({
|
|
1651
|
+
hasSelection: true,
|
|
1652
|
+
canEditStyles: showEditableSections,
|
|
1653
|
+
hasTextControls,
|
|
1654
|
+
hasColorControls: canShowFillColor || canShowTextColor,
|
|
1655
|
+
});
|
|
2292
1656
|
const manualOffset = readStudioPathOffset(element.element);
|
|
2293
1657
|
const manualSize = readStudioBoxSize(element.element);
|
|
1658
|
+
const manualRotation = readStudioRotation(element.element);
|
|
2294
1659
|
const resolvedWidth =
|
|
2295
1660
|
manualSize.width > 0
|
|
2296
1661
|
? manualSize.width
|
|
@@ -2328,20 +1693,6 @@ export const PropertyPanel = memo(function PropertyPanel({
|
|
|
2328
1693
|
});
|
|
2329
1694
|
};
|
|
2330
1695
|
|
|
2331
|
-
const handleFillModeChange = (nextMode: string) => {
|
|
2332
|
-
setPreferredFillMode(nextMode);
|
|
2333
|
-
if (nextMode === "Solid") {
|
|
2334
|
-
onSetStyle("background-image", "none");
|
|
2335
|
-
return;
|
|
2336
|
-
}
|
|
2337
|
-
if (nextMode === "Gradient" && !backgroundImage.includes("gradient")) {
|
|
2338
|
-
onSetStyle(
|
|
2339
|
-
"background-image",
|
|
2340
|
-
serializeGradient(buildDefaultGradientModel(styles["background-color"])),
|
|
2341
|
-
);
|
|
2342
|
-
}
|
|
2343
|
-
};
|
|
2344
|
-
|
|
2345
1696
|
return (
|
|
2346
1697
|
<div className="flex h-full min-h-0 flex-col overflow-hidden bg-neutral-900 text-neutral-100">
|
|
2347
1698
|
<div className="border-b border-neutral-800 px-4 py-5">
|
|
@@ -2384,574 +1735,205 @@ export const PropertyPanel = memo(function PropertyPanel({
|
|
|
2384
1735
|
</div>
|
|
2385
1736
|
|
|
2386
1737
|
<div className="flex-1 overflow-y-auto">
|
|
2387
|
-
|
|
2388
|
-
<
|
|
2389
|
-
|
|
2390
|
-
|
|
2391
|
-
|
|
2392
|
-
|
|
2393
|
-
|
|
2394
|
-
|
|
2395
|
-
|
|
2396
|
-
|
|
2397
|
-
|
|
2398
|
-
|
|
2399
|
-
|
|
2400
|
-
|
|
2401
|
-
|
|
2402
|
-
|
|
2403
|
-
|
|
2404
|
-
|
|
2405
|
-
|
|
2406
|
-
|
|
2407
|
-
|
|
2408
|
-
|
|
2409
|
-
|
|
2410
|
-
|
|
2411
|
-
|
|
2412
|
-
|
|
2413
|
-
|
|
2414
|
-
|
|
1738
|
+
{visibleSections.includes("Text") && (
|
|
1739
|
+
<Section title="Text" icon={<Type size={15} />}>
|
|
1740
|
+
{(() => {
|
|
1741
|
+
const textFields = element.textFields;
|
|
1742
|
+
const activeField =
|
|
1743
|
+
textFields.find((field) => field.key === activeTextFieldKey) ?? textFields[0];
|
|
1744
|
+
if (!activeField) return null;
|
|
1745
|
+
|
|
1746
|
+
return (
|
|
1747
|
+
<div className="space-y-4">
|
|
1748
|
+
{textFields.length > 1 && (
|
|
1749
|
+
<div className="grid gap-2">
|
|
1750
|
+
<span className={LABEL}>Text layers</span>
|
|
1751
|
+
{textFields.map((field, index) => {
|
|
1752
|
+
const active = field.key === activeField.key;
|
|
1753
|
+
return (
|
|
1754
|
+
<button
|
|
1755
|
+
key={field.key}
|
|
1756
|
+
type="button"
|
|
1757
|
+
onClick={() => setActiveTextFieldKey(field.key)}
|
|
1758
|
+
className={`min-w-0 w-full rounded-xl border px-3 py-2 text-left transition-colors ${
|
|
1759
|
+
active
|
|
1760
|
+
? "border-studio-accent/50 bg-studio-accent/10"
|
|
1761
|
+
: "border-neutral-800 bg-neutral-900/80 hover:border-neutral-700 hover:bg-neutral-900"
|
|
1762
|
+
}`}
|
|
1763
|
+
>
|
|
1764
|
+
<div className="flex min-w-0 items-center justify-between gap-2">
|
|
1765
|
+
<div className="flex min-w-0 items-center gap-2">
|
|
1766
|
+
<span
|
|
1767
|
+
className="h-4 w-4 flex-shrink-0 rounded border border-neutral-700 bg-neutral-950"
|
|
1768
|
+
style={{ backgroundColor: getTextFieldColor(field, styles) }}
|
|
1769
|
+
/>
|
|
1770
|
+
<span className="min-w-0 truncate text-[11px] font-medium text-neutral-100">
|
|
1771
|
+
{formatTextFieldPreview(field.value) || `Text ${index + 1}`}
|
|
1772
|
+
</span>
|
|
1773
|
+
</div>
|
|
1774
|
+
<span className="flex-shrink-0 rounded-md border border-neutral-700 bg-neutral-950 px-1.5 py-0.5 text-[10px] uppercase tracking-[0.12em] text-neutral-500">
|
|
1775
|
+
{field.tagName}
|
|
1776
|
+
</span>
|
|
1777
|
+
</div>
|
|
1778
|
+
</button>
|
|
1779
|
+
);
|
|
1780
|
+
})}
|
|
1781
|
+
</div>
|
|
1782
|
+
)}
|
|
1783
|
+
|
|
1784
|
+
<div className="space-y-4 rounded-xl border border-neutral-800 bg-neutral-900/60 p-3">
|
|
1785
|
+
<TextAreaField
|
|
1786
|
+
key={activeField.key}
|
|
1787
|
+
label="Content"
|
|
1788
|
+
value={activeField.value}
|
|
1789
|
+
disabled={false}
|
|
1790
|
+
onCommit={(next) => onSetText(next, activeField.key)}
|
|
1791
|
+
/>
|
|
2415
1792
|
|
|
2416
|
-
|
|
2417
|
-
|
|
2418
|
-
|
|
2419
|
-
|
|
2420
|
-
|
|
2421
|
-
|
|
2422
|
-
|
|
2423
|
-
|
|
2424
|
-
|
|
2425
|
-
|
|
2426
|
-
|
|
1793
|
+
<ColorField
|
|
1794
|
+
label="Text color"
|
|
1795
|
+
value={getTextFieldColor(activeField, styles)}
|
|
1796
|
+
disabled={false}
|
|
1797
|
+
onCommit={(next) => onSetTextFieldStyle(activeField.key, "color", next)}
|
|
1798
|
+
/>
|
|
1799
|
+
|
|
1800
|
+
<FontFamilyField
|
|
1801
|
+
value={
|
|
1802
|
+
activeField.computedStyles["font-family"] ||
|
|
1803
|
+
styles["font-family"] ||
|
|
1804
|
+
"inherit"
|
|
1805
|
+
}
|
|
1806
|
+
disabled={false}
|
|
1807
|
+
importedFonts={fontAssets}
|
|
1808
|
+
onImportFonts={onImportFonts}
|
|
1809
|
+
onCommit={(next) => onSetTextFieldStyle(activeField.key, "font-family", next)}
|
|
1810
|
+
/>
|
|
1811
|
+
|
|
1812
|
+
<div className={RESPONSIVE_GRID}>
|
|
1813
|
+
<MetricField
|
|
1814
|
+
label="Size"
|
|
1815
|
+
value={
|
|
1816
|
+
activeField.computedStyles["font-size"] || styles["font-size"] || "16px"
|
|
1817
|
+
}
|
|
1818
|
+
disabled={false}
|
|
1819
|
+
liveCommit
|
|
1820
|
+
onCommit={(next) => onSetTextFieldStyle(activeField.key, "font-size", next)}
|
|
1821
|
+
/>
|
|
1822
|
+
<FontWeightField
|
|
1823
|
+
value={
|
|
1824
|
+
activeField.computedStyles["font-weight"] ||
|
|
1825
|
+
styles["font-weight"] ||
|
|
1826
|
+
"400"
|
|
1827
|
+
}
|
|
1828
|
+
disabled={false}
|
|
1829
|
+
onCommit={(next) =>
|
|
1830
|
+
onSetTextFieldStyle(activeField.key, "font-weight", next)
|
|
1831
|
+
}
|
|
1832
|
+
/>
|
|
1833
|
+
</div>
|
|
1834
|
+
</div>
|
|
1835
|
+
</div>
|
|
1836
|
+
);
|
|
1837
|
+
})()}
|
|
1838
|
+
</Section>
|
|
1839
|
+
)}
|
|
1840
|
+
|
|
1841
|
+
{visibleSections.includes("Layout") && (
|
|
1842
|
+
<Section title="Layout" icon={<Move size={15} />}>
|
|
1843
|
+
<div className={RESPONSIVE_GRID}>
|
|
1844
|
+
<MetricField
|
|
1845
|
+
label="X"
|
|
1846
|
+
value={formatPxMetricValue(manualOffset.x)}
|
|
1847
|
+
disabled={manualOffsetEditingDisabled}
|
|
1848
|
+
onCommit={(next) => commitManualOffset("x", next)}
|
|
2427
1849
|
/>
|
|
2428
|
-
<
|
|
2429
|
-
|
|
2430
|
-
|
|
2431
|
-
|
|
2432
|
-
|
|
2433
|
-
|
|
2434
|
-
|
|
2435
|
-
|
|
2436
|
-
|
|
2437
|
-
|
|
2438
|
-
|
|
2439
|
-
|
|
2440
|
-
|
|
2441
|
-
|
|
2442
|
-
|
|
2443
|
-
|
|
2444
|
-
|
|
2445
|
-
|
|
2446
|
-
|
|
2447
|
-
|
|
2448
|
-
|
|
2449
|
-
|
|
2450
|
-
|
|
2451
|
-
|
|
2452
|
-
|
|
2453
|
-
|
|
2454
|
-
|
|
2455
|
-
|
|
1850
|
+
<MetricField
|
|
1851
|
+
label="Y"
|
|
1852
|
+
value={formatPxMetricValue(manualOffset.y)}
|
|
1853
|
+
disabled={manualOffsetEditingDisabled}
|
|
1854
|
+
onCommit={(next) => commitManualOffset("y", next)}
|
|
1855
|
+
/>
|
|
1856
|
+
<MetricField
|
|
1857
|
+
label="W"
|
|
1858
|
+
value={formatPxMetricValue(resolvedWidth)}
|
|
1859
|
+
disabled={manualSizeEditingDisabled}
|
|
1860
|
+
onCommit={(next) => commitManualSize("width", next)}
|
|
1861
|
+
/>
|
|
1862
|
+
<MetricField
|
|
1863
|
+
label="H"
|
|
1864
|
+
value={formatPxMetricValue(resolvedHeight)}
|
|
1865
|
+
disabled={manualSizeEditingDisabled}
|
|
1866
|
+
onCommit={(next) => commitManualSize("height", next)}
|
|
1867
|
+
/>
|
|
1868
|
+
<MetricField
|
|
1869
|
+
label="Rotate"
|
|
1870
|
+
value={`${formatNumericValue(manualRotation.angle)}°`}
|
|
1871
|
+
disabled={manualOffsetEditingDisabled}
|
|
1872
|
+
onCommit={(next) => {
|
|
1873
|
+
const parsed = parseFloat(next.replace("°", "").trim());
|
|
1874
|
+
if (Number.isFinite(parsed)) {
|
|
1875
|
+
onSetRotation(element, { angle: parsed % 360 });
|
|
1876
|
+
}
|
|
1877
|
+
}}
|
|
2456
1878
|
/>
|
|
2457
1879
|
</div>
|
|
2458
1880
|
</Section>
|
|
2459
1881
|
)}
|
|
2460
1882
|
|
|
2461
|
-
{
|
|
2462
|
-
|
|
2463
|
-
<
|
|
2464
|
-
|
|
2465
|
-
value={radiusValue}
|
|
2466
|
-
min={0}
|
|
2467
|
-
max={Math.max(240, Math.ceil(radiusValue))}
|
|
2468
|
-
step={1}
|
|
2469
|
-
disabled={styleEditingDisabled}
|
|
2470
|
-
displayValue={`${formatNumericValue(radiusValue)}px`}
|
|
2471
|
-
formatDisplayValue={(next) => `${formatNumericValue(next)}px`}
|
|
2472
|
-
onCommit={(next) => onSetStyle("border-radius", `${formatNumericValue(next)}px`)}
|
|
2473
|
-
/>
|
|
2474
|
-
</Section>
|
|
2475
|
-
|
|
2476
|
-
<Section title="Stroke" icon={<Square size={15} />}>
|
|
2477
|
-
<div className="space-y-4">
|
|
2478
|
-
<div className={RESPONSIVE_GRID}>
|
|
2479
|
-
<MetricField
|
|
2480
|
-
label="Width"
|
|
2481
|
-
value={formatPxMetricValue(borderWidthValue)}
|
|
2482
|
-
disabled={styleEditingDisabled}
|
|
2483
|
-
liveCommit
|
|
2484
|
-
onCommit={async (next) => {
|
|
2485
|
-
const normalized = normalizePanelPxValue(next, {
|
|
2486
|
-
min: 0,
|
|
2487
|
-
max: 200,
|
|
2488
|
-
fallback: borderWidthValue,
|
|
2489
|
-
});
|
|
2490
|
-
if (!normalized) return;
|
|
2491
|
-
for (const [property, value] of buildStrokeWidthStyleUpdates(
|
|
2492
|
-
normalized,
|
|
2493
|
-
borderStyleValue,
|
|
2494
|
-
)) {
|
|
2495
|
-
await onSetStyle(property, value);
|
|
2496
|
-
}
|
|
2497
|
-
}}
|
|
2498
|
-
/>
|
|
2499
|
-
<SelectField
|
|
2500
|
-
label="Style"
|
|
2501
|
-
value={borderStyleValue}
|
|
2502
|
-
disabled={styleEditingDisabled}
|
|
2503
|
-
onChange={async (next) => {
|
|
2504
|
-
for (const [property, value] of buildStrokeStyleUpdates(
|
|
2505
|
-
next,
|
|
2506
|
-
formatPxMetricValue(borderWidthValue),
|
|
2507
|
-
)) {
|
|
2508
|
-
await onSetStyle(property, value);
|
|
2509
|
-
}
|
|
2510
|
-
}}
|
|
2511
|
-
options={[
|
|
2512
|
-
"none",
|
|
2513
|
-
"solid",
|
|
2514
|
-
"dashed",
|
|
2515
|
-
"dotted",
|
|
2516
|
-
"double",
|
|
2517
|
-
"hidden",
|
|
2518
|
-
"groove",
|
|
2519
|
-
"ridge",
|
|
2520
|
-
"inset",
|
|
2521
|
-
"outset",
|
|
2522
|
-
]}
|
|
2523
|
-
/>
|
|
2524
|
-
</div>
|
|
1883
|
+
{visibleSections.includes("Colors") && (
|
|
1884
|
+
<Section title="Colors" icon={<Palette size={15} />}>
|
|
1885
|
+
<div className="space-y-4">
|
|
1886
|
+
{canShowFillColor && (
|
|
2525
1887
|
<ColorField
|
|
2526
|
-
label="
|
|
2527
|
-
value={
|
|
2528
|
-
disabled={styleEditingDisabled}
|
|
2529
|
-
onCommit={(next) => onSetStyle("border-color", next)}
|
|
2530
|
-
/>
|
|
2531
|
-
</div>
|
|
2532
|
-
</Section>
|
|
2533
|
-
|
|
2534
|
-
<Section title="Effects" icon={<Zap size={15} />}>
|
|
2535
|
-
<div className="space-y-4">
|
|
2536
|
-
<SelectField
|
|
2537
|
-
label="Shadow"
|
|
2538
|
-
value={boxShadowPreset}
|
|
1888
|
+
label="Fill color"
|
|
1889
|
+
value={styles["background-color"] ?? "transparent"}
|
|
2539
1890
|
disabled={styleEditingDisabled}
|
|
2540
|
-
|
|
2541
|
-
if (next === "custom") return;
|
|
2542
|
-
onSetStyle(
|
|
2543
|
-
"box-shadow",
|
|
2544
|
-
buildBoxShadowPresetValue(next as BoxShadowPreset, styles["box-shadow"]),
|
|
2545
|
-
);
|
|
2546
|
-
}}
|
|
2547
|
-
options={["custom", "none", "soft", "lift", "glow"]}
|
|
1891
|
+
onCommit={(next) => onSetStyle("background-color", next)}
|
|
2548
1892
|
/>
|
|
2549
|
-
|
|
2550
|
-
|
|
2551
|
-
|
|
2552
|
-
|
|
2553
|
-
|
|
2554
|
-
min={0}
|
|
2555
|
-
max={Math.max(40, Math.ceil(filterBlurValue))}
|
|
2556
|
-
step={1}
|
|
2557
|
-
disabled={styleEditingDisabled}
|
|
2558
|
-
displayValue={`${formatNumericValue(filterBlurValue)}px`}
|
|
2559
|
-
formatDisplayValue={(next) => `${formatNumericValue(next)}px`}
|
|
2560
|
-
onCommit={(next) =>
|
|
2561
|
-
onSetStyle("filter", setCssFilterFunctionPx(styles.filter, "blur", next))
|
|
2562
|
-
}
|
|
2563
|
-
/>
|
|
2564
|
-
</div>
|
|
2565
|
-
<div className="grid min-w-0 gap-1.5">
|
|
2566
|
-
<span className={LABEL}>Backdrop</span>
|
|
2567
|
-
<SliderControl
|
|
2568
|
-
value={backdropBlurValue}
|
|
2569
|
-
min={0}
|
|
2570
|
-
max={Math.max(60, Math.ceil(backdropBlurValue))}
|
|
2571
|
-
step={1}
|
|
2572
|
-
disabled={styleEditingDisabled}
|
|
2573
|
-
displayValue={`${formatNumericValue(backdropBlurValue)}px`}
|
|
2574
|
-
formatDisplayValue={(next) => `${formatNumericValue(next)}px`}
|
|
2575
|
-
onCommit={(next) =>
|
|
2576
|
-
onSetStyle(
|
|
2577
|
-
"backdrop-filter",
|
|
2578
|
-
setCssFilterFunctionPx(styles["backdrop-filter"], "blur", next),
|
|
2579
|
-
)
|
|
2580
|
-
}
|
|
2581
|
-
/>
|
|
2582
|
-
</div>
|
|
2583
|
-
</div>
|
|
2584
|
-
</div>
|
|
2585
|
-
</Section>
|
|
2586
|
-
|
|
2587
|
-
<Section title="Clip" icon={<Layers size={15} />}>
|
|
2588
|
-
<div className="space-y-4">
|
|
2589
|
-
<div className={RESPONSIVE_GRID}>
|
|
2590
|
-
<SelectField
|
|
2591
|
-
label="Overflow"
|
|
2592
|
-
value={styles.overflow || "visible"}
|
|
2593
|
-
disabled={styleEditingDisabled}
|
|
2594
|
-
onChange={(next) => onSetStyle("overflow", next)}
|
|
2595
|
-
options={["visible", "hidden", "clip", "auto", "scroll"]}
|
|
2596
|
-
/>
|
|
2597
|
-
<SelectField
|
|
2598
|
-
label="Mask"
|
|
2599
|
-
value={clipPathPreset}
|
|
2600
|
-
disabled={styleEditingDisabled}
|
|
2601
|
-
onChange={(next) => {
|
|
2602
|
-
if (next === "custom") return;
|
|
2603
|
-
onSetStyle(
|
|
2604
|
-
"clip-path",
|
|
2605
|
-
buildClipPathValue(
|
|
2606
|
-
next as "none" | "inset" | "circle",
|
|
2607
|
-
radiusValue,
|
|
2608
|
-
clipPathValue,
|
|
2609
|
-
),
|
|
2610
|
-
);
|
|
2611
|
-
}}
|
|
2612
|
-
options={["custom", "none", "inset", "circle"]}
|
|
2613
|
-
/>
|
|
2614
|
-
</div>
|
|
2615
|
-
<div className="grid min-w-0 gap-1.5">
|
|
2616
|
-
<span className={LABEL}>Mask inset</span>
|
|
2617
|
-
<SliderControl
|
|
2618
|
-
value={clipInsetValue}
|
|
2619
|
-
min={0}
|
|
2620
|
-
max={Math.max(120, Math.ceil(clipInsetValue))}
|
|
2621
|
-
step={1}
|
|
2622
|
-
disabled={styleEditingDisabled}
|
|
2623
|
-
displayValue={`${formatNumericValue(clipInsetValue)}px`}
|
|
2624
|
-
formatDisplayValue={(next) => `${formatNumericValue(next)}px`}
|
|
2625
|
-
onCommit={(next) =>
|
|
2626
|
-
onSetStyle("clip-path", buildInsetClipPathValue(next, radiusValue))
|
|
2627
|
-
}
|
|
2628
|
-
/>
|
|
2629
|
-
</div>
|
|
2630
|
-
</div>
|
|
2631
|
-
</Section>
|
|
2632
|
-
|
|
2633
|
-
<Section title="Blending" icon={<Eye size={15} />}>
|
|
2634
|
-
<div className="space-y-4">
|
|
2635
|
-
<SliderControl
|
|
2636
|
-
value={opacityValue}
|
|
2637
|
-
min={0}
|
|
2638
|
-
max={100}
|
|
2639
|
-
step={1}
|
|
2640
|
-
disabled={styleEditingDisabled}
|
|
2641
|
-
displayValue={`${opacityValue}%`}
|
|
2642
|
-
formatDisplayValue={(next) => `${Math.round(next)}%`}
|
|
2643
|
-
onCommit={(next) => onSetStyle("opacity", formatNumericValue(next / 100))}
|
|
2644
|
-
/>
|
|
2645
|
-
<SelectField
|
|
2646
|
-
label="Mode"
|
|
2647
|
-
value={styles["mix-blend-mode"] || "normal"}
|
|
2648
|
-
disabled={styleEditingDisabled}
|
|
2649
|
-
onChange={(next) => onSetStyle("mix-blend-mode", next)}
|
|
2650
|
-
options={["normal", "multiply", "screen", "overlay", "darken", "lighten"]}
|
|
2651
|
-
/>
|
|
2652
|
-
</div>
|
|
2653
|
-
</Section>
|
|
2654
|
-
|
|
2655
|
-
<Section
|
|
2656
|
-
title="Fill"
|
|
2657
|
-
icon={<Palette size={15} />}
|
|
2658
|
-
accessory={
|
|
2659
|
-
<div className="rounded-full border border-neutral-700 bg-neutral-900 px-2.5 py-1 text-[10px] font-medium uppercase tracking-[0.16em] text-neutral-400">
|
|
2660
|
-
{preferredFillMode}
|
|
2661
|
-
</div>
|
|
2662
|
-
}
|
|
2663
|
-
>
|
|
2664
|
-
<div className="space-y-4">
|
|
2665
|
-
<SegmentedControl
|
|
1893
|
+
)}
|
|
1894
|
+
{canShowTextColor && (
|
|
1895
|
+
<ColorField
|
|
1896
|
+
label="Text color"
|
|
1897
|
+
value={styles.color ?? "rgb(0, 0, 0)"}
|
|
2666
1898
|
disabled={styleEditingDisabled}
|
|
2667
|
-
|
|
2668
|
-
onChange={handleFillModeChange}
|
|
2669
|
-
options={[
|
|
2670
|
-
{ label: "Solid", value: "Solid" },
|
|
2671
|
-
{ label: "Gradient", value: "Gradient" },
|
|
2672
|
-
{ label: "Image", value: "Image" },
|
|
2673
|
-
]}
|
|
1899
|
+
onCommit={(next) => onSetStyle("color", next)}
|
|
2674
1900
|
/>
|
|
2675
|
-
|
|
2676
|
-
|
|
2677
|
-
|
|
2678
|
-
|
|
2679
|
-
disabled={styleEditingDisabled}
|
|
2680
|
-
onCommit={(next) => onSetStyle("background-color", next)}
|
|
2681
|
-
/>
|
|
2682
|
-
) : preferredFillMode === "Gradient" ? (
|
|
2683
|
-
<GradientField
|
|
2684
|
-
value={
|
|
2685
|
-
backgroundImage !== "none"
|
|
2686
|
-
? backgroundImage
|
|
2687
|
-
: serializeGradient(buildDefaultGradientModel(styles["background-color"]))
|
|
2688
|
-
}
|
|
2689
|
-
fallbackColor={styles["background-color"]}
|
|
2690
|
-
disabled={styleEditingDisabled}
|
|
2691
|
-
onCommit={(next) => onSetStyle("background-image", next)}
|
|
2692
|
-
/>
|
|
2693
|
-
) : (
|
|
2694
|
-
<ImageFillField
|
|
2695
|
-
projectId={projectId}
|
|
2696
|
-
sourceFile={element.sourceFile}
|
|
2697
|
-
value={imageUrl}
|
|
2698
|
-
assets={assets}
|
|
2699
|
-
disabled={styleEditingDisabled}
|
|
2700
|
-
onCommit={(next) => onSetStyle("background-image", next)}
|
|
2701
|
-
onImportAssets={onImportAssets}
|
|
2702
|
-
/>
|
|
2703
|
-
)}
|
|
2704
|
-
{!hasTextControls && (
|
|
2705
|
-
<ColorField
|
|
2706
|
-
label="Text color"
|
|
2707
|
-
value={styles.color ?? "rgb(0, 0, 0)"}
|
|
2708
|
-
disabled={styleEditingDisabled}
|
|
2709
|
-
onCommit={(next) => onSetStyle("color", next)}
|
|
2710
|
-
/>
|
|
2711
|
-
)}
|
|
2712
|
-
</div>
|
|
2713
|
-
</Section>
|
|
2714
|
-
|
|
2715
|
-
{hasTextControls && (
|
|
2716
|
-
<Section title="Text" icon={<Type size={15} />}>
|
|
2717
|
-
{(() => {
|
|
2718
|
-
const textFields = element.textFields;
|
|
2719
|
-
const activeField =
|
|
2720
|
-
textFields.find((field) => field.key === activeTextFieldKey) ?? textFields[0];
|
|
2721
|
-
if (!activeField) return null;
|
|
2722
|
-
|
|
2723
|
-
if (textFields.length === 1) {
|
|
2724
|
-
return (
|
|
2725
|
-
<div className="space-y-4 rounded-xl border border-neutral-800 bg-neutral-900/60 p-3">
|
|
2726
|
-
<div className="min-w-0">
|
|
2727
|
-
<div className="truncate text-[11px] font-medium text-neutral-100">
|
|
2728
|
-
{formatTextFieldPreview(activeField.value) || "Text"}
|
|
2729
|
-
</div>
|
|
2730
|
-
<div className="text-[10px] uppercase tracking-[0.12em] text-neutral-500">
|
|
2731
|
-
{activeField.tagName}
|
|
2732
|
-
</div>
|
|
2733
|
-
</div>
|
|
2734
|
-
|
|
2735
|
-
<TextAreaField
|
|
2736
|
-
key={activeField.key}
|
|
2737
|
-
label="Content"
|
|
2738
|
-
value={activeField.value}
|
|
2739
|
-
disabled={false}
|
|
2740
|
-
onCommit={(next) => onSetText(next, activeField.key)}
|
|
2741
|
-
/>
|
|
2742
|
-
|
|
2743
|
-
<ColorField
|
|
2744
|
-
label="Text color"
|
|
2745
|
-
value={getTextFieldColor(activeField, styles)}
|
|
2746
|
-
disabled={false}
|
|
2747
|
-
onCommit={(next) => onSetTextFieldStyle(activeField.key, "color", next)}
|
|
2748
|
-
/>
|
|
2749
|
-
|
|
2750
|
-
<div className={RESPONSIVE_GRID}>
|
|
2751
|
-
<MetricField
|
|
2752
|
-
label="Size"
|
|
2753
|
-
value={
|
|
2754
|
-
activeField.computedStyles["font-size"] ||
|
|
2755
|
-
styles["font-size"] ||
|
|
2756
|
-
"16px"
|
|
2757
|
-
}
|
|
2758
|
-
disabled={false}
|
|
2759
|
-
liveCommit
|
|
2760
|
-
onCommit={(next) =>
|
|
2761
|
-
onSetTextFieldStyle(activeField.key, "font-size", next)
|
|
2762
|
-
}
|
|
2763
|
-
/>
|
|
2764
|
-
<FontWeightField
|
|
2765
|
-
value={
|
|
2766
|
-
activeField.computedStyles["font-weight"] ||
|
|
2767
|
-
styles["font-weight"] ||
|
|
2768
|
-
"400"
|
|
2769
|
-
}
|
|
2770
|
-
disabled={false}
|
|
2771
|
-
onCommit={(next) =>
|
|
2772
|
-
onSetTextFieldStyle(activeField.key, "font-weight", next)
|
|
2773
|
-
}
|
|
2774
|
-
/>
|
|
2775
|
-
</div>
|
|
2776
|
-
|
|
2777
|
-
<FontFamilyField
|
|
2778
|
-
value={
|
|
2779
|
-
activeField.computedStyles["font-family"] ||
|
|
2780
|
-
styles["font-family"] ||
|
|
2781
|
-
"inherit"
|
|
2782
|
-
}
|
|
2783
|
-
disabled={false}
|
|
2784
|
-
importedFonts={fontAssets}
|
|
2785
|
-
onImportFonts={onImportFonts}
|
|
2786
|
-
onCommit={(next) =>
|
|
2787
|
-
onSetTextFieldStyle(activeField.key, "font-family", next)
|
|
2788
|
-
}
|
|
2789
|
-
/>
|
|
2790
|
-
|
|
2791
|
-
<AdvancedTextControls
|
|
2792
|
-
field={activeField}
|
|
2793
|
-
inheritedStyles={styles}
|
|
2794
|
-
disabled={false}
|
|
2795
|
-
onCommit={(property, value) =>
|
|
2796
|
-
onSetTextFieldStyle(activeField.key, property, value)
|
|
2797
|
-
}
|
|
2798
|
-
/>
|
|
2799
|
-
</div>
|
|
2800
|
-
);
|
|
2801
|
-
}
|
|
1901
|
+
)}
|
|
1902
|
+
</div>
|
|
1903
|
+
</Section>
|
|
1904
|
+
)}
|
|
2802
1905
|
|
|
2803
|
-
|
|
2804
|
-
|
|
2805
|
-
|
|
2806
|
-
|
|
2807
|
-
|
|
2808
|
-
|
|
2809
|
-
|
|
2810
|
-
|
|
2811
|
-
|
|
2812
|
-
|
|
2813
|
-
|
|
2814
|
-
|
|
2815
|
-
|
|
2816
|
-
|
|
2817
|
-
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 hover:border-neutral-600 hover:text-white"
|
|
2818
|
-
>
|
|
2819
|
-
<Plus size={12} className="flex-shrink-0" />
|
|
2820
|
-
<span className="truncate">Add text</span>
|
|
2821
|
-
</button>
|
|
2822
|
-
</div>
|
|
2823
|
-
<div className="grid gap-2">
|
|
2824
|
-
{textFields.map((field, index) => {
|
|
2825
|
-
const active = field.key === activeField.key;
|
|
2826
|
-
return (
|
|
2827
|
-
<button
|
|
2828
|
-
key={field.key}
|
|
2829
|
-
type="button"
|
|
2830
|
-
onClick={() => setActiveTextFieldKey(field.key)}
|
|
2831
|
-
className={`min-w-0 w-full rounded-xl border px-3 py-2 text-left transition-colors ${
|
|
2832
|
-
active
|
|
2833
|
-
? "border-studio-accent/50 bg-studio-accent/10"
|
|
2834
|
-
: "border-neutral-800 bg-neutral-900/80 hover:border-neutral-700 hover:bg-neutral-900"
|
|
2835
|
-
}`}
|
|
2836
|
-
>
|
|
2837
|
-
<div className="flex min-w-0 items-center justify-between gap-2">
|
|
2838
|
-
<div className="flex min-w-0 items-center gap-2">
|
|
2839
|
-
<span
|
|
2840
|
-
className="h-4 w-4 flex-shrink-0 rounded border border-neutral-700 bg-neutral-950"
|
|
2841
|
-
style={{ backgroundColor: getTextFieldColor(field, styles) }}
|
|
2842
|
-
/>
|
|
2843
|
-
<span className="min-w-0 truncate text-[11px] font-medium text-neutral-100">
|
|
2844
|
-
{formatTextFieldPreview(field.value) || `Text ${index + 1}`}
|
|
2845
|
-
</span>
|
|
2846
|
-
</div>
|
|
2847
|
-
<span className="flex-shrink-0 rounded-md border border-neutral-700 bg-neutral-950 px-1.5 py-0.5 text-[10px] uppercase tracking-[0.12em] text-neutral-500">
|
|
2848
|
-
{field.tagName}
|
|
2849
|
-
</span>
|
|
2850
|
-
</div>
|
|
2851
|
-
</button>
|
|
2852
|
-
);
|
|
2853
|
-
})}
|
|
2854
|
-
</div>
|
|
2855
|
-
</div>
|
|
2856
|
-
|
|
2857
|
-
<div className="space-y-4 rounded-xl border border-neutral-800 bg-neutral-900/60 p-3">
|
|
2858
|
-
<div className="flex min-w-0 items-center justify-between gap-2">
|
|
2859
|
-
<div className="min-w-0">
|
|
2860
|
-
<div className="truncate text-[11px] font-medium text-neutral-100">
|
|
2861
|
-
{formatTextFieldPreview(activeField.value) || "Text"}
|
|
2862
|
-
</div>
|
|
2863
|
-
<div className="text-[10px] uppercase tracking-[0.12em] text-neutral-500">
|
|
2864
|
-
{activeField.tagName}
|
|
2865
|
-
</div>
|
|
2866
|
-
</div>
|
|
2867
|
-
<button
|
|
2868
|
-
type="button"
|
|
2869
|
-
onClick={() => onRemoveTextField(activeField.key)}
|
|
2870
|
-
className="inline-flex h-7 flex-shrink-0 items-center 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"
|
|
2871
|
-
>
|
|
2872
|
-
Remove
|
|
2873
|
-
</button>
|
|
2874
|
-
</div>
|
|
2875
|
-
|
|
2876
|
-
<TextAreaField
|
|
2877
|
-
key={activeField.key}
|
|
2878
|
-
label="Content"
|
|
2879
|
-
value={activeField.value}
|
|
2880
|
-
disabled={false}
|
|
2881
|
-
autoFocus
|
|
2882
|
-
onCommit={(next) => onSetText(next, activeField.key)}
|
|
2883
|
-
/>
|
|
2884
|
-
|
|
2885
|
-
<ColorField
|
|
2886
|
-
label="Text color"
|
|
2887
|
-
value={getTextFieldColor(activeField, styles)}
|
|
2888
|
-
disabled={false}
|
|
2889
|
-
onCommit={(next) => onSetTextFieldStyle(activeField.key, "color", next)}
|
|
2890
|
-
/>
|
|
2891
|
-
|
|
2892
|
-
<div className={RESPONSIVE_GRID}>
|
|
2893
|
-
<MetricField
|
|
2894
|
-
label="Size"
|
|
2895
|
-
value={activeField.computedStyles["font-size"] || "16px"}
|
|
2896
|
-
disabled={false}
|
|
2897
|
-
liveCommit
|
|
2898
|
-
onCommit={(next) =>
|
|
2899
|
-
onSetTextFieldStyle(activeField.key, "font-size", next)
|
|
2900
|
-
}
|
|
2901
|
-
/>
|
|
2902
|
-
<FontWeightField
|
|
2903
|
-
value={activeField.computedStyles["font-weight"] || "400"}
|
|
2904
|
-
disabled={false}
|
|
2905
|
-
onCommit={(next) =>
|
|
2906
|
-
onSetTextFieldStyle(activeField.key, "font-weight", next)
|
|
2907
|
-
}
|
|
2908
|
-
/>
|
|
2909
|
-
</div>
|
|
2910
|
-
|
|
2911
|
-
<FontFamilyField
|
|
2912
|
-
value={
|
|
2913
|
-
activeField.computedStyles["font-family"] ||
|
|
2914
|
-
styles["font-family"] ||
|
|
2915
|
-
"inherit"
|
|
2916
|
-
}
|
|
2917
|
-
disabled={false}
|
|
2918
|
-
importedFonts={fontAssets}
|
|
2919
|
-
onImportFonts={onImportFonts}
|
|
2920
|
-
onCommit={(next) =>
|
|
2921
|
-
onSetTextFieldStyle(activeField.key, "font-family", next)
|
|
2922
|
-
}
|
|
2923
|
-
/>
|
|
2924
|
-
|
|
2925
|
-
<AdvancedTextControls
|
|
2926
|
-
field={activeField}
|
|
2927
|
-
inheritedStyles={styles}
|
|
2928
|
-
disabled={false}
|
|
2929
|
-
onCommit={(property, value) =>
|
|
2930
|
-
onSetTextFieldStyle(activeField.key, property, value)
|
|
2931
|
-
}
|
|
2932
|
-
/>
|
|
2933
|
-
</div>
|
|
2934
|
-
</div>
|
|
2935
|
-
);
|
|
2936
|
-
})()}
|
|
2937
|
-
</Section>
|
|
2938
|
-
)}
|
|
1906
|
+
{visibleSections.includes("Radius") && (
|
|
1907
|
+
<Section title="Radius" icon={<Settings size={15} />}>
|
|
1908
|
+
<SliderControl
|
|
1909
|
+
value={radiusValue}
|
|
1910
|
+
min={0}
|
|
1911
|
+
max={Math.max(240, Math.ceil(radiusValue))}
|
|
1912
|
+
step={1}
|
|
1913
|
+
disabled={styleEditingDisabled}
|
|
1914
|
+
displayValue={`${formatNumericValue(radiusValue)}px`}
|
|
1915
|
+
formatDisplayValue={(next) => `${formatNumericValue(next)}px`}
|
|
1916
|
+
onCommit={(next) => onSetStyle("border-radius", `${formatNumericValue(next)}px`)}
|
|
1917
|
+
/>
|
|
1918
|
+
</Section>
|
|
1919
|
+
)}
|
|
2939
1920
|
|
|
2940
|
-
|
|
2941
|
-
|
|
2942
|
-
|
|
2943
|
-
|
|
2944
|
-
|
|
2945
|
-
|
|
2946
|
-
|
|
2947
|
-
|
|
2948
|
-
|
|
2949
|
-
|
|
2950
|
-
)
|
|
2951
|
-
|
|
2952
|
-
|
|
2953
|
-
|
|
2954
|
-
|
|
1921
|
+
{visibleSections.includes("Shadow") && (
|
|
1922
|
+
<Section title="Shadow" icon={<Eye size={15} />}>
|
|
1923
|
+
<SelectField
|
|
1924
|
+
label="Box shadow"
|
|
1925
|
+
value={boxShadowPreset}
|
|
1926
|
+
disabled={styleEditingDisabled}
|
|
1927
|
+
onChange={(next) => {
|
|
1928
|
+
if (next === "custom") return;
|
|
1929
|
+
onSetStyle(
|
|
1930
|
+
"box-shadow",
|
|
1931
|
+
buildBoxShadowPresetValue(next as BoxShadowPreset, styles["box-shadow"]),
|
|
1932
|
+
);
|
|
1933
|
+
}}
|
|
1934
|
+
options={["custom", "none", "soft", "lift", "glow"]}
|
|
1935
|
+
/>
|
|
1936
|
+
</Section>
|
|
2955
1937
|
)}
|
|
2956
1938
|
</div>
|
|
2957
1939
|
</div>
|