@hyperframes/studio 0.6.0-alpha.8 → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/assets/{hyperframes-player-DjsVzYFP.js → hyperframes-player-DOFETgjy.js} +1 -1
- package/dist/assets/index-D1JDq7Gg.css +1 -0
- package/dist/assets/index-DUqUmaoH.js +117 -0
- package/dist/favicon.svg +14 -0
- package/dist/index.html +3 -2
- package/package.json +9 -9
- package/src/App.tsx +428 -4299
- package/src/components/AskAgentModal.tsx +120 -0
- package/src/components/StudioHeader.tsx +133 -0
- package/src/components/StudioLeftSidebar.tsx +125 -0
- package/src/components/StudioPreviewArea.tsx +163 -0
- package/src/components/StudioRightPanel.tsx +198 -0
- package/src/components/TimelineToolbar.tsx +89 -0
- package/src/components/editor/DomEditOverlay.tsx +15 -1
- package/src/components/editor/PropertyPanel.test.ts +0 -49
- package/src/components/editor/PropertyPanel.tsx +132 -2763
- package/src/components/editor/domEditing.ts +38 -5
- package/src/components/editor/manualEditingAvailability.test.ts +2 -2
- package/src/components/editor/manualEditingAvailability.ts +1 -1
- package/src/components/editor/manualEdits.ts +32 -0
- package/src/components/editor/propertyPanelColor.tsx +371 -0
- package/src/components/editor/propertyPanelFill.tsx +421 -0
- package/src/components/editor/propertyPanelFont.tsx +455 -0
- package/src/components/editor/propertyPanelHelpers.ts +401 -0
- package/src/components/editor/propertyPanelPrimitives.tsx +357 -0
- package/src/components/editor/propertyPanelSections.tsx +453 -0
- package/src/components/editor/propertyPanelStyleSections.tsx +411 -0
- package/src/components/nle/NLELayout.tsx +8 -11
- package/src/components/nle/NLEPreview.tsx +3 -0
- package/src/components/renders/RenderQueue.tsx +102 -31
- package/src/components/renders/useRenderQueue.ts +8 -2
- package/src/components/sidebar/LeftSidebar.tsx +186 -186
- package/src/contexts/DomEditContext.tsx +137 -0
- package/src/contexts/FileManagerContext.tsx +110 -0
- package/src/contexts/PanelLayoutContext.tsx +68 -0
- package/src/contexts/StudioContext.tsx +135 -0
- package/src/hooks/useAppHotkeys.ts +326 -0
- package/src/hooks/useAskAgentModal.ts +162 -0
- package/src/hooks/useCaptionDetection.ts +132 -0
- package/src/hooks/useCompositionDimensions.ts +25 -0
- package/src/hooks/useConsoleErrorCapture.ts +60 -0
- package/src/hooks/useDomEditCommits.ts +437 -0
- package/src/hooks/useDomEditSession.ts +342 -0
- package/src/hooks/useDomEditTextCommits.ts +330 -0
- package/src/hooks/useDomSelection.ts +398 -0
- package/src/hooks/useFileManager.ts +431 -0
- package/src/hooks/useFrameCapture.ts +77 -0
- package/src/hooks/useLintModal.ts +35 -0
- package/src/hooks/useManifestPersistence.ts +492 -0
- package/src/hooks/usePanelLayout.ts +68 -0
- package/src/hooks/usePreviewInteraction.ts +153 -0
- package/src/hooks/useRenderClipContent.ts +124 -0
- package/src/hooks/useTimelineEditing.ts +472 -0
- package/src/player/components/Player.tsx +35 -4
- package/src/player/components/Timeline.test.ts +0 -8
- package/src/player/components/Timeline.tsx +10 -103
- package/src/player/components/TimelineClip.tsx +9 -244
- package/src/player/hooks/useTimelinePlayer.ts +140 -103
- package/src/utils/domEditHelpers.ts +50 -0
- package/src/utils/studioFontHelpers.ts +83 -0
- package/src/utils/studioHelpers.ts +214 -0
- package/src/utils/studioPreviewHelpers.ts +185 -0
- package/src/utils/timelineDiscovery.ts +1 -1
- package/dist/assets/index-14zH9lqh.css +0 -1
- package/dist/assets/index-ClYcrksa.js +0 -108
- package/src/player/components/TimelineClip.test.ts +0 -92
|
@@ -1,54 +1,35 @@
|
|
|
1
|
+
import { memo } from "react";
|
|
2
|
+
import { Eye, Layers, MessageSquare, Move, RotateCcw, X } from "../../icons/SystemIcons";
|
|
1
3
|
import {
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
type ReactNode,
|
|
10
|
-
} from "react";
|
|
11
|
-
import { createPortal } from "react-dom";
|
|
4
|
+
collectDomEditLayerItems,
|
|
5
|
+
getDomEditLayerKey,
|
|
6
|
+
type DomEditSelection,
|
|
7
|
+
type DomEditLayerItem,
|
|
8
|
+
} from "./domEditing";
|
|
9
|
+
import { readStudioBoxSize, readStudioPathOffset, readStudioRotation } from "./manualEdits";
|
|
10
|
+
import type { ImportedFontAsset } from "./fontAssets";
|
|
12
11
|
import {
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
} from "./colorValue";
|
|
35
|
-
import {
|
|
36
|
-
buildDefaultGradientModel,
|
|
37
|
-
insertGradientStop,
|
|
38
|
-
parseGradient,
|
|
39
|
-
serializeGradient,
|
|
40
|
-
type GradientModel,
|
|
41
|
-
} from "./gradientValue";
|
|
42
|
-
import { isTextEditableSelection, type DomEditSelection } from "./domEditing";
|
|
43
|
-
import { readStudioBoxSize, readStudioPathOffset } from "./manualEdits";
|
|
44
|
-
import {
|
|
45
|
-
COMMON_LOCAL_FONT_FAMILIES,
|
|
46
|
-
googleFontStylesheetUrl,
|
|
47
|
-
POPULAR_GOOGLE_FONT_FAMILIES,
|
|
48
|
-
} from "./fontCatalog";
|
|
49
|
-
import { fontFamilyFromAssetPath, importedFontFaceCss, type ImportedFontAsset } from "./fontAssets";
|
|
50
|
-
import { resolveFloatingPanelPosition, type FloatingPosition } from "./floatingPanel";
|
|
51
|
-
import { IMAGE_EXT } from "../../utils/mediaTypes";
|
|
12
|
+
EMPTY_STYLES,
|
|
13
|
+
formatPxMetricValue,
|
|
14
|
+
LABEL,
|
|
15
|
+
parsePxMetricValue,
|
|
16
|
+
RESPONSIVE_GRID,
|
|
17
|
+
} from "./propertyPanelHelpers";
|
|
18
|
+
import { MetricField, Section } from "./propertyPanelPrimitives";
|
|
19
|
+
import { TextSection, StyleSections } from "./propertyPanelSections";
|
|
20
|
+
|
|
21
|
+
// Re-export helpers that external consumers import from this module
|
|
22
|
+
export {
|
|
23
|
+
buildStrokeStyleUpdates,
|
|
24
|
+
buildStrokeWidthStyleUpdates,
|
|
25
|
+
clampPanelNumber,
|
|
26
|
+
getCssFilterFunctionPx,
|
|
27
|
+
getClipPathInsetPx,
|
|
28
|
+
inferBoxShadowPreset,
|
|
29
|
+
inferClipPathPreset,
|
|
30
|
+
normalizePanelPxValue,
|
|
31
|
+
setCssFilterFunctionPx,
|
|
32
|
+
} from "./propertyPanelHelpers";
|
|
52
33
|
|
|
53
34
|
interface PropertyPanelProps {
|
|
54
35
|
projectId: string;
|
|
@@ -60,6 +41,7 @@ interface PropertyPanelProps {
|
|
|
60
41
|
onSetStyle: (prop: string, value: string) => void | Promise<void>;
|
|
61
42
|
onSetManualOffset: (element: DomEditSelection, next: { x: number; y: number }) => void;
|
|
62
43
|
onSetManualSize: (element: DomEditSelection, next: { width: number; height: number }) => void;
|
|
44
|
+
onSetManualRotation: (element: DomEditSelection, next: { angle: number }) => void;
|
|
63
45
|
onSetText: (value: string, fieldKey?: string) => void;
|
|
64
46
|
onSetTextFieldStyle: (fieldKey: string, property: string, value: string) => void;
|
|
65
47
|
onAddTextField: (afterFieldKey?: string) => string | Promise<string | null> | null;
|
|
@@ -69,2145 +51,73 @@ interface PropertyPanelProps {
|
|
|
69
51
|
onImportAssets?: (files: FileList) => Promise<string[]>;
|
|
70
52
|
fontAssets?: ImportedFontAsset[];
|
|
71
53
|
onImportFonts?: (files: FileList | File[]) => Promise<ImportedFontAsset[]>;
|
|
54
|
+
activeCompositionPath?: string | null;
|
|
55
|
+
onSelectLayer?: (layer: DomEditLayerItem) => void;
|
|
72
56
|
}
|
|
73
57
|
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
const RESPONSIVE_GRID = "grid grid-cols-[repeat(auto-fit,minmax(118px,1fr))] gap-3";
|
|
78
|
-
const EMPTY_STYLES: Record<string, string> = {};
|
|
79
|
-
const GENERIC_FONT_FAMILIES = new Set([
|
|
80
|
-
"inherit",
|
|
81
|
-
"initial",
|
|
82
|
-
"revert",
|
|
83
|
-
"revert-layer",
|
|
84
|
-
"serif",
|
|
85
|
-
"sans-serif",
|
|
86
|
-
"monospace",
|
|
87
|
-
"cursive",
|
|
88
|
-
"fantasy",
|
|
89
|
-
"system-ui",
|
|
90
|
-
"ui-sans-serif",
|
|
91
|
-
"ui-serif",
|
|
92
|
-
"ui-monospace",
|
|
93
|
-
"ui-rounded",
|
|
94
|
-
"emoji",
|
|
95
|
-
"math",
|
|
96
|
-
"fangsong",
|
|
97
|
-
]);
|
|
98
|
-
const DEFAULT_FONT_FAMILIES = [
|
|
99
|
-
...COMMON_LOCAL_FONT_FAMILIES,
|
|
100
|
-
"Inter",
|
|
101
|
-
"system-ui",
|
|
102
|
-
"sans-serif",
|
|
103
|
-
"serif",
|
|
104
|
-
"monospace",
|
|
105
|
-
];
|
|
106
|
-
interface LocalFontData {
|
|
107
|
-
family: string;
|
|
108
|
-
fullName?: string;
|
|
109
|
-
postscriptName?: string;
|
|
110
|
-
style?: string;
|
|
111
|
-
blob?: () => Promise<Blob>;
|
|
112
|
-
}
|
|
113
|
-
|
|
114
|
-
type FontSource = "Current" | "Document" | "Imported" | "Local" | "Google" | "System";
|
|
115
|
-
|
|
116
|
-
interface FontOption {
|
|
117
|
-
family: string;
|
|
118
|
-
source: FontSource;
|
|
119
|
-
}
|
|
120
|
-
|
|
121
|
-
const COLOR_PICKER_SIZE = { width: 292, height: 386 };
|
|
122
|
-
const EMPTY_FILTER_VALUE = "none";
|
|
123
|
-
const BOX_SHADOW_PRESETS = {
|
|
124
|
-
none: "none",
|
|
125
|
-
soft: "0 12px 36px rgba(0, 0, 0, 0.28)",
|
|
126
|
-
lift: "0 18px 54px rgba(0, 0, 0, 0.38)",
|
|
127
|
-
glow: "0 0 0 1px rgba(60, 230, 172, 0.34), 0 18px 56px rgba(60, 230, 172, 0.2)",
|
|
128
|
-
} as const;
|
|
129
|
-
|
|
130
|
-
type BoxShadowPreset = keyof typeof BOX_SHADOW_PRESETS | "custom";
|
|
131
|
-
|
|
132
|
-
function colorFromCss(value: string): ParsedColor {
|
|
133
|
-
return parseCssColor(value) ?? { red: 0, green: 0, blue: 0, alpha: 1 };
|
|
134
|
-
}
|
|
135
|
-
|
|
136
|
-
declare global {
|
|
137
|
-
interface Window {
|
|
138
|
-
queryLocalFonts?: () => Promise<LocalFontData[]>;
|
|
139
|
-
}
|
|
140
|
-
}
|
|
141
|
-
|
|
142
|
-
function sanitizeFontFilePart(value: string): string {
|
|
143
|
-
return value
|
|
144
|
-
.replace(/[^\w .-]+/g, " ")
|
|
145
|
-
.replace(/\s+/g, " ")
|
|
146
|
-
.trim();
|
|
147
|
-
}
|
|
148
|
-
|
|
149
|
-
function localFontSortScore(font: LocalFontData): number {
|
|
150
|
-
const style = font.style?.toLowerCase() ?? "";
|
|
151
|
-
const fullName = font.fullName?.toLowerCase() ?? "";
|
|
152
|
-
if (style === "regular" || fullName.endsWith(" regular")) return 0;
|
|
153
|
-
if (style === "normal" || fullName.endsWith(" normal")) return 1;
|
|
154
|
-
if (style === "medium" || fullName.endsWith(" medium")) return 2;
|
|
155
|
-
return 3;
|
|
156
|
-
}
|
|
157
|
-
|
|
158
|
-
function parseNumericValue(value: string | undefined): number | null {
|
|
159
|
-
if (!value) return null;
|
|
160
|
-
const parsed = Number.parseFloat(value);
|
|
161
|
-
return Number.isFinite(parsed) ? parsed : null;
|
|
162
|
-
}
|
|
163
|
-
|
|
164
|
-
function formatNumericValue(value: number): string {
|
|
165
|
-
const rounded = Math.round(value * 100) / 100;
|
|
166
|
-
return Number.isInteger(rounded)
|
|
167
|
-
? `${rounded}`
|
|
168
|
-
: rounded.toFixed(2).replace(/0+$/, "").replace(/\.$/, "");
|
|
169
|
-
}
|
|
170
|
-
|
|
171
|
-
interface ParsedNumericToken {
|
|
172
|
-
value: number;
|
|
173
|
-
unit: string;
|
|
174
|
-
}
|
|
175
|
-
|
|
176
|
-
function parseNumericToken(value: string | undefined): ParsedNumericToken | null {
|
|
177
|
-
if (!value) return null;
|
|
178
|
-
const match = value.trim().match(/^(-?\d+(?:\.\d+)?)([a-z%]*)$/i);
|
|
179
|
-
if (!match) return null;
|
|
180
|
-
const parsed = Number.parseFloat(match[1]);
|
|
181
|
-
if (!Number.isFinite(parsed)) return null;
|
|
182
|
-
return {
|
|
183
|
-
value: parsed,
|
|
184
|
-
unit: match[2] ?? "",
|
|
185
|
-
};
|
|
186
|
-
}
|
|
187
|
-
|
|
188
|
-
function parsePxMetricValue(value: string): number | null {
|
|
189
|
-
const token = parseNumericToken(value);
|
|
190
|
-
if (!token) return null;
|
|
191
|
-
if (token.unit && token.unit.toLowerCase() !== "px") return null;
|
|
192
|
-
return token.value;
|
|
193
|
-
}
|
|
194
|
-
|
|
195
|
-
export function clampPanelNumber(
|
|
196
|
-
value: number,
|
|
197
|
-
min: number,
|
|
198
|
-
max: number,
|
|
199
|
-
fallback: number,
|
|
200
|
-
): number {
|
|
201
|
-
if (!Number.isFinite(value)) return fallback;
|
|
202
|
-
return Math.max(min, Math.min(max, value));
|
|
203
|
-
}
|
|
58
|
+
/* ------------------------------------------------------------------ */
|
|
59
|
+
/* LayerTree */
|
|
60
|
+
/* ------------------------------------------------------------------ */
|
|
204
61
|
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
const token = parseNumericToken(value.trim());
|
|
210
|
-
if (!token) return null;
|
|
211
|
-
if (token.unit && token.unit.toLowerCase() !== "px") return null;
|
|
212
|
-
const next = clampPanelNumber(
|
|
213
|
-
token.value,
|
|
214
|
-
options.min ?? Number.NEGATIVE_INFINITY,
|
|
215
|
-
options.max ?? Number.POSITIVE_INFINITY,
|
|
216
|
-
options.fallback ?? 0,
|
|
217
|
-
);
|
|
218
|
-
return `${formatNumericValue(next)}px`;
|
|
219
|
-
}
|
|
220
|
-
|
|
221
|
-
function formatPxMetricValue(value: number): string {
|
|
222
|
-
return `${formatNumericValue(value)}px`;
|
|
223
|
-
}
|
|
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
|
-
|
|
237
|
-
function splitCssFunctions(value: string): string[] {
|
|
238
|
-
const functions: string[] = [];
|
|
239
|
-
let current = "";
|
|
240
|
-
let depth = 0;
|
|
241
|
-
|
|
242
|
-
for (const char of value.trim()) {
|
|
243
|
-
if (char === "(") depth += 1;
|
|
244
|
-
if (char === ")") depth = Math.max(0, depth - 1);
|
|
245
|
-
if (/\s/.test(char) && depth === 0) {
|
|
246
|
-
if (current.trim()) functions.push(current.trim());
|
|
247
|
-
current = "";
|
|
248
|
-
continue;
|
|
249
|
-
}
|
|
250
|
-
current += char;
|
|
251
|
-
}
|
|
252
|
-
|
|
253
|
-
if (current.trim()) functions.push(current.trim());
|
|
254
|
-
return functions;
|
|
255
|
-
}
|
|
256
|
-
|
|
257
|
-
export function getCssFilterFunctionPx(value: string | undefined, name: string): number {
|
|
258
|
-
const normalized = value?.trim();
|
|
259
|
-
if (!normalized || normalized === EMPTY_FILTER_VALUE) return 0;
|
|
260
|
-
const escapedName = name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
261
|
-
const match = new RegExp(`(?:^|\\s)${escapedName}\\((-?\\d+(?:\\.\\d+)?)px\\)`, "i").exec(
|
|
262
|
-
normalized,
|
|
263
|
-
);
|
|
264
|
-
if (!match) return 0;
|
|
265
|
-
const parsed = Number.parseFloat(match[1]);
|
|
266
|
-
return Number.isFinite(parsed) ? Math.max(0, parsed) : 0;
|
|
267
|
-
}
|
|
268
|
-
|
|
269
|
-
export function setCssFilterFunctionPx(
|
|
270
|
-
value: string | undefined,
|
|
271
|
-
name: string,
|
|
272
|
-
nextPx: number,
|
|
273
|
-
): string {
|
|
274
|
-
const nextValue = clampPanelNumber(nextPx, 0, 200, 0);
|
|
275
|
-
const functions = splitCssFunctions(value && value.trim() !== EMPTY_FILTER_VALUE ? value : "");
|
|
276
|
-
const lowerName = name.toLowerCase();
|
|
277
|
-
const filtered = functions.filter((entry) => !entry.toLowerCase().startsWith(`${lowerName}(`));
|
|
278
|
-
if (nextValue > 0) filtered.push(`${name}(${formatNumericValue(nextValue)}px)`);
|
|
279
|
-
return filtered.length > 0 ? filtered.join(" ") : EMPTY_FILTER_VALUE;
|
|
280
|
-
}
|
|
281
|
-
|
|
282
|
-
export function inferBoxShadowPreset(value: string | undefined): BoxShadowPreset {
|
|
283
|
-
const normalized = value?.trim() || "none";
|
|
284
|
-
for (const [preset, shadow] of Object.entries(BOX_SHADOW_PRESETS)) {
|
|
285
|
-
if (normalized === shadow) return preset as BoxShadowPreset;
|
|
286
|
-
}
|
|
287
|
-
return normalized === "none" ? "none" : "custom";
|
|
288
|
-
}
|
|
289
|
-
|
|
290
|
-
function buildBoxShadowPresetValue(preset: BoxShadowPreset, fallback: string | undefined): string {
|
|
291
|
-
if (preset === "custom") return fallback?.trim() || "none";
|
|
292
|
-
return BOX_SHADOW_PRESETS[preset];
|
|
293
|
-
}
|
|
294
|
-
|
|
295
|
-
export function inferClipPathPreset(
|
|
296
|
-
value: string | undefined,
|
|
297
|
-
): "none" | "inset" | "circle" | "custom" {
|
|
298
|
-
const normalized = value?.trim();
|
|
299
|
-
if (!normalized || normalized === "none") return "none";
|
|
300
|
-
if (/^inset\(/i.test(normalized)) return "inset";
|
|
301
|
-
if (/^circle\(/i.test(normalized)) return "circle";
|
|
302
|
-
return "custom";
|
|
303
|
-
}
|
|
304
|
-
|
|
305
|
-
export function getClipPathInsetPx(value: string | undefined): number {
|
|
306
|
-
const match = /^inset\(\s*(-?\d+(?:\.\d+)?)px\b/i.exec(value?.trim() ?? "");
|
|
307
|
-
if (!match) return 0;
|
|
308
|
-
const parsed = Number.parseFloat(match[1]);
|
|
309
|
-
return Number.isFinite(parsed) ? Math.max(0, parsed) : 0;
|
|
310
|
-
}
|
|
311
|
-
|
|
312
|
-
export function buildStrokeWidthStyleUpdates(
|
|
313
|
-
nextWidth: string,
|
|
314
|
-
currentBorderStyle: string | undefined,
|
|
315
|
-
): Array<[property: string, value: string]> {
|
|
316
|
-
const updates: Array<[property: string, value: string]> = [["border-width", nextWidth]];
|
|
317
|
-
const token = parseNumericToken(nextWidth);
|
|
318
|
-
const style = currentBorderStyle?.trim().toLowerCase() || "none";
|
|
319
|
-
if (token && token.value > 0 && (style === "none" || style === "hidden")) {
|
|
320
|
-
updates.push(["border-style", "solid"]);
|
|
321
|
-
}
|
|
322
|
-
return updates;
|
|
323
|
-
}
|
|
324
|
-
|
|
325
|
-
export function buildStrokeStyleUpdates(
|
|
326
|
-
nextStyle: string,
|
|
327
|
-
currentBorderWidth: string | undefined,
|
|
328
|
-
): Array<[property: string, value: string]> {
|
|
329
|
-
const updates: Array<[property: string, value: string]> = [["border-style", nextStyle]];
|
|
330
|
-
const style = nextStyle.trim().toLowerCase();
|
|
331
|
-
if (!style || style === "none" || style === "hidden") return updates;
|
|
332
|
-
|
|
333
|
-
const token = parseNumericToken(currentBorderWidth?.trim() || "0");
|
|
334
|
-
if (!token || token.value <= 0) {
|
|
335
|
-
updates.push(["border-width", "1px"]);
|
|
336
|
-
}
|
|
337
|
-
return updates;
|
|
338
|
-
}
|
|
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
|
-
|
|
357
|
-
function adjustNumericToken(
|
|
358
|
-
value: string,
|
|
359
|
-
direction: 1 | -1,
|
|
360
|
-
modifiers?: { shiftKey?: boolean; altKey?: boolean },
|
|
361
|
-
): string | null {
|
|
362
|
-
const token = parseNumericToken(value);
|
|
363
|
-
if (!token) return null;
|
|
364
|
-
|
|
365
|
-
const baseStep = modifiers?.altKey ? 0.1 : modifiers?.shiftKey ? 10 : 1;
|
|
366
|
-
const nextValue = token.value + baseStep * direction;
|
|
367
|
-
return `${formatNumericValue(nextValue)}${token.unit}`;
|
|
368
|
-
}
|
|
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
|
-
|
|
487
|
-
function CommitField({
|
|
488
|
-
value,
|
|
489
|
-
disabled,
|
|
490
|
-
liveCommit,
|
|
491
|
-
onCommit,
|
|
492
|
-
}: {
|
|
493
|
-
value: string;
|
|
494
|
-
disabled?: boolean;
|
|
495
|
-
liveCommit?: boolean;
|
|
496
|
-
onCommit: (nextValue: string) => void;
|
|
497
|
-
}) {
|
|
498
|
-
const [draft, setDraft] = useState(value);
|
|
499
|
-
const commitTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
500
|
-
const valueRef = useRef(value);
|
|
501
|
-
|
|
502
|
-
valueRef.current = value;
|
|
503
|
-
|
|
504
|
-
useEffect(() => {
|
|
505
|
-
setDraft(value);
|
|
506
|
-
}, [value]);
|
|
507
|
-
|
|
508
|
-
useEffect(
|
|
509
|
-
() => () => {
|
|
510
|
-
if (commitTimerRef.current) clearTimeout(commitTimerRef.current);
|
|
511
|
-
},
|
|
512
|
-
[],
|
|
513
|
-
);
|
|
514
|
-
|
|
515
|
-
const commitDraft = (nextDraft: string) => {
|
|
516
|
-
if (commitTimerRef.current) clearTimeout(commitTimerRef.current);
|
|
517
|
-
if (nextDraft !== valueRef.current) {
|
|
518
|
-
onCommit(nextDraft);
|
|
519
|
-
}
|
|
520
|
-
};
|
|
521
|
-
|
|
522
|
-
const scheduleCommit = (nextDraft: string) => {
|
|
523
|
-
if (commitTimerRef.current) clearTimeout(commitTimerRef.current);
|
|
524
|
-
commitTimerRef.current = setTimeout(() => {
|
|
525
|
-
if (nextDraft !== valueRef.current) {
|
|
526
|
-
onCommit(nextDraft);
|
|
527
|
-
}
|
|
528
|
-
}, 120);
|
|
529
|
-
};
|
|
530
|
-
|
|
531
|
-
return (
|
|
532
|
-
<input
|
|
533
|
-
type="text"
|
|
534
|
-
value={draft}
|
|
535
|
-
disabled={disabled}
|
|
536
|
-
onChange={(e) => {
|
|
537
|
-
setDraft(e.target.value);
|
|
538
|
-
if (liveCommit) scheduleCommit(e.target.value);
|
|
539
|
-
}}
|
|
540
|
-
onBlur={() => commitDraft(draft)}
|
|
541
|
-
onWheel={(e) => {
|
|
542
|
-
if (disabled) return;
|
|
543
|
-
const delta = e.deltaY === 0 ? e.deltaX : e.deltaY;
|
|
544
|
-
if (delta === 0) return;
|
|
545
|
-
const nextDraft = adjustNumericToken(draft, delta < 0 ? 1 : -1, e);
|
|
546
|
-
if (!nextDraft) return;
|
|
547
|
-
e.preventDefault();
|
|
548
|
-
setDraft(nextDraft);
|
|
549
|
-
scheduleCommit(nextDraft);
|
|
550
|
-
}}
|
|
551
|
-
onKeyDown={(e) => {
|
|
552
|
-
if (e.key === "Enter") {
|
|
553
|
-
(e.target as HTMLInputElement).blur();
|
|
554
|
-
return;
|
|
555
|
-
}
|
|
556
|
-
if (e.key !== "ArrowUp" && e.key !== "ArrowDown") return;
|
|
557
|
-
const nextDraft = adjustNumericToken(draft, e.key === "ArrowUp" ? 1 : -1, e);
|
|
558
|
-
if (!nextDraft) return;
|
|
559
|
-
e.preventDefault();
|
|
560
|
-
setDraft(nextDraft);
|
|
561
|
-
scheduleCommit(nextDraft);
|
|
562
|
-
}}
|
|
563
|
-
title={parseNumericToken(value) ? "Scroll or use Arrow keys to adjust" : undefined}
|
|
564
|
-
className="min-w-0 w-full bg-transparent text-[11px] font-medium text-neutral-100 outline-none disabled:cursor-not-allowed disabled:text-neutral-600"
|
|
565
|
-
/>
|
|
566
|
-
);
|
|
567
|
-
}
|
|
568
|
-
|
|
569
|
-
function MetricField({
|
|
570
|
-
label,
|
|
571
|
-
value,
|
|
572
|
-
disabled,
|
|
573
|
-
liveCommit,
|
|
574
|
-
onCommit,
|
|
575
|
-
}: {
|
|
576
|
-
label: string;
|
|
577
|
-
value: string;
|
|
578
|
-
disabled?: boolean;
|
|
579
|
-
liveCommit?: boolean;
|
|
580
|
-
onCommit: (nextValue: string) => void;
|
|
581
|
-
}) {
|
|
582
|
-
return (
|
|
583
|
-
<div className={FIELD}>
|
|
584
|
-
<div className="flex min-w-0 items-center gap-3">
|
|
585
|
-
<span className="flex-shrink-0 text-[11px] font-medium text-neutral-500">{label}</span>
|
|
586
|
-
<CommitField
|
|
587
|
-
value={value}
|
|
588
|
-
disabled={disabled}
|
|
589
|
-
liveCommit={liveCommit}
|
|
590
|
-
onCommit={onCommit}
|
|
591
|
-
/>
|
|
592
|
-
</div>
|
|
593
|
-
</div>
|
|
594
|
-
);
|
|
595
|
-
}
|
|
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
|
-
|
|
618
|
-
function TextAreaField({
|
|
619
|
-
label,
|
|
620
|
-
value,
|
|
621
|
-
disabled,
|
|
622
|
-
autoFocus,
|
|
623
|
-
onCommit,
|
|
62
|
+
function LayerTree({
|
|
63
|
+
element,
|
|
64
|
+
activeCompositionPath,
|
|
65
|
+
onSelectLayer,
|
|
624
66
|
}: {
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
autoFocus?: boolean;
|
|
629
|
-
onCommit: (nextValue: string) => void;
|
|
67
|
+
element: DomEditSelection | null;
|
|
68
|
+
activeCompositionPath: string | null;
|
|
69
|
+
onSelectLayer: (layer: DomEditLayerItem) => void;
|
|
630
70
|
}) {
|
|
631
|
-
const
|
|
632
|
-
const
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
const valueRef = useRef(value);
|
|
636
|
-
|
|
637
|
-
valueRef.current = value;
|
|
638
|
-
|
|
639
|
-
useEffect(() => {
|
|
640
|
-
if (focusedRef.current) return;
|
|
641
|
-
setDraft(value);
|
|
642
|
-
}, [value]);
|
|
643
|
-
|
|
644
|
-
useEffect(
|
|
645
|
-
() => () => {
|
|
646
|
-
if (commitTimerRef.current) clearTimeout(commitTimerRef.current);
|
|
647
|
-
},
|
|
648
|
-
[],
|
|
649
|
-
);
|
|
650
|
-
|
|
651
|
-
useEffect(() => {
|
|
652
|
-
if (!autoFocus) return;
|
|
653
|
-
textareaRef.current?.focus();
|
|
654
|
-
}, [autoFocus]);
|
|
655
|
-
|
|
656
|
-
const commitDraft = (nextDraft: string) => {
|
|
657
|
-
if (commitTimerRef.current) clearTimeout(commitTimerRef.current);
|
|
658
|
-
if (nextDraft !== valueRef.current) {
|
|
659
|
-
onCommit(nextDraft);
|
|
660
|
-
}
|
|
661
|
-
};
|
|
662
|
-
|
|
663
|
-
const scheduleCommit = (nextDraft: string) => {
|
|
664
|
-
if (commitTimerRef.current) clearTimeout(commitTimerRef.current);
|
|
665
|
-
commitTimerRef.current = setTimeout(() => {
|
|
666
|
-
if (nextDraft !== valueRef.current) {
|
|
667
|
-
onCommit(nextDraft);
|
|
668
|
-
}
|
|
669
|
-
}, 120);
|
|
670
|
-
};
|
|
671
|
-
|
|
672
|
-
return (
|
|
673
|
-
<label className="grid min-w-0 gap-1.5">
|
|
674
|
-
<span className={LABEL}>{label}</span>
|
|
675
|
-
<div className={FIELD}>
|
|
676
|
-
<textarea
|
|
677
|
-
ref={textareaRef}
|
|
678
|
-
value={draft}
|
|
679
|
-
disabled={disabled}
|
|
680
|
-
rows={4}
|
|
681
|
-
onFocus={() => {
|
|
682
|
-
focusedRef.current = true;
|
|
683
|
-
}}
|
|
684
|
-
onChange={(e) => {
|
|
685
|
-
setDraft(e.target.value);
|
|
686
|
-
scheduleCommit(e.target.value);
|
|
687
|
-
}}
|
|
688
|
-
onBlur={() => {
|
|
689
|
-
focusedRef.current = false;
|
|
690
|
-
commitDraft(draft);
|
|
691
|
-
}}
|
|
692
|
-
className="w-full resize-none bg-transparent text-[11px] font-medium text-neutral-100 outline-none disabled:cursor-not-allowed disabled:text-neutral-600"
|
|
693
|
-
/>
|
|
694
|
-
</div>
|
|
695
|
-
</label>
|
|
696
|
-
);
|
|
697
|
-
}
|
|
698
|
-
|
|
699
|
-
function formatTextFieldPreview(value: string): string {
|
|
700
|
-
const collapsed = value.trim().replace(/\s+/g, " ");
|
|
701
|
-
if (collapsed.length <= 56) return collapsed;
|
|
702
|
-
return `${collapsed.slice(0, 55)}…`;
|
|
703
|
-
}
|
|
704
|
-
|
|
705
|
-
function getTextFieldColor(
|
|
706
|
-
field: { computedStyles: Record<string, string> },
|
|
707
|
-
inheritedStyles: Record<string, string>,
|
|
708
|
-
): string {
|
|
709
|
-
return field.computedStyles.color || inheritedStyles.color || "rgb(0, 0, 0)";
|
|
710
|
-
}
|
|
711
|
-
|
|
712
|
-
function splitFontFamilies(value: string): string[] {
|
|
713
|
-
const families: string[] = [];
|
|
714
|
-
let current = "";
|
|
715
|
-
let quote: '"' | "'" | null = null;
|
|
716
|
-
|
|
717
|
-
for (const char of value) {
|
|
718
|
-
if ((char === '"' || char === "'") && !quote) {
|
|
719
|
-
quote = char;
|
|
720
|
-
continue;
|
|
721
|
-
}
|
|
722
|
-
if (char === quote) {
|
|
723
|
-
quote = null;
|
|
724
|
-
continue;
|
|
725
|
-
}
|
|
726
|
-
if (char === "," && !quote) {
|
|
727
|
-
if (current.trim()) families.push(current.trim());
|
|
728
|
-
current = "";
|
|
729
|
-
continue;
|
|
730
|
-
}
|
|
731
|
-
current += char;
|
|
732
|
-
}
|
|
733
|
-
|
|
734
|
-
if (current.trim()) families.push(current.trim());
|
|
735
|
-
return families.map((family) => family.replace(/^["']|["']$/g, "").trim()).filter(Boolean);
|
|
736
|
-
}
|
|
737
|
-
|
|
738
|
-
function primaryFontFamily(value: string): string {
|
|
739
|
-
return splitFontFamilies(value)[0] ?? "inherit";
|
|
740
|
-
}
|
|
741
|
-
|
|
742
|
-
function quoteFontFamily(family: string): string {
|
|
743
|
-
const trimmed = family.trim();
|
|
744
|
-
if (GENERIC_FONT_FAMILIES.has(trimmed.toLowerCase())) return trimmed;
|
|
745
|
-
return `"${trimmed.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`;
|
|
746
|
-
}
|
|
747
|
-
|
|
748
|
-
function buildFontFamilyValue(family: string): string {
|
|
749
|
-
const trimmed = family.trim();
|
|
750
|
-
if (!trimmed) return "inherit";
|
|
751
|
-
if (GENERIC_FONT_FAMILIES.has(trimmed.toLowerCase())) return trimmed;
|
|
752
|
-
return `${quoteFontFamily(trimmed)}, ui-sans-serif, system-ui, sans-serif`;
|
|
753
|
-
}
|
|
754
|
-
|
|
755
|
-
function collectDocumentFontFamilies(): string[] {
|
|
756
|
-
if (typeof document === "undefined") return [];
|
|
757
|
-
const fontSet = document.fonts;
|
|
758
|
-
if (!fontSet) return [];
|
|
759
|
-
return Array.from(fontSet, (fontFace) => fontFace.family.replace(/^["']|["']$/g, "").trim())
|
|
760
|
-
.filter(Boolean)
|
|
761
|
-
.sort((a, b) => a.localeCompare(b));
|
|
762
|
-
}
|
|
763
|
-
|
|
764
|
-
function uniqueFontFamilies(values: string[]): string[] {
|
|
765
|
-
const seen = new Set<string>();
|
|
766
|
-
const result: string[] = [];
|
|
767
|
-
for (const value of values) {
|
|
768
|
-
const family = value.trim();
|
|
769
|
-
if (!family) continue;
|
|
770
|
-
const key = family.toLowerCase();
|
|
771
|
-
if (seen.has(key)) continue;
|
|
772
|
-
seen.add(key);
|
|
773
|
-
result.push(family);
|
|
774
|
-
}
|
|
775
|
-
return result;
|
|
776
|
-
}
|
|
777
|
-
|
|
778
|
-
function uniqueFontOptions(values: FontOption[]): FontOption[] {
|
|
779
|
-
const seen = new Set<string>();
|
|
780
|
-
const result: FontOption[] = [];
|
|
781
|
-
for (const value of values) {
|
|
782
|
-
const family = value.family.trim();
|
|
783
|
-
if (!family) continue;
|
|
784
|
-
const key = family.toLowerCase();
|
|
785
|
-
if (seen.has(key)) continue;
|
|
786
|
-
seen.add(key);
|
|
787
|
-
result.push({ family, source: value.source });
|
|
788
|
-
}
|
|
789
|
-
return result;
|
|
790
|
-
}
|
|
791
|
-
|
|
792
|
-
function fontSourceRank(source: FontSource): number {
|
|
793
|
-
if (source === "Current") return 0;
|
|
794
|
-
if (source === "Document") return 1;
|
|
795
|
-
if (source === "Imported") return 2;
|
|
796
|
-
if (source === "Local") return 3;
|
|
797
|
-
if (source === "Google") return 4;
|
|
798
|
-
return 5;
|
|
799
|
-
}
|
|
800
|
-
|
|
801
|
-
function sortFontOptions(options: FontOption[]): FontOption[] {
|
|
802
|
-
return [...options].sort((a, b) => {
|
|
803
|
-
const rankDelta = fontSourceRank(a.source) - fontSourceRank(b.source);
|
|
804
|
-
if (rankDelta !== 0) return rankDelta;
|
|
805
|
-
|
|
806
|
-
const commonA = COMMON_LOCAL_FONT_FAMILIES.findIndex(
|
|
807
|
-
(family) => family.toLowerCase() === a.family.toLowerCase(),
|
|
808
|
-
);
|
|
809
|
-
const commonB = COMMON_LOCAL_FONT_FAMILIES.findIndex(
|
|
810
|
-
(family) => family.toLowerCase() === b.family.toLowerCase(),
|
|
811
|
-
);
|
|
812
|
-
const commonDelta =
|
|
813
|
-
(commonA === -1 ? Number.MAX_SAFE_INTEGER : commonA) -
|
|
814
|
-
(commonB === -1 ? Number.MAX_SAFE_INTEGER : commonB);
|
|
815
|
-
|
|
816
|
-
return commonDelta === 0 ? a.family.localeCompare(b.family) : commonDelta;
|
|
71
|
+
const isMasterView = !activeCompositionPath || activeCompositionPath === "index.html";
|
|
72
|
+
const layers = collectDomEditLayerItems(element?.element, {
|
|
73
|
+
activeCompositionPath,
|
|
74
|
+
isMasterView,
|
|
817
75
|
});
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
function fontSearchKey(value: string): string {
|
|
821
|
-
return value.toLowerCase().replace(/[^a-z0-9]+/g, "");
|
|
822
|
-
}
|
|
823
|
-
|
|
824
|
-
function fontMatchesQuery(family: string, query: string): boolean {
|
|
825
|
-
const normalizedQuery = query.trim().toLowerCase();
|
|
826
|
-
if (!normalizedQuery) return true;
|
|
827
|
-
const normalizedFamily = family.toLowerCase();
|
|
828
|
-
if (normalizedFamily.includes(normalizedQuery)) return true;
|
|
829
|
-
return fontSearchKey(family).includes(fontSearchKey(normalizedQuery));
|
|
830
|
-
}
|
|
831
|
-
|
|
832
|
-
function loadGoogleFontStylesheet(family: string): void {
|
|
833
|
-
if (typeof document === "undefined") return;
|
|
834
|
-
const trimmed = family.trim();
|
|
835
|
-
if (!trimmed) return;
|
|
836
|
-
|
|
837
|
-
const id = `studio-google-font-${trimmed.toLowerCase().replace(/[^a-z0-9]+/g, "-")}`;
|
|
838
|
-
if (document.getElementById(id)) return;
|
|
839
|
-
|
|
840
|
-
const preconnect = document.querySelector('link[data-studio-google-font-preconnect="true"]');
|
|
841
|
-
if (!preconnect) {
|
|
842
|
-
const preconnectEl = document.createElement("link");
|
|
843
|
-
preconnectEl.setAttribute("data-studio-google-font-preconnect", "true");
|
|
844
|
-
preconnectEl.rel = "preconnect";
|
|
845
|
-
preconnectEl.href = "https://fonts.gstatic.com";
|
|
846
|
-
preconnectEl.crossOrigin = "anonymous";
|
|
847
|
-
document.head.appendChild(preconnectEl);
|
|
848
|
-
}
|
|
849
|
-
|
|
850
|
-
const link = document.createElement("link");
|
|
851
|
-
link.id = id;
|
|
852
|
-
link.rel = "stylesheet";
|
|
853
|
-
link.href = googleFontStylesheetUrl(trimmed);
|
|
854
|
-
document.head.appendChild(link);
|
|
855
|
-
}
|
|
856
|
-
|
|
857
|
-
function loadImportedFontStylesheet(asset: ImportedFontAsset): void {
|
|
858
|
-
if (typeof document === "undefined") return;
|
|
859
|
-
const id = `studio-imported-font-${asset.family.toLowerCase().replace(/[^a-z0-9]+/g, "-")}`;
|
|
860
|
-
if (document.getElementById(id)) return;
|
|
861
|
-
|
|
862
|
-
const style = document.createElement("style");
|
|
863
|
-
style.id = id;
|
|
864
|
-
style.textContent = importedFontFaceCss(asset);
|
|
865
|
-
document.head.appendChild(style);
|
|
866
|
-
}
|
|
867
|
-
|
|
868
|
-
function FontWeightField({
|
|
869
|
-
value,
|
|
870
|
-
disabled,
|
|
871
|
-
onCommit,
|
|
872
|
-
}: {
|
|
873
|
-
value: string;
|
|
874
|
-
disabled?: boolean;
|
|
875
|
-
onCommit: (nextValue: string) => void;
|
|
876
|
-
}) {
|
|
877
|
-
const options = ["300", "400", "500", "600", "700", "800"];
|
|
878
|
-
return (
|
|
879
|
-
<div className={FIELD}>
|
|
880
|
-
<div className="flex min-w-0 items-center gap-3">
|
|
881
|
-
<span className="flex-shrink-0 text-[11px] font-medium text-neutral-500">Weight</span>
|
|
882
|
-
<select
|
|
883
|
-
value={value}
|
|
884
|
-
disabled={disabled}
|
|
885
|
-
onChange={(e) => onCommit(e.target.value)}
|
|
886
|
-
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"
|
|
887
|
-
>
|
|
888
|
-
{options.map((option) => (
|
|
889
|
-
<option key={option} value={option}>
|
|
890
|
-
{option}
|
|
891
|
-
</option>
|
|
892
|
-
))}
|
|
893
|
-
</select>
|
|
894
|
-
</div>
|
|
895
|
-
</div>
|
|
896
|
-
);
|
|
897
|
-
}
|
|
898
|
-
|
|
899
|
-
function FontFamilyField({
|
|
900
|
-
value,
|
|
901
|
-
disabled,
|
|
902
|
-
importedFonts,
|
|
903
|
-
onImportFonts,
|
|
904
|
-
onCommit,
|
|
905
|
-
}: {
|
|
906
|
-
value: string;
|
|
907
|
-
disabled?: boolean;
|
|
908
|
-
importedFonts: ImportedFontAsset[];
|
|
909
|
-
onImportFonts?: (files: FileList | File[]) => Promise<ImportedFontAsset[]>;
|
|
910
|
-
onCommit: (nextValue: string) => void;
|
|
911
|
-
}) {
|
|
912
|
-
const currentFamily = primaryFontFamily(value);
|
|
913
|
-
const containerRef = useRef<HTMLDivElement | null>(null);
|
|
914
|
-
const inputRef = useRef<HTMLInputElement | null>(null);
|
|
915
|
-
const fontInputRef = useRef<HTMLInputElement | null>(null);
|
|
916
|
-
const [open, setOpen] = useState(false);
|
|
917
|
-
const [query, setQuery] = useState("");
|
|
918
|
-
const [localFonts, setLocalFonts] = useState<string[]>([]);
|
|
919
|
-
const [localFontData, setLocalFontData] = useState<LocalFontData[]>([]);
|
|
920
|
-
const [googleFonts, setGoogleFonts] = useState<string[]>(() => [...POPULAR_GOOGLE_FONT_FAMILIES]);
|
|
921
|
-
const [loadingLocalFonts, setLoadingLocalFonts] = useState(false);
|
|
922
|
-
const [loadingGoogleFonts, setLoadingGoogleFonts] = useState(false);
|
|
923
|
-
const [importingFonts, setImportingFonts] = useState(false);
|
|
924
|
-
const [fontNotice, setFontNotice] = useState<string | null>(null);
|
|
925
|
-
const canQueryLocalFonts =
|
|
926
|
-
typeof window !== "undefined" && typeof window.queryLocalFonts === "function";
|
|
927
|
-
|
|
928
|
-
useEffect(() => {
|
|
929
|
-
if (!open) return;
|
|
930
|
-
const handlePointerDown = (event: PointerEvent) => {
|
|
931
|
-
const target = event.target;
|
|
932
|
-
if (!(target instanceof Node)) return;
|
|
933
|
-
if (!containerRef.current?.contains(target)) setOpen(false);
|
|
934
|
-
};
|
|
935
|
-
document.addEventListener("pointerdown", handlePointerDown);
|
|
936
|
-
return () => {
|
|
937
|
-
document.removeEventListener("pointerdown", handlePointerDown);
|
|
938
|
-
};
|
|
939
|
-
}, [open]);
|
|
940
|
-
|
|
941
|
-
useEffect(() => {
|
|
942
|
-
if (!open) return;
|
|
943
|
-
requestAnimationFrame(() => inputRef.current?.focus());
|
|
944
|
-
}, [open]);
|
|
945
|
-
|
|
946
|
-
useEffect(() => {
|
|
947
|
-
let cancelled = false;
|
|
948
|
-
void fetch("/api/fonts")
|
|
949
|
-
.then((response) => (response.ok ? response.json() : null))
|
|
950
|
-
.then((data: { fonts?: string[] } | null) => {
|
|
951
|
-
const fonts = data?.fonts;
|
|
952
|
-
if (cancelled || !Array.isArray(fonts)) return;
|
|
953
|
-
setLocalFonts((current) => uniqueFontFamilies([...current, ...fonts]));
|
|
954
|
-
})
|
|
955
|
-
.catch(() => undefined);
|
|
956
|
-
return () => {
|
|
957
|
-
cancelled = true;
|
|
958
|
-
};
|
|
959
|
-
}, []);
|
|
960
|
-
|
|
961
|
-
useEffect(() => {
|
|
962
|
-
let cancelled = false;
|
|
963
|
-
setLoadingGoogleFonts(true);
|
|
964
|
-
void fetch("/api/fonts/google")
|
|
965
|
-
.then((response) => (response.ok ? response.json() : null))
|
|
966
|
-
.then((data: { fonts?: string[] } | null) => {
|
|
967
|
-
const fonts = data?.fonts;
|
|
968
|
-
if (cancelled || !Array.isArray(fonts)) return;
|
|
969
|
-
setGoogleFonts(uniqueFontFamilies([...fonts, ...POPULAR_GOOGLE_FONT_FAMILIES]));
|
|
970
|
-
})
|
|
971
|
-
.catch(() => undefined)
|
|
972
|
-
.finally(() => {
|
|
973
|
-
if (!cancelled) setLoadingGoogleFonts(false);
|
|
974
|
-
});
|
|
975
|
-
return () => {
|
|
976
|
-
cancelled = true;
|
|
977
|
-
};
|
|
978
|
-
}, []);
|
|
979
|
-
|
|
980
|
-
useEffect(() => {
|
|
981
|
-
if (googleFonts.some((family) => family.toLowerCase() === currentFamily.toLowerCase())) {
|
|
982
|
-
loadGoogleFontStylesheet(currentFamily);
|
|
983
|
-
}
|
|
984
|
-
const imported = importedFonts.find(
|
|
985
|
-
(font) => font.family.toLowerCase() === currentFamily.toLowerCase(),
|
|
986
|
-
);
|
|
987
|
-
if (imported) loadImportedFontStylesheet(imported);
|
|
988
|
-
}, [currentFamily, googleFonts, importedFonts]);
|
|
989
|
-
|
|
990
|
-
const loadBrowserLocalFonts = async () => {
|
|
991
|
-
if (!canQueryLocalFonts || !window.queryLocalFonts) {
|
|
992
|
-
setFontNotice("This browser does not expose installed fonts. Import a font file instead.");
|
|
993
|
-
return;
|
|
994
|
-
}
|
|
995
|
-
setLoadingLocalFonts(true);
|
|
996
|
-
setFontNotice(null);
|
|
997
|
-
try {
|
|
998
|
-
const fonts = await window.queryLocalFonts();
|
|
999
|
-
const sortedFonts = [...fonts].sort((a, b) => localFontSortScore(a) - localFontSortScore(b));
|
|
1000
|
-
const families = sortedFonts
|
|
1001
|
-
.map((font) => font.family)
|
|
1002
|
-
.filter((name): name is string => Boolean(name))
|
|
1003
|
-
.map((name) => fontFamilyFromAssetPath(`${name}.ttf`));
|
|
1004
|
-
setLocalFontData(sortedFonts);
|
|
1005
|
-
setLocalFonts((current) => uniqueFontFamilies([...current, ...families]));
|
|
1006
|
-
setFontNotice(fonts.length === 0 ? "No browser-local fonts were returned." : null);
|
|
1007
|
-
} catch (error) {
|
|
1008
|
-
const name = error instanceof Error ? error.name : "";
|
|
1009
|
-
setFontNotice(
|
|
1010
|
-
name === "NotAllowedError"
|
|
1011
|
-
? "Local font access was denied. Import a font file instead."
|
|
1012
|
-
: "Local font access is unavailable. Import a font file instead.",
|
|
1013
|
-
);
|
|
1014
|
-
} finally {
|
|
1015
|
-
setLoadingLocalFonts(false);
|
|
1016
|
-
}
|
|
1017
|
-
};
|
|
1018
|
-
|
|
1019
|
-
const handleImportFonts = async (files: FileList | File[] | null) => {
|
|
1020
|
-
if (!files?.length || !onImportFonts) return;
|
|
1021
|
-
setImportingFonts(true);
|
|
1022
|
-
setFontNotice(null);
|
|
1023
|
-
try {
|
|
1024
|
-
const imported = await onImportFonts(files);
|
|
1025
|
-
for (const font of imported) loadImportedFontStylesheet(font);
|
|
1026
|
-
const first = imported[0];
|
|
1027
|
-
if (first) {
|
|
1028
|
-
onCommit(buildFontFamilyValue(first.family));
|
|
1029
|
-
setQuery("");
|
|
1030
|
-
setOpen(false);
|
|
1031
|
-
} else {
|
|
1032
|
-
setFontNotice("No supported font files were imported.");
|
|
1033
|
-
}
|
|
1034
|
-
} finally {
|
|
1035
|
-
setImportingFonts(false);
|
|
1036
|
-
}
|
|
1037
|
-
};
|
|
1038
|
-
|
|
1039
|
-
const projectFontAssets = useMemo(
|
|
1040
|
-
() =>
|
|
1041
|
-
uniqueFontOptions(
|
|
1042
|
-
importedFonts.map((font): FontOption => ({ family: font.family, source: "Imported" })),
|
|
1043
|
-
),
|
|
1044
|
-
[importedFonts],
|
|
1045
|
-
);
|
|
1046
|
-
|
|
1047
|
-
const options = useMemo(() => {
|
|
1048
|
-
const documentFonts = collectDocumentFontFamilies();
|
|
1049
|
-
return sortFontOptions(
|
|
1050
|
-
uniqueFontOptions([
|
|
1051
|
-
{ family: currentFamily, source: "Current" },
|
|
1052
|
-
...documentFonts.map((family): FontOption => ({ family, source: "Document" })),
|
|
1053
|
-
...projectFontAssets,
|
|
1054
|
-
...localFonts.map((family): FontOption => ({ family, source: "Local" })),
|
|
1055
|
-
...googleFonts.map((family): FontOption => ({ family, source: "Google" })),
|
|
1056
|
-
...DEFAULT_FONT_FAMILIES.map((family): FontOption => ({ family, source: "System" })),
|
|
1057
|
-
]),
|
|
1058
|
-
);
|
|
1059
|
-
}, [currentFamily, googleFonts, localFonts, projectFontAssets]);
|
|
1060
|
-
|
|
1061
|
-
const filteredOptions = useMemo(() => {
|
|
1062
|
-
const matches = options.filter((option) => fontMatchesQuery(option.family, query));
|
|
1063
|
-
return matches.slice(0, query.trim() ? 120 : 160);
|
|
1064
|
-
}, [options, query]);
|
|
1065
|
-
|
|
1066
|
-
const importLocalFont = async (family: string): Promise<ImportedFontAsset | null> => {
|
|
1067
|
-
if (!onImportFonts) return null;
|
|
1068
|
-
const candidates = localFontData
|
|
1069
|
-
.filter((font) => fontFamilyFromAssetPath(`${font.family}.ttf`) === family)
|
|
1070
|
-
.sort((a, b) => localFontSortScore(a) - localFontSortScore(b));
|
|
1071
|
-
const font = candidates.find((entry) => typeof entry.blob === "function");
|
|
1072
|
-
if (!font?.blob) return null;
|
|
1073
|
-
|
|
1074
|
-
const blob = await font.blob();
|
|
1075
|
-
const style = sanitizeFontFilePart(font.style ?? "Regular") || "Regular";
|
|
1076
|
-
const name = sanitizeFontFilePart(`${family} ${style}`) || family;
|
|
1077
|
-
const file = new File([blob], `${name}.ttf`, {
|
|
1078
|
-
type: blob.type || "font/ttf",
|
|
1079
|
-
});
|
|
1080
|
-
const imported = await onImportFonts([file]);
|
|
1081
|
-
return (
|
|
1082
|
-
imported.find((asset) => asset.family.toLowerCase() === family.toLowerCase()) ??
|
|
1083
|
-
imported[0] ??
|
|
1084
|
-
null
|
|
1085
|
-
);
|
|
1086
|
-
};
|
|
1087
|
-
|
|
1088
|
-
const commitFamily = async (option: FontOption) => {
|
|
1089
|
-
if (option.source === "Local") {
|
|
1090
|
-
setImportingFonts(true);
|
|
1091
|
-
setFontNotice(null);
|
|
1092
|
-
try {
|
|
1093
|
-
const imported = await importLocalFont(option.family);
|
|
1094
|
-
if (imported) {
|
|
1095
|
-
loadImportedFontStylesheet(imported);
|
|
1096
|
-
onCommit(buildFontFamilyValue(imported.family));
|
|
1097
|
-
setQuery("");
|
|
1098
|
-
setOpen(false);
|
|
1099
|
-
return;
|
|
1100
|
-
}
|
|
1101
|
-
onCommit(buildFontFamilyValue(option.family));
|
|
1102
|
-
setQuery("");
|
|
1103
|
-
setOpen(false);
|
|
1104
|
-
} finally {
|
|
1105
|
-
setImportingFonts(false);
|
|
1106
|
-
}
|
|
1107
|
-
return;
|
|
1108
|
-
}
|
|
1109
|
-
|
|
1110
|
-
if (option.source === "Google") {
|
|
1111
|
-
loadGoogleFontStylesheet(option.family);
|
|
1112
|
-
}
|
|
1113
|
-
const imported = importedFonts.find(
|
|
1114
|
-
(font) => font.family.toLowerCase() === option.family.toLowerCase(),
|
|
1115
|
-
);
|
|
1116
|
-
if (imported) loadImportedFontStylesheet(imported);
|
|
1117
|
-
onCommit(buildFontFamilyValue(option.family));
|
|
1118
|
-
setQuery("");
|
|
1119
|
-
setOpen(false);
|
|
1120
|
-
};
|
|
1121
|
-
|
|
1122
|
-
return (
|
|
1123
|
-
<div ref={containerRef} className="relative grid min-w-0 gap-1.5">
|
|
1124
|
-
<span className={LABEL}>Font family</span>
|
|
1125
|
-
<button
|
|
1126
|
-
type="button"
|
|
1127
|
-
disabled={disabled}
|
|
1128
|
-
onClick={() => setOpen((next) => !next)}
|
|
1129
|
-
className={`${FIELD} flex h-10 items-center justify-between gap-3 text-left hover:border-neutral-700 disabled:cursor-not-allowed`}
|
|
1130
|
-
>
|
|
1131
|
-
<span
|
|
1132
|
-
className="min-w-0 flex-1 truncate text-[11px] font-medium text-neutral-100"
|
|
1133
|
-
style={{ fontFamily: value }}
|
|
1134
|
-
>
|
|
1135
|
-
{currentFamily}
|
|
1136
|
-
</span>
|
|
1137
|
-
<span className="flex-shrink-0 text-[10px] uppercase tracking-[0.14em] text-neutral-600">
|
|
1138
|
-
Font
|
|
1139
|
-
</span>
|
|
1140
|
-
</button>
|
|
1141
|
-
|
|
1142
|
-
{open && (
|
|
1143
|
-
<div className="absolute left-0 right-0 top-[calc(100%+6px)] z-50 overflow-hidden rounded-xl border border-neutral-700 bg-neutral-950 shadow-2xl">
|
|
1144
|
-
<div className="grid grid-cols-[minmax(0,1fr)_auto_auto] gap-2 border-b border-neutral-800 p-2">
|
|
1145
|
-
<input
|
|
1146
|
-
ref={inputRef}
|
|
1147
|
-
type="text"
|
|
1148
|
-
value={query}
|
|
1149
|
-
disabled={disabled}
|
|
1150
|
-
placeholder={loadingGoogleFonts ? "Loading Google Fonts..." : "Search fonts"}
|
|
1151
|
-
onChange={(e) => setQuery(e.target.value)}
|
|
1152
|
-
onKeyDown={(e) => {
|
|
1153
|
-
if (e.key === "Escape") {
|
|
1154
|
-
e.preventDefault();
|
|
1155
|
-
setOpen(false);
|
|
1156
|
-
}
|
|
1157
|
-
if (e.key === "Enter" && filteredOptions[0]) {
|
|
1158
|
-
e.preventDefault();
|
|
1159
|
-
commitFamily(filteredOptions[0]);
|
|
1160
|
-
}
|
|
1161
|
-
}}
|
|
1162
|
-
className="min-w-0 rounded-lg border border-neutral-800 bg-neutral-900 px-2.5 py-2 text-[11px] font-medium text-neutral-100 outline-none placeholder:text-neutral-600 focus:border-neutral-600"
|
|
1163
|
-
/>
|
|
1164
|
-
{canQueryLocalFonts && (
|
|
1165
|
-
<button
|
|
1166
|
-
type="button"
|
|
1167
|
-
disabled={disabled || loadingLocalFonts}
|
|
1168
|
-
onClick={loadBrowserLocalFonts}
|
|
1169
|
-
className="rounded-lg border border-neutral-700 bg-neutral-900 px-2.5 text-[10px] font-medium text-neutral-400 transition-colors hover:border-neutral-600 hover:text-neutral-100 disabled:cursor-not-allowed disabled:text-neutral-700"
|
|
1170
|
-
>
|
|
1171
|
-
{loadingLocalFonts ? "..." : "Local"}
|
|
1172
|
-
</button>
|
|
1173
|
-
)}
|
|
1174
|
-
<button
|
|
1175
|
-
type="button"
|
|
1176
|
-
disabled={disabled || importingFonts || !onImportFonts}
|
|
1177
|
-
onClick={() => fontInputRef.current?.click()}
|
|
1178
|
-
className="rounded-lg border border-neutral-700 bg-neutral-900 px-2.5 text-[10px] font-medium text-neutral-400 transition-colors hover:border-neutral-600 hover:text-neutral-100 disabled:cursor-not-allowed disabled:text-neutral-700"
|
|
1179
|
-
>
|
|
1180
|
-
{importingFonts ? "..." : "Import"}
|
|
1181
|
-
</button>
|
|
1182
|
-
<input
|
|
1183
|
-
ref={fontInputRef}
|
|
1184
|
-
type="file"
|
|
1185
|
-
accept=".ttf,.otf,.ttc,.woff,.woff2,.eot,font/*"
|
|
1186
|
-
multiple
|
|
1187
|
-
aria-label="Import local font files"
|
|
1188
|
-
disabled={disabled || importingFonts || !onImportFonts}
|
|
1189
|
-
className="hidden"
|
|
1190
|
-
onChange={async (event) => {
|
|
1191
|
-
await handleImportFonts(event.target.files);
|
|
1192
|
-
event.target.value = "";
|
|
1193
|
-
}}
|
|
1194
|
-
/>
|
|
1195
|
-
</div>
|
|
1196
|
-
{fontNotice && (
|
|
1197
|
-
<div className="border-b border-neutral-800 px-3 py-2 text-[10px] leading-4 text-neutral-500">
|
|
1198
|
-
{fontNotice}
|
|
1199
|
-
</div>
|
|
1200
|
-
)}
|
|
1201
|
-
<div className="max-h-64 overflow-y-auto p-1">
|
|
1202
|
-
{filteredOptions.length === 0 ? (
|
|
1203
|
-
<div className="px-2 py-3 text-[11px] text-neutral-500">No fonts found.</div>
|
|
1204
|
-
) : (
|
|
1205
|
-
filteredOptions.map((option) => (
|
|
1206
|
-
<button
|
|
1207
|
-
key={`${option.source}-${option.family}`}
|
|
1208
|
-
type="button"
|
|
1209
|
-
onClick={() => commitFamily(option)}
|
|
1210
|
-
className={`flex w-full min-w-0 items-center justify-between gap-3 rounded-lg px-2 py-2 text-left text-[11px] transition-colors ${
|
|
1211
|
-
option.family === currentFamily
|
|
1212
|
-
? "bg-studio-accent/15 text-neutral-50"
|
|
1213
|
-
: "text-neutral-300 hover:bg-neutral-900 hover:text-neutral-100"
|
|
1214
|
-
}`}
|
|
1215
|
-
>
|
|
1216
|
-
<span className="min-w-0 truncate font-medium">{option.family}</span>
|
|
1217
|
-
<span className="flex-shrink-0 text-[9px] uppercase tracking-[0.14em] text-neutral-600">
|
|
1218
|
-
{option.source}
|
|
1219
|
-
</span>
|
|
1220
|
-
</button>
|
|
1221
|
-
))
|
|
1222
|
-
)}
|
|
1223
|
-
</div>
|
|
1224
|
-
</div>
|
|
1225
|
-
)}
|
|
1226
|
-
</div>
|
|
1227
|
-
);
|
|
1228
|
-
}
|
|
76
|
+
if (layers.length <= 1) return null;
|
|
1229
77
|
|
|
1230
|
-
|
|
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
|
-
}
|
|
78
|
+
const selectedKey = element ? getDomEditLayerKey(element) : null;
|
|
1238
79
|
|
|
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
80
|
return (
|
|
1251
|
-
<
|
|
1252
|
-
<div className=
|
|
1253
|
-
|
|
1254
|
-
|
|
1255
|
-
|
|
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
|
-
|
|
1299
|
-
function ColorField({
|
|
1300
|
-
label,
|
|
1301
|
-
value,
|
|
1302
|
-
disabled,
|
|
1303
|
-
onCommit,
|
|
1304
|
-
}: {
|
|
1305
|
-
label: string;
|
|
1306
|
-
value: string;
|
|
1307
|
-
disabled?: boolean;
|
|
1308
|
-
onCommit: (nextValue: string) => void;
|
|
1309
|
-
}) {
|
|
1310
|
-
const buttonRef = useRef<HTMLButtonElement | null>(null);
|
|
1311
|
-
const panelRef = useRef<HTMLDivElement | null>(null);
|
|
1312
|
-
const [open, setOpen] = useState(false);
|
|
1313
|
-
const [panelPosition, setPanelPosition] = useState<FloatingPosition | null>(null);
|
|
1314
|
-
const [draftColor, setDraftColor] = useState<ParsedColor>(() => colorFromCss(value));
|
|
1315
|
-
const [hexDraft, setHexDraft] = useState(() => toHexColor(colorFromCss(value)).toUpperCase());
|
|
1316
|
-
const hsv = rgbToHsv(draftColor);
|
|
1317
|
-
const hueColor = formatCssColor({
|
|
1318
|
-
...hsvToRgb({ hue: hsv.hue, saturation: 1, value: 1 }),
|
|
1319
|
-
alpha: 1,
|
|
1320
|
-
});
|
|
1321
|
-
const opaqueColor = formatCssColor({ ...draftColor, alpha: 1 });
|
|
1322
|
-
const currentColor = formatCssColor(draftColor);
|
|
1323
|
-
const saturationPercent = Math.round(hsv.saturation * 100);
|
|
1324
|
-
const brightnessPercent = Math.round(hsv.value * 100);
|
|
1325
|
-
const alphaPercent = Math.round(draftColor.alpha * 100);
|
|
1326
|
-
|
|
1327
|
-
useEffect(() => {
|
|
1328
|
-
const nextColor = colorFromCss(value);
|
|
1329
|
-
setDraftColor(nextColor);
|
|
1330
|
-
setHexDraft(toHexColor(nextColor).toUpperCase());
|
|
1331
|
-
}, [value]);
|
|
1332
|
-
|
|
1333
|
-
const updatePanelPosition = useCallback(() => {
|
|
1334
|
-
const anchor = buttonRef.current?.getBoundingClientRect();
|
|
1335
|
-
if (!anchor) return;
|
|
1336
|
-
const measured = panelRef.current?.getBoundingClientRect();
|
|
1337
|
-
setPanelPosition(
|
|
1338
|
-
resolveFloatingPanelPosition(
|
|
1339
|
-
anchor,
|
|
1340
|
-
{ width: window.innerWidth, height: window.innerHeight },
|
|
1341
|
-
{
|
|
1342
|
-
width: measured?.width || COLOR_PICKER_SIZE.width,
|
|
1343
|
-
height: measured?.height || COLOR_PICKER_SIZE.height,
|
|
1344
|
-
},
|
|
1345
|
-
),
|
|
1346
|
-
);
|
|
1347
|
-
}, []);
|
|
1348
|
-
|
|
1349
|
-
useLayoutEffect(() => {
|
|
1350
|
-
if (!open) return;
|
|
1351
|
-
updatePanelPosition();
|
|
1352
|
-
|
|
1353
|
-
const handlePositionInvalidated = () => updatePanelPosition();
|
|
1354
|
-
window.addEventListener("resize", handlePositionInvalidated);
|
|
1355
|
-
window.addEventListener("scroll", handlePositionInvalidated, true);
|
|
1356
|
-
return () => {
|
|
1357
|
-
window.removeEventListener("resize", handlePositionInvalidated);
|
|
1358
|
-
window.removeEventListener("scroll", handlePositionInvalidated, true);
|
|
1359
|
-
};
|
|
1360
|
-
}, [open, updatePanelPosition]);
|
|
1361
|
-
|
|
1362
|
-
useEffect(() => {
|
|
1363
|
-
if (!open) return;
|
|
1364
|
-
|
|
1365
|
-
const handlePointerDown = (event: PointerEvent) => {
|
|
1366
|
-
const target = event.target as Node | null;
|
|
1367
|
-
if (!target) return;
|
|
1368
|
-
if (panelRef.current?.contains(target) || buttonRef.current?.contains(target)) return;
|
|
1369
|
-
setOpen(false);
|
|
1370
|
-
};
|
|
1371
|
-
const handleKeyDown = (event: KeyboardEvent) => {
|
|
1372
|
-
if (event.key === "Escape") setOpen(false);
|
|
1373
|
-
};
|
|
1374
|
-
|
|
1375
|
-
document.addEventListener("pointerdown", handlePointerDown);
|
|
1376
|
-
document.addEventListener("keydown", handleKeyDown);
|
|
1377
|
-
return () => {
|
|
1378
|
-
document.removeEventListener("pointerdown", handlePointerDown);
|
|
1379
|
-
document.removeEventListener("keydown", handleKeyDown);
|
|
1380
|
-
};
|
|
1381
|
-
}, [open]);
|
|
1382
|
-
|
|
1383
|
-
const commitColor = (nextColor: ParsedColor) => {
|
|
1384
|
-
setDraftColor(nextColor);
|
|
1385
|
-
setHexDraft(toHexColor(nextColor).toUpperCase());
|
|
1386
|
-
onCommit(formatCssColor(nextColor));
|
|
1387
|
-
};
|
|
1388
|
-
|
|
1389
|
-
const commitHsv = (nextHsv: { hue?: number; saturation?: number; value?: number }) => {
|
|
1390
|
-
const rgb = hsvToRgb({
|
|
1391
|
-
hue: nextHsv.hue ?? hsv.hue,
|
|
1392
|
-
saturation: nextHsv.saturation ?? hsv.saturation,
|
|
1393
|
-
value: nextHsv.value ?? hsv.value,
|
|
1394
|
-
});
|
|
1395
|
-
commitColor({ ...rgb, alpha: draftColor.alpha });
|
|
1396
|
-
};
|
|
1397
|
-
|
|
1398
|
-
const updateSaturationValue = (clientX: number, clientY: number, target: HTMLDivElement) => {
|
|
1399
|
-
const rect = target.getBoundingClientRect();
|
|
1400
|
-
const saturation = Math.max(0, Math.min(1, (clientX - rect.left) / rect.width));
|
|
1401
|
-
const nextValue = Math.max(0, Math.min(1, 1 - (clientY - rect.top) / rect.height));
|
|
1402
|
-
commitHsv({ saturation, value: nextValue });
|
|
1403
|
-
};
|
|
1404
|
-
|
|
1405
|
-
const handleHexCommit = (nextHex: string) => {
|
|
1406
|
-
setHexDraft(nextHex);
|
|
1407
|
-
const normalized = nextHex.trim().startsWith("#") ? nextHex.trim() : `#${nextHex.trim()}`;
|
|
1408
|
-
const parsed = parseCssColor(normalized);
|
|
1409
|
-
if (!parsed) return;
|
|
1410
|
-
commitColor({ ...parsed, alpha: draftColor.alpha });
|
|
1411
|
-
};
|
|
1412
|
-
|
|
1413
|
-
const picker = open
|
|
1414
|
-
? createPortal(
|
|
1415
|
-
<div
|
|
1416
|
-
ref={panelRef}
|
|
1417
|
-
className="fixed z-[9999] w-[292px] overflow-hidden rounded-2xl border border-neutral-700 bg-neutral-950 shadow-2xl shadow-black/50"
|
|
1418
|
-
style={{
|
|
1419
|
-
left: panelPosition?.left ?? -9999,
|
|
1420
|
-
top: panelPosition?.top ?? -9999,
|
|
1421
|
-
}}
|
|
1422
|
-
>
|
|
1423
|
-
<div className="flex items-center justify-between border-b border-neutral-800 px-3 py-2">
|
|
1424
|
-
<div className="min-w-0">
|
|
1425
|
-
<div className="truncate text-[11px] font-medium text-neutral-100">{label}</div>
|
|
1426
|
-
<div className="text-[9px] uppercase tracking-[0.16em] text-neutral-600">Color</div>
|
|
1427
|
-
</div>
|
|
81
|
+
<Section title="Layers" icon={<Layers size={15} />}>
|
|
82
|
+
<div className="space-y-0.5">
|
|
83
|
+
{layers.map((layer) => {
|
|
84
|
+
const selected = layer.key === selectedKey;
|
|
85
|
+
return (
|
|
1428
86
|
<button
|
|
87
|
+
key={layer.key}
|
|
1429
88
|
type="button"
|
|
1430
|
-
onClick={() =>
|
|
1431
|
-
className=
|
|
1432
|
-
|
|
1433
|
-
|
|
1434
|
-
|
|
1435
|
-
|
|
1436
|
-
|
|
1437
|
-
<div className="space-y-3 p-3">
|
|
1438
|
-
<div
|
|
1439
|
-
className="relative h-36 cursor-crosshair overflow-hidden rounded-xl border border-neutral-700 shadow-[inset_0_0_0_1px_rgba(255,255,255,0.06)]"
|
|
1440
|
-
style={{
|
|
1441
|
-
backgroundColor: hueColor,
|
|
1442
|
-
}}
|
|
1443
|
-
onPointerDown={(event) => {
|
|
1444
|
-
event.currentTarget.setPointerCapture(event.pointerId);
|
|
1445
|
-
updateSaturationValue(event.clientX, event.clientY, event.currentTarget);
|
|
1446
|
-
}}
|
|
1447
|
-
onPointerMove={(event) => {
|
|
1448
|
-
if (event.buttons !== 1) return;
|
|
1449
|
-
updateSaturationValue(event.clientX, event.clientY, event.currentTarget);
|
|
1450
|
-
}}
|
|
89
|
+
onClick={() => onSelectLayer(layer)}
|
|
90
|
+
className={`flex w-full items-center gap-2 rounded-lg px-2 py-1.5 text-left transition-colors ${
|
|
91
|
+
selected
|
|
92
|
+
? "bg-studio-accent/14 text-studio-accent"
|
|
93
|
+
: "text-neutral-300 hover:bg-white/[0.04] hover:text-neutral-100"
|
|
94
|
+
}`}
|
|
95
|
+
style={{ paddingLeft: 8 + layer.depth * 12 }}
|
|
1451
96
|
>
|
|
1452
|
-
<
|
|
1453
|
-
|
|
1454
|
-
|
|
1455
|
-
|
|
1456
|
-
|
|
1457
|
-
|
|
1458
|
-
<div
|
|
1459
|
-
className="pointer-events-none absolute left-0 h-px w-full -translate-y-1/2 bg-white/70 shadow-[0_0_0_1px_rgba(0,0,0,0.45)] mix-blend-difference"
|
|
1460
|
-
style={{ top: `${(1 - hsv.value) * 100}%` }}
|
|
1461
|
-
/>
|
|
1462
|
-
<div
|
|
1463
|
-
className="pointer-events-none absolute h-6 w-6 -translate-x-1/2 -translate-y-1/2 rounded-full border-2 border-white shadow-[0_0_0_1px_rgba(0,0,0,0.85),0_8px_18px_rgba(0,0,0,0.45)]"
|
|
1464
|
-
style={{
|
|
1465
|
-
left: `${hsv.saturation * 100}%`,
|
|
1466
|
-
top: `${(1 - hsv.value) * 100}%`,
|
|
1467
|
-
backgroundColor: opaqueColor,
|
|
1468
|
-
}}
|
|
1469
|
-
/>
|
|
1470
|
-
</div>
|
|
1471
|
-
|
|
1472
|
-
<div className="flex min-w-0 items-center gap-3">
|
|
1473
|
-
<div
|
|
1474
|
-
className="h-9 w-9 flex-shrink-0 rounded-xl border border-neutral-600 shadow-[inset_0_1px_0_rgba(255,255,255,0.08)]"
|
|
1475
|
-
style={{ backgroundColor: currentColor }}
|
|
1476
|
-
/>
|
|
1477
|
-
<div className="min-w-0 flex-1">
|
|
1478
|
-
<div className="truncate text-[11px] font-medium text-neutral-100">
|
|
1479
|
-
{currentColor}
|
|
1480
|
-
</div>
|
|
1481
|
-
<div className="mt-0.5 text-[9px] uppercase tracking-[0.12em] text-neutral-600">
|
|
1482
|
-
S {saturationPercent}% · B {brightnessPercent}% · A {alphaPercent}%
|
|
1483
|
-
</div>
|
|
1484
|
-
</div>
|
|
1485
|
-
</div>
|
|
1486
|
-
|
|
1487
|
-
<ColorSlider
|
|
1488
|
-
label="Hue"
|
|
1489
|
-
value={hsv.hue}
|
|
1490
|
-
min={0}
|
|
1491
|
-
max={360}
|
|
1492
|
-
step={1}
|
|
1493
|
-
displayValue={`${Math.round(hsv.hue)}°`}
|
|
1494
|
-
background="linear-gradient(90deg, #f00, #ff0, #0f0, #0ff, #00f, #f0f, #f00)"
|
|
1495
|
-
thumbColor={hueColor}
|
|
1496
|
-
disabled={disabled}
|
|
1497
|
-
onCommit={(nextHue) => commitHsv({ hue: nextHue })}
|
|
1498
|
-
/>
|
|
1499
|
-
|
|
1500
|
-
<ColorSlider
|
|
1501
|
-
label="Alpha"
|
|
1502
|
-
value={draftColor.alpha}
|
|
1503
|
-
min={0}
|
|
1504
|
-
max={1}
|
|
1505
|
-
step={0.01}
|
|
1506
|
-
displayValue={`${alphaPercent}%`}
|
|
1507
|
-
background={`linear-gradient(90deg, transparent, ${opaqueColor})`}
|
|
1508
|
-
thumbColor={currentColor}
|
|
1509
|
-
disabled={disabled}
|
|
1510
|
-
onCommit={(nextAlpha) => commitColor({ ...draftColor, alpha: nextAlpha })}
|
|
1511
|
-
/>
|
|
1512
|
-
|
|
1513
|
-
<label className="grid gap-1.5">
|
|
1514
|
-
<span className={LABEL}>Hex</span>
|
|
1515
|
-
<input
|
|
1516
|
-
value={hexDraft}
|
|
1517
|
-
onChange={(event) => handleHexCommit(event.target.value)}
|
|
1518
|
-
className={`${FIELD} h-10 w-full text-[11px] font-medium outline-none`}
|
|
1519
|
-
spellCheck={false}
|
|
1520
|
-
/>
|
|
1521
|
-
</label>
|
|
1522
|
-
</div>
|
|
1523
|
-
</div>,
|
|
1524
|
-
document.body,
|
|
1525
|
-
)
|
|
1526
|
-
: null;
|
|
1527
|
-
|
|
1528
|
-
const openPicker = () => {
|
|
1529
|
-
if (disabled) return;
|
|
1530
|
-
setOpen((current) => !current);
|
|
1531
|
-
if (!open) {
|
|
1532
|
-
requestAnimationFrame(updatePanelPosition);
|
|
1533
|
-
}
|
|
1534
|
-
};
|
|
1535
|
-
|
|
1536
|
-
return (
|
|
1537
|
-
<div className="grid min-w-0 gap-1.5">
|
|
1538
|
-
<span className={LABEL}>{label}</span>
|
|
1539
|
-
<button
|
|
1540
|
-
type="button"
|
|
1541
|
-
disabled={disabled}
|
|
1542
|
-
aria-label={`Pick ${label.toLowerCase()} color`}
|
|
1543
|
-
ref={buttonRef}
|
|
1544
|
-
onClick={openPicker}
|
|
1545
|
-
className={`${FIELD} flex items-center gap-3 text-left hover:border-neutral-700 disabled:cursor-not-allowed ${open ? "border-neutral-600" : ""}`}
|
|
1546
|
-
>
|
|
1547
|
-
<div
|
|
1548
|
-
className="relative h-7 w-7 flex-shrink-0 overflow-hidden rounded-lg border border-neutral-700 bg-neutral-950 shadow-[inset_0_1px_0_rgba(255,255,255,0.04)]"
|
|
1549
|
-
style={{ backgroundColor: value || "transparent" }}
|
|
1550
|
-
/>
|
|
1551
|
-
<span className="min-w-0 flex-1 truncate text-[11px] font-medium text-neutral-100">
|
|
1552
|
-
{value}
|
|
1553
|
-
</span>
|
|
1554
|
-
</button>
|
|
1555
|
-
{picker}
|
|
1556
|
-
</div>
|
|
1557
|
-
);
|
|
1558
|
-
}
|
|
1559
|
-
|
|
1560
|
-
function ColorSlider({
|
|
1561
|
-
label,
|
|
1562
|
-
value,
|
|
1563
|
-
min,
|
|
1564
|
-
max,
|
|
1565
|
-
step,
|
|
1566
|
-
displayValue,
|
|
1567
|
-
background,
|
|
1568
|
-
thumbColor,
|
|
1569
|
-
disabled,
|
|
1570
|
-
onCommit,
|
|
1571
|
-
}: {
|
|
1572
|
-
label: string;
|
|
1573
|
-
value: number;
|
|
1574
|
-
min: number;
|
|
1575
|
-
max: number;
|
|
1576
|
-
step: number;
|
|
1577
|
-
displayValue: string;
|
|
1578
|
-
background: string;
|
|
1579
|
-
thumbColor: string;
|
|
1580
|
-
disabled?: boolean;
|
|
1581
|
-
onCommit: (nextValue: number) => void;
|
|
1582
|
-
}) {
|
|
1583
|
-
const trackRef = useRef<HTMLDivElement | null>(null);
|
|
1584
|
-
const percent = ((value - min) / (max - min)) * 100;
|
|
1585
|
-
|
|
1586
|
-
const commitFromClientX = (clientX: number) => {
|
|
1587
|
-
const rect = trackRef.current?.getBoundingClientRect();
|
|
1588
|
-
if (!rect || rect.width <= 0) return;
|
|
1589
|
-
const rawValue = min + ((clientX - rect.left) / rect.width) * (max - min);
|
|
1590
|
-
const stepped = Math.round(rawValue / step) * step;
|
|
1591
|
-
onCommit(Math.max(min, Math.min(max, stepped)));
|
|
1592
|
-
};
|
|
1593
|
-
|
|
1594
|
-
const commitKeyboardValue = (nextValue: number) => {
|
|
1595
|
-
onCommit(Math.max(min, Math.min(max, nextValue)));
|
|
1596
|
-
};
|
|
1597
|
-
|
|
1598
|
-
return (
|
|
1599
|
-
<div className="grid gap-1.5">
|
|
1600
|
-
<div className="flex items-center justify-between">
|
|
1601
|
-
<span className={LABEL}>{label}</span>
|
|
1602
|
-
<span className="text-[10px] font-medium text-neutral-400">{displayValue}</span>
|
|
1603
|
-
</div>
|
|
1604
|
-
<div
|
|
1605
|
-
ref={trackRef}
|
|
1606
|
-
role="slider"
|
|
1607
|
-
tabIndex={disabled ? -1 : 0}
|
|
1608
|
-
aria-label={label}
|
|
1609
|
-
aria-valuemin={min}
|
|
1610
|
-
aria-valuemax={max}
|
|
1611
|
-
aria-valuenow={value}
|
|
1612
|
-
aria-disabled={disabled}
|
|
1613
|
-
className={`relative h-4 rounded-full border border-neutral-700 shadow-[inset_0_1px_2px_rgba(0,0,0,0.55)] outline-none focus:border-[#f5a400] focus:ring-2 focus:ring-[#f5a400]/40 ${
|
|
1614
|
-
disabled ? "cursor-not-allowed opacity-50" : "cursor-ew-resize"
|
|
1615
|
-
}`}
|
|
1616
|
-
style={{ background }}
|
|
1617
|
-
onPointerDown={(event) => {
|
|
1618
|
-
if (disabled) return;
|
|
1619
|
-
event.currentTarget.setPointerCapture(event.pointerId);
|
|
1620
|
-
commitFromClientX(event.clientX);
|
|
1621
|
-
}}
|
|
1622
|
-
onPointerMove={(event) => {
|
|
1623
|
-
if (disabled || event.buttons !== 1) return;
|
|
1624
|
-
commitFromClientX(event.clientX);
|
|
1625
|
-
}}
|
|
1626
|
-
onKeyDown={(event) => {
|
|
1627
|
-
if (disabled) return;
|
|
1628
|
-
if (event.key === "ArrowRight" || event.key === "ArrowUp") {
|
|
1629
|
-
event.preventDefault();
|
|
1630
|
-
commitKeyboardValue(value + step);
|
|
1631
|
-
} else if (event.key === "ArrowLeft" || event.key === "ArrowDown") {
|
|
1632
|
-
event.preventDefault();
|
|
1633
|
-
commitKeyboardValue(value - step);
|
|
1634
|
-
} else if (event.key === "Home") {
|
|
1635
|
-
event.preventDefault();
|
|
1636
|
-
commitKeyboardValue(min);
|
|
1637
|
-
} else if (event.key === "End") {
|
|
1638
|
-
event.preventDefault();
|
|
1639
|
-
commitKeyboardValue(max);
|
|
1640
|
-
}
|
|
1641
|
-
}}
|
|
1642
|
-
>
|
|
1643
|
-
<div
|
|
1644
|
-
className="pointer-events-none absolute top-1/2 h-6 w-6 -translate-x-1/2 -translate-y-1/2 rounded-full border-2 border-white shadow-[0_0_0_1px_rgba(0,0,0,0.85),0_6px_14px_rgba(0,0,0,0.5)]"
|
|
1645
|
-
style={{ left: `${Math.max(0, Math.min(100, percent))}%`, backgroundColor: thumbColor }}
|
|
1646
|
-
/>
|
|
1647
|
-
</div>
|
|
1648
|
-
</div>
|
|
1649
|
-
);
|
|
1650
|
-
}
|
|
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"
|
|
97
|
+
<span
|
|
98
|
+
className={`flex h-5 w-5 flex-shrink-0 items-center justify-center rounded text-[9px] font-bold uppercase ${
|
|
99
|
+
selected
|
|
100
|
+
? "bg-studio-accent/18 text-studio-accent"
|
|
101
|
+
: "bg-neutral-800 text-neutral-500"
|
|
102
|
+
}`}
|
|
1747
103
|
>
|
|
1748
|
-
|
|
1749
|
-
|
|
1750
|
-
|
|
1751
|
-
|
|
1752
|
-
|
|
1753
|
-
|
|
1754
|
-
|
|
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,
|
|
104
|
+
{layer.tagName.slice(0, 2)}
|
|
105
|
+
</span>
|
|
106
|
+
<span className="min-w-0 flex-1 truncate text-xs">{layer.label}</span>
|
|
107
|
+
{layer.childCount > 0 && (
|
|
108
|
+
<span className="text-[9px] tabular-nums text-neutral-500">{layer.childCount}</span>
|
|
109
|
+
)}
|
|
110
|
+
</button>
|
|
1808
111
|
);
|
|
1809
|
-
|
|
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
|
-
|
|
2006
|
-
function SliderControl({
|
|
2007
|
-
value,
|
|
2008
|
-
min,
|
|
2009
|
-
max,
|
|
2010
|
-
step,
|
|
2011
|
-
displayValue,
|
|
2012
|
-
formatDisplayValue,
|
|
2013
|
-
disabled,
|
|
2014
|
-
onCommit,
|
|
2015
|
-
}: {
|
|
2016
|
-
value: number;
|
|
2017
|
-
min: number;
|
|
2018
|
-
max: number;
|
|
2019
|
-
step: number;
|
|
2020
|
-
displayValue: string;
|
|
2021
|
-
formatDisplayValue?: (nextValue: number) => string;
|
|
2022
|
-
disabled?: boolean;
|
|
2023
|
-
onCommit: (nextValue: number) => void;
|
|
2024
|
-
}) {
|
|
2025
|
-
const [draft, setDraft] = useState(value);
|
|
2026
|
-
const commitTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
2027
|
-
const valueRef = useRef(value);
|
|
2028
|
-
|
|
2029
|
-
valueRef.current = value;
|
|
2030
|
-
|
|
2031
|
-
useEffect(() => {
|
|
2032
|
-
setDraft(value);
|
|
2033
|
-
}, [value]);
|
|
2034
|
-
|
|
2035
|
-
useEffect(
|
|
2036
|
-
() => () => {
|
|
2037
|
-
if (commitTimerRef.current) clearTimeout(commitTimerRef.current);
|
|
2038
|
-
},
|
|
2039
|
-
[],
|
|
2040
|
-
);
|
|
2041
|
-
|
|
2042
|
-
const commitDraft = (nextDraft: number) => {
|
|
2043
|
-
if (commitTimerRef.current) clearTimeout(commitTimerRef.current);
|
|
2044
|
-
if (nextDraft !== valueRef.current) {
|
|
2045
|
-
onCommit(nextDraft);
|
|
2046
|
-
}
|
|
2047
|
-
};
|
|
2048
|
-
|
|
2049
|
-
const scheduleCommit = (nextDraft: number) => {
|
|
2050
|
-
if (commitTimerRef.current) clearTimeout(commitTimerRef.current);
|
|
2051
|
-
commitTimerRef.current = setTimeout(() => {
|
|
2052
|
-
if (nextDraft !== valueRef.current) {
|
|
2053
|
-
onCommit(nextDraft);
|
|
2054
|
-
}
|
|
2055
|
-
}, 40);
|
|
2056
|
-
};
|
|
2057
|
-
|
|
2058
|
-
const renderedDisplayValue = formatDisplayValue?.(draft) ?? displayValue;
|
|
2059
|
-
|
|
2060
|
-
return (
|
|
2061
|
-
<div className="grid min-w-0 grid-cols-[minmax(0,1fr)_auto] items-center gap-2">
|
|
2062
|
-
<input
|
|
2063
|
-
type="range"
|
|
2064
|
-
min={min}
|
|
2065
|
-
max={max}
|
|
2066
|
-
step={step}
|
|
2067
|
-
value={draft}
|
|
2068
|
-
disabled={disabled}
|
|
2069
|
-
onChange={(e) => {
|
|
2070
|
-
const nextDraft = Number(e.target.value);
|
|
2071
|
-
setDraft(nextDraft);
|
|
2072
|
-
scheduleCommit(nextDraft);
|
|
2073
|
-
}}
|
|
2074
|
-
onMouseUp={() => commitDraft(draft)}
|
|
2075
|
-
onTouchEnd={() => commitDraft(draft)}
|
|
2076
|
-
onBlur={() => commitDraft(draft)}
|
|
2077
|
-
className="h-2 min-w-0 w-full cursor-pointer appearance-none rounded-full bg-neutral-800 accent-[#3ce6ac] disabled:cursor-not-allowed"
|
|
2078
|
-
/>
|
|
2079
|
-
<div className="min-w-[52px] rounded-xl border border-neutral-800 bg-neutral-900 px-2 py-2 text-right text-[11px] font-medium text-neutral-100 shadow-[inset_0_1px_0_rgba(255,255,255,0.03)]">
|
|
2080
|
-
{renderedDisplayValue}
|
|
2081
|
-
</div>
|
|
2082
|
-
</div>
|
|
2083
|
-
);
|
|
2084
|
-
}
|
|
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
|
-
|
|
2124
|
-
function SelectField({
|
|
2125
|
-
label,
|
|
2126
|
-
value,
|
|
2127
|
-
disabled,
|
|
2128
|
-
options,
|
|
2129
|
-
onChange,
|
|
2130
|
-
}: {
|
|
2131
|
-
label: string;
|
|
2132
|
-
value: string;
|
|
2133
|
-
disabled?: boolean;
|
|
2134
|
-
options: string[];
|
|
2135
|
-
onChange: (nextValue: string) => void;
|
|
2136
|
-
}) {
|
|
2137
|
-
const renderedOptions = value && !options.includes(value) ? [value, ...options] : options;
|
|
2138
|
-
|
|
2139
|
-
return (
|
|
2140
|
-
<label className="grid min-w-0 gap-1.5">
|
|
2141
|
-
<span className={LABEL}>{label}</span>
|
|
2142
|
-
<div className={FIELD}>
|
|
2143
|
-
<select
|
|
2144
|
-
value={value}
|
|
2145
|
-
disabled={disabled}
|
|
2146
|
-
onChange={(e) => onChange(e.target.value)}
|
|
2147
|
-
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"
|
|
2148
|
-
>
|
|
2149
|
-
{renderedOptions.map((option) => (
|
|
2150
|
-
<option key={option} value={option}>
|
|
2151
|
-
{option}
|
|
2152
|
-
</option>
|
|
2153
|
-
))}
|
|
2154
|
-
</select>
|
|
112
|
+
})}
|
|
2155
113
|
</div>
|
|
2156
|
-
</
|
|
114
|
+
</Section>
|
|
2157
115
|
);
|
|
2158
116
|
}
|
|
2159
117
|
|
|
2160
|
-
|
|
2161
|
-
|
|
2162
|
-
|
|
2163
|
-
children,
|
|
2164
|
-
accessory,
|
|
2165
|
-
}: {
|
|
2166
|
-
title: string;
|
|
2167
|
-
icon: ReactNode;
|
|
2168
|
-
children: ReactNode;
|
|
2169
|
-
accessory?: ReactNode;
|
|
2170
|
-
}) {
|
|
2171
|
-
return (
|
|
2172
|
-
<section className="min-w-0 border-t border-neutral-800/80 px-4 py-4">
|
|
2173
|
-
<div className="mb-3 flex min-w-0 flex-wrap items-center justify-between gap-2">
|
|
2174
|
-
<div className="flex min-w-0 items-center gap-2.5">
|
|
2175
|
-
<span className="flex-shrink-0 text-neutral-500">{icon}</span>
|
|
2176
|
-
<h3 className="text-[11px] font-semibold uppercase tracking-[0.12em] text-neutral-300">
|
|
2177
|
-
{title}
|
|
2178
|
-
</h3>
|
|
2179
|
-
</div>
|
|
2180
|
-
{accessory}
|
|
2181
|
-
</div>
|
|
2182
|
-
{children}
|
|
2183
|
-
</section>
|
|
2184
|
-
);
|
|
2185
|
-
}
|
|
2186
|
-
|
|
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
|
-
);
|
|
2210
|
-
}
|
|
118
|
+
/* ------------------------------------------------------------------ */
|
|
119
|
+
/* PropertyPanel */
|
|
120
|
+
/* ------------------------------------------------------------------ */
|
|
2211
121
|
|
|
2212
122
|
export const PropertyPanel = memo(function PropertyPanel({
|
|
2213
123
|
projectId,
|
|
@@ -2219,6 +129,7 @@ export const PropertyPanel = memo(function PropertyPanel({
|
|
|
2219
129
|
onSetStyle,
|
|
2220
130
|
onSetManualOffset,
|
|
2221
131
|
onSetManualSize,
|
|
132
|
+
onSetManualRotation,
|
|
2222
133
|
onSetText,
|
|
2223
134
|
onSetTextFieldStyle,
|
|
2224
135
|
onAddTextField,
|
|
@@ -2228,34 +139,10 @@ export const PropertyPanel = memo(function PropertyPanel({
|
|
|
2228
139
|
onImportAssets,
|
|
2229
140
|
fontAssets = [],
|
|
2230
141
|
onImportFonts,
|
|
142
|
+
activeCompositionPath = null,
|
|
143
|
+
onSelectLayer,
|
|
2231
144
|
}: PropertyPanelProps) {
|
|
2232
145
|
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);
|
|
2243
|
-
const [activeTextFieldKey, setActiveTextFieldKey] = useState<string | null>(
|
|
2244
|
-
element?.textFields[0]?.key ?? null,
|
|
2245
|
-
);
|
|
2246
|
-
const hasTextControls = element != null && isTextEditableSelection(element);
|
|
2247
|
-
|
|
2248
|
-
useEffect(() => {
|
|
2249
|
-
setPreferredFillMode(fillMode);
|
|
2250
|
-
}, [fillMode, element?.id, element?.selector, backgroundImage]);
|
|
2251
|
-
|
|
2252
|
-
useEffect(() => {
|
|
2253
|
-
const nextFields = element?.textFields ?? [];
|
|
2254
|
-
setActiveTextFieldKey((current) => {
|
|
2255
|
-
if (current && nextFields.some((field) => field.key === current)) return current;
|
|
2256
|
-
return nextFields[0]?.key ?? null;
|
|
2257
|
-
});
|
|
2258
|
-
}, [element?.id, element?.selector, element?.textFields]);
|
|
2259
146
|
|
|
2260
147
|
if (!element) {
|
|
2261
148
|
return (
|
|
@@ -2287,25 +174,8 @@ export const PropertyPanel = memo(function PropertyPanel({
|
|
|
2287
174
|
);
|
|
2288
175
|
}
|
|
2289
176
|
|
|
2290
|
-
const styleEditingDisabled = !element.capabilities.canEditStyles;
|
|
2291
177
|
const manualOffsetEditingDisabled = !element.capabilities.canApplyManualOffset;
|
|
2292
178
|
const manualSizeEditingDisabled = !element.capabilities.canApplyManualSize;
|
|
2293
|
-
const isFlex = styles.display === "flex" || styles.display === "inline-flex";
|
|
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)";
|
|
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);
|
|
2309
179
|
const sourceLabel = element.id ? `#${element.id}` : element.selector;
|
|
2310
180
|
const showEditableSections = element.capabilities.canEditStyles;
|
|
2311
181
|
const manualOffset = readStudioPathOffset(element.element);
|
|
@@ -2347,18 +217,11 @@ export const PropertyPanel = memo(function PropertyPanel({
|
|
|
2347
217
|
});
|
|
2348
218
|
};
|
|
2349
219
|
|
|
2350
|
-
const
|
|
2351
|
-
|
|
2352
|
-
|
|
2353
|
-
|
|
2354
|
-
|
|
2355
|
-
}
|
|
2356
|
-
if (nextMode === "Gradient" && !backgroundImage.includes("gradient")) {
|
|
2357
|
-
onSetStyle(
|
|
2358
|
-
"background-image",
|
|
2359
|
-
serializeGradient(buildDefaultGradientModel(styles["background-color"])),
|
|
2360
|
-
);
|
|
2361
|
-
}
|
|
220
|
+
const manualRotation = readStudioRotation(element.element);
|
|
221
|
+
const commitManualRotation = (nextValue: string) => {
|
|
222
|
+
const parsed = Number.parseFloat(nextValue);
|
|
223
|
+
if (!Number.isFinite(parsed)) return;
|
|
224
|
+
onSetManualRotation(element, { angle: parsed });
|
|
2362
225
|
};
|
|
2363
226
|
|
|
2364
227
|
return (
|
|
@@ -2403,574 +266,80 @@ export const PropertyPanel = memo(function PropertyPanel({
|
|
|
2403
266
|
</div>
|
|
2404
267
|
|
|
2405
268
|
<div className="flex-1 overflow-y-auto">
|
|
269
|
+
<TextSection
|
|
270
|
+
element={element}
|
|
271
|
+
styles={styles}
|
|
272
|
+
fontAssets={fontAssets}
|
|
273
|
+
onImportFonts={onImportFonts}
|
|
274
|
+
onSetText={onSetText}
|
|
275
|
+
onSetTextFieldStyle={onSetTextFieldStyle}
|
|
276
|
+
onAddTextField={onAddTextField}
|
|
277
|
+
onRemoveTextField={onRemoveTextField}
|
|
278
|
+
/>
|
|
279
|
+
|
|
280
|
+
{onSelectLayer && (
|
|
281
|
+
<LayerTree
|
|
282
|
+
element={element}
|
|
283
|
+
activeCompositionPath={activeCompositionPath}
|
|
284
|
+
onSelectLayer={onSelectLayer}
|
|
285
|
+
/>
|
|
286
|
+
)}
|
|
287
|
+
|
|
2406
288
|
<Section title="Layout" icon={<Move size={15} />}>
|
|
2407
289
|
<div className={RESPONSIVE_GRID}>
|
|
2408
290
|
<MetricField
|
|
2409
291
|
label="X"
|
|
2410
292
|
value={formatPxMetricValue(manualOffset.x)}
|
|
2411
293
|
disabled={manualOffsetEditingDisabled}
|
|
294
|
+
scrub
|
|
2412
295
|
onCommit={(next) => commitManualOffset("x", next)}
|
|
2413
296
|
/>
|
|
2414
297
|
<MetricField
|
|
2415
298
|
label="Y"
|
|
2416
299
|
value={formatPxMetricValue(manualOffset.y)}
|
|
2417
300
|
disabled={manualOffsetEditingDisabled}
|
|
301
|
+
scrub
|
|
2418
302
|
onCommit={(next) => commitManualOffset("y", next)}
|
|
2419
303
|
/>
|
|
2420
304
|
<MetricField
|
|
2421
305
|
label="W"
|
|
2422
306
|
value={formatPxMetricValue(resolvedWidth)}
|
|
2423
307
|
disabled={manualSizeEditingDisabled}
|
|
308
|
+
scrub
|
|
2424
309
|
onCommit={(next) => commitManualSize("width", next)}
|
|
2425
310
|
/>
|
|
2426
311
|
<MetricField
|
|
2427
312
|
label="H"
|
|
2428
313
|
value={formatPxMetricValue(resolvedHeight)}
|
|
2429
314
|
disabled={manualSizeEditingDisabled}
|
|
315
|
+
scrub
|
|
2430
316
|
onCommit={(next) => commitManualSize("height", next)}
|
|
2431
317
|
/>
|
|
318
|
+
<MetricField
|
|
319
|
+
label="R"
|
|
320
|
+
value={`${manualRotation.angle}°`}
|
|
321
|
+
onCommit={(next) => commitManualRotation(next.replace("°", ""))}
|
|
322
|
+
/>
|
|
323
|
+
</div>
|
|
324
|
+
<div className="mt-3">
|
|
325
|
+
<MetricField
|
|
326
|
+
label="Layer"
|
|
327
|
+
value={String(parseInt(styles["z-index"] || "auto", 10) || 0)}
|
|
328
|
+
scrub
|
|
329
|
+
onCommit={(next) => onSetStyle("z-index", next)}
|
|
330
|
+
/>
|
|
2432
331
|
</div>
|
|
2433
332
|
</Section>
|
|
2434
333
|
|
|
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
|
-
]}
|
|
2446
|
-
/>
|
|
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`)}
|
|
2475
|
-
/>
|
|
2476
|
-
</div>
|
|
2477
|
-
</Section>
|
|
2478
|
-
)}
|
|
2479
|
-
|
|
2480
334
|
{showEditableSections && (
|
|
2481
|
-
|
|
2482
|
-
|
|
2483
|
-
|
|
2484
|
-
|
|
2485
|
-
|
|
2486
|
-
|
|
2487
|
-
|
|
2488
|
-
|
|
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>
|
|
2544
|
-
<ColorField
|
|
2545
|
-
label="Stroke color"
|
|
2546
|
-
value={borderColorValue}
|
|
2547
|
-
disabled={styleEditingDisabled}
|
|
2548
|
-
onCommit={(next) => onSetStyle("border-color", next)}
|
|
2549
|
-
/>
|
|
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}
|
|
2558
|
-
disabled={styleEditingDisabled}
|
|
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"]}
|
|
2567
|
-
/>
|
|
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
|
-
}
|
|
2821
|
-
|
|
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
|
-
)}
|
|
2958
|
-
|
|
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
|
-
</>
|
|
335
|
+
<StyleSections
|
|
336
|
+
projectId={projectId}
|
|
337
|
+
element={element}
|
|
338
|
+
styles={styles}
|
|
339
|
+
assets={assets}
|
|
340
|
+
onSetStyle={onSetStyle}
|
|
341
|
+
onImportAssets={onImportAssets}
|
|
342
|
+
/>
|
|
2974
343
|
)}
|
|
2975
344
|
</div>
|
|
2976
345
|
</div>
|