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