import { useMemo, useRef, useState } from "react"; import { Plus, RotateCcw, X } from "../../icons/SystemIcons"; import { buildDefaultGradientModel, insertGradientStop, parseGradient, serializeGradient, type GradientModel, } from "./gradientValue"; import { IMAGE_EXT } from "../../utils/mediaTypes"; import { FIELD, LABEL, RESPONSIVE_GRID } from "./propertyPanelHelpers"; import { DetailField, SelectField, SegmentedControl, SliderControl, } from "./propertyPanelPrimitives"; import { ColorField } from "./propertyPanelColor"; /* ------------------------------------------------------------------ */ /* Asset path helpers */ /* ------------------------------------------------------------------ */ function normalizeProjectPath(value: string): string { const trimmed = value.trim(); const maybeUrl = /^[a-z]+:\/\//i.test(trimmed) ? new URL(trimmed).pathname : trimmed; return decodeURIComponent(maybeUrl) .replace(/\\/g, "/") .replace(/^\.?\//, ""); } function toRelativeProjectAssetPath(sourceFile: string, assetPath: string): string { const fromParts = normalizeProjectPath(sourceFile).split("/").filter(Boolean); const targetParts = normalizeProjectPath(assetPath).split("/").filter(Boolean); fromParts.pop(); while (fromParts.length > 0 && targetParts.length > 0 && fromParts[0] === targetParts[0]) { fromParts.shift(); targetParts.shift(); } return [...fromParts.map(() => ".."), ...targetParts].join("/") || assetPath; } function toProjectRootAssetPath(assetPath: string): string { return normalizeProjectPath(assetPath); } function resolveSelectedAsset( imageUrl: string, sourceFile: string, assets: string[], ): string | null { const normalizedUrl = normalizeProjectPath(imageUrl); if (!normalizedUrl) return null; for (const asset of assets) { const normalizedAsset = normalizeProjectPath(asset); const relativeAsset = toRelativeProjectAssetPath(sourceFile, asset); if ( normalizedUrl === normalizedAsset || normalizedUrl === relativeAsset || normalizedUrl.endsWith(`/${normalizedAsset}`) || normalizedUrl.endsWith(`/${relativeAsset}`) ) { return asset; } } return null; } /* ------------------------------------------------------------------ */ /* ImageFillField */ /* ------------------------------------------------------------------ */ export function ImageFillField({ projectId, sourceFile, value, assets, disabled, onCommit, onImportAssets, }: { projectId: string; sourceFile: string; value: string; assets: string[]; disabled?: boolean; onCommit: (nextValue: string) => void; onImportAssets?: (files: FileList) => Promise; }) { const fileInputRef = useRef(null); const [uploading, setUploading] = useState(false); const imageAssets = useMemo(() => assets.filter((a) => IMAGE_EXT.test(a)), [assets]); const selectedAsset = useMemo( () => resolveSelectedAsset(value, sourceFile, imageAssets), [imageAssets, sourceFile, value], ); const externalUrlValue = selectedAsset ? "" : value; const handleUpload = async (files: FileList | null) => { if (!files?.length || !onImportAssets) return; setUploading(true); try { const uploaded = await onImportAssets(files); const nextImage = uploaded.find((a) => IMAGE_EXT.test(a)); if (nextImage) onCommit(`url("${toProjectRootAssetPath(nextImage)}")`); } finally { setUploading(false); } }; return (
Project asset { await handleUpload(event.target.files); event.target.value = ""; }} />
{imageAssets.length > 0 ? (
{selectedAsset && (
{selectedAsset.split("/").pop()
)}
) : (
No image assets yet. Upload one here and Studio will also add it to the Assets tab.
)}
onCommit(next.trim() ? `url("${next.trim()}")` : "none")} />
); } /* ------------------------------------------------------------------ */ /* GradientField */ /* ------------------------------------------------------------------ */ export function GradientField({ value, fallbackColor, disabled, onCommit, }: { value: string; fallbackColor: string | undefined; disabled?: boolean; onCommit: (nextValue: string) => void; }) { const previewRef = useRef(null); const parsed = parseGradient(value) ?? buildDefaultGradientModel(fallbackColor); const commit = (next: GradientModel) => onCommit(serializeGradient(next)); const patch = (partial: Partial) => commit({ ...parsed, ...partial }); const updateStop = (index: number, partial: Partial) => { const stops = parsed.stops.map((stop, i) => (i === index ? { ...stop, ...partial } : stop)); commit({ ...parsed, stops }); }; const addStop = (position?: number) => { const nextGradient = position != null ? insertGradientStop(parsed, position) : insertGradientStop( parsed, parsed.stops.at(-1)?.position != null ? Math.min(100, (parsed.stops.at(-1)?.position ?? 90) + 10) : 100, ); commit(nextGradient); }; const removeStop = (index: number) => { if (parsed.stops.length <= 2) return; commit({ ...parsed, stops: parsed.stops.filter((_, i) => i !== index) }); }; const previewStyle = { backgroundImage: serializeGradient(parsed) }; return (
{ if (disabled) return; const rect = previewRef.current?.getBoundingClientRect(); if (!rect || rect.width <= 0) return; addStop(((event.clientX - rect.left) / rect.width) * 100); }} > {parsed.stops.map((stop, index) => (
))}
patch({ kind: next as GradientModel["kind"] })} options={[ { label: "Linear", value: "linear" }, { label: "Radial", value: "radial" }, { label: "Conic", value: "conic" }, ]} />
{(parsed.kind === "linear" || parsed.kind === "conic") && (
{parsed.kind === "linear" ? "Angle" : "Start angle"} `${Math.round(next)}°`} onCommit={(next) => patch({ angle: next })} />
)} {parsed.kind === "radial" && (
patch({ shape: next as GradientModel["shape"] })} options={["ellipse", "circle"]} /> patch({ radialSize: next as GradientModel["radialSize"] })} options={["closest-side", "closest-corner", "farthest-side", "farthest-corner"]} />
)} {(parsed.kind === "radial" || parsed.kind === "conic") && (
Center X `${Math.round(next)}%`} onCommit={(next) => patch({ centerX: next })} />
Center Y `${Math.round(next)}%`} onCommit={(next) => patch({ centerY: next })} />
)}
Stops
{parsed.stops.map((stop, index) => (
updateStop(index, { color: next })} /> updateStop(index, { position: Number.parseFloat(next.replace("%", "")) || 0, }) } />
))}
); }