@hyperframes/studio 0.6.0-alpha.13 → 0.6.0-alpha.14

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.
Files changed (51) hide show
  1. package/dist/assets/hyperframes-player-BI1oj9hu.js +418 -0
  2. package/dist/assets/index-CBj2NLRG.js +117 -0
  3. package/dist/assets/index-D1JDq7Gg.css +1 -0
  4. package/dist/favicon.svg +14 -0
  5. package/dist/index.html +3 -2
  6. package/package.json +9 -9
  7. package/src/App.tsx +424 -4217
  8. package/src/components/AskAgentModal.tsx +120 -0
  9. package/src/components/StudioHeader.tsx +133 -0
  10. package/src/components/StudioLeftSidebar.tsx +125 -0
  11. package/src/components/StudioPreviewArea.tsx +163 -0
  12. package/src/components/StudioRightPanel.tsx +198 -0
  13. package/src/components/TimelineToolbar.tsx +89 -0
  14. package/src/components/editor/PropertyPanel.tsx +125 -2808
  15. package/src/components/editor/manualEdits.ts +32 -0
  16. package/src/components/editor/propertyPanelColor.tsx +371 -0
  17. package/src/components/editor/propertyPanelFill.tsx +421 -0
  18. package/src/components/editor/propertyPanelFont.tsx +455 -0
  19. package/src/components/editor/propertyPanelHelpers.ts +401 -0
  20. package/src/components/editor/propertyPanelPrimitives.tsx +357 -0
  21. package/src/components/editor/propertyPanelSections.tsx +453 -0
  22. package/src/components/editor/propertyPanelStyleSections.tsx +411 -0
  23. package/src/contexts/DomEditContext.tsx +137 -0
  24. package/src/contexts/FileManagerContext.tsx +110 -0
  25. package/src/contexts/PanelLayoutContext.tsx +68 -0
  26. package/src/contexts/StudioContext.tsx +135 -0
  27. package/src/hooks/useAppHotkeys.ts +326 -0
  28. package/src/hooks/useAskAgentModal.ts +162 -0
  29. package/src/hooks/useCaptionDetection.ts +132 -0
  30. package/src/hooks/useCompositionDimensions.ts +25 -0
  31. package/src/hooks/useConsoleErrorCapture.ts +60 -0
  32. package/src/hooks/useDomEditCommits.ts +437 -0
  33. package/src/hooks/useDomEditSession.ts +342 -0
  34. package/src/hooks/useDomEditTextCommits.ts +330 -0
  35. package/src/hooks/useDomSelection.ts +398 -0
  36. package/src/hooks/useFileManager.ts +431 -0
  37. package/src/hooks/useFrameCapture.ts +77 -0
  38. package/src/hooks/useLintModal.ts +35 -0
  39. package/src/hooks/useManifestPersistence.ts +492 -0
  40. package/src/hooks/usePanelLayout.ts +68 -0
  41. package/src/hooks/usePreviewInteraction.ts +153 -0
  42. package/src/hooks/useRenderClipContent.ts +124 -0
  43. package/src/hooks/useTimelineEditing.ts +472 -0
  44. package/src/player/hooks/useTimelinePlayer.ts +140 -103
  45. package/src/utils/domEditHelpers.ts +50 -0
  46. package/src/utils/studioFontHelpers.ts +83 -0
  47. package/src/utils/studioHelpers.ts +214 -0
  48. package/src/utils/studioPreviewHelpers.ts +185 -0
  49. package/dist/assets/hyperframes-player-DMgdgHZd.js +0 -418
  50. package/dist/assets/index-B0OzpJPU.css +0 -1
  51. package/dist/assets/index-SEkerIt9.js +0 -110
@@ -1,53 +1,35 @@
1
+ import { memo } from "react";
2
+ import { Eye, Layers, MessageSquare, Move, RotateCcw, X } from "../../icons/SystemIcons";
1
3
  import {
2
- memo,
3
- useCallback,
4
- useEffect,
5
- useLayoutEffect,
6
- useMemo,
7
- useRef,
8
- useState,
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
- Eye,
14
- Layers,
15
- MessageSquare,
16
- Move,
17
- Palette,
18
- Plus,
19
- RotateCcw,
20
- Settings,
21
- Square,
22
- Type,
23
- X,
24
- Zap,
25
- } from "../../icons/SystemIcons";
26
- import {
27
- formatCssColor,
28
- hsvToRgb,
29
- parseCssColor,
30
- rgbToHsv,
31
- toHexColor,
32
- type ParsedColor,
33
- } from "./colorValue";
34
- import {
35
- buildDefaultGradientModel,
36
- insertGradientStop,
37
- parseGradient,
38
- serializeGradient,
39
- type GradientModel,
40
- } from "./gradientValue";
41
- import { isTextEditableSelection, type DomEditSelection } from "./domEditing";
42
- import { readStudioBoxSize, readStudioPathOffset } from "./manualEdits";
43
- import {
44
- COMMON_LOCAL_FONT_FAMILIES,
45
- googleFontStylesheetUrl,
46
- POPULAR_GOOGLE_FONT_FAMILIES,
47
- } from "./fontCatalog";
48
- import { fontFamilyFromAssetPath, importedFontFaceCss, type ImportedFontAsset } from "./fontAssets";
49
- import { resolveFloatingPanelPosition, type FloatingPosition } from "./floatingPanel";
50
- 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";
51
33
 
52
34
  interface PropertyPanelProps {
53
35
  projectId: string;
@@ -59,6 +41,7 @@ interface PropertyPanelProps {
59
41
  onSetStyle: (prop: string, value: string) => void | Promise<void>;
60
42
  onSetManualOffset: (element: DomEditSelection, next: { x: number; y: number }) => void;
61
43
  onSetManualSize: (element: DomEditSelection, next: { width: number; height: number }) => void;
44
+ onSetManualRotation: (element: DomEditSelection, next: { angle: number }) => void;
62
45
  onSetText: (value: string, fieldKey?: string) => void;
63
46
  onSetTextFieldStyle: (fieldKey: string, property: string, value: string) => void;
64
47
  onAddTextField: (afterFieldKey?: string) => string | Promise<string | null> | null;
@@ -68,2206 +51,73 @@ interface PropertyPanelProps {
68
51
  onImportAssets?: (files: FileList) => Promise<string[]>;
69
52
  fontAssets?: ImportedFontAsset[];
70
53
  onImportFonts?: (files: FileList | File[]) => Promise<ImportedFontAsset[]>;
54
+ activeCompositionPath?: string | null;
55
+ onSelectLayer?: (layer: DomEditLayerItem) => void;
71
56
  }
72
57
 
73
- const FIELD =
74
- "min-w-0 rounded-xl border border-neutral-800 bg-neutral-900/95 px-3 py-2 text-neutral-100 shadow-[inset_0_1px_0_rgba(255,255,255,0.03)] transition-colors focus-within:border-neutral-600";
75
- const LABEL = "text-[11px] font-medium uppercase tracking-[0.18em] text-neutral-500";
76
- const RESPONSIVE_GRID = "grid grid-cols-[repeat(auto-fit,minmax(118px,1fr))] gap-3";
77
- const EMPTY_STYLES: Record<string, string> = {};
78
- const GENERIC_FONT_FAMILIES = new Set([
79
- "inherit",
80
- "initial",
81
- "revert",
82
- "revert-layer",
83
- "serif",
84
- "sans-serif",
85
- "monospace",
86
- "cursive",
87
- "fantasy",
88
- "system-ui",
89
- "ui-sans-serif",
90
- "ui-serif",
91
- "ui-monospace",
92
- "ui-rounded",
93
- "emoji",
94
- "math",
95
- "fangsong",
96
- ]);
97
- const DEFAULT_FONT_FAMILIES = [
98
- ...COMMON_LOCAL_FONT_FAMILIES,
99
- "Inter",
100
- "system-ui",
101
- "sans-serif",
102
- "serif",
103
- "monospace",
104
- ];
105
- interface LocalFontData {
106
- family: string;
107
- fullName?: string;
108
- postscriptName?: string;
109
- style?: string;
110
- blob?: () => Promise<Blob>;
111
- }
112
-
113
- type FontSource = "Current" | "Document" | "Imported" | "Local" | "Google" | "System";
114
-
115
- interface FontOption {
116
- family: string;
117
- source: FontSource;
118
- }
119
-
120
- const COLOR_PICKER_SIZE = { width: 292, height: 386 };
121
- const EMPTY_FILTER_VALUE = "none";
122
- const BOX_SHADOW_PRESETS = {
123
- none: "none",
124
- soft: "0 12px 36px rgba(0, 0, 0, 0.28)",
125
- lift: "0 18px 54px rgba(0, 0, 0, 0.38)",
126
- glow: "0 0 0 1px rgba(60, 230, 172, 0.34), 0 18px 56px rgba(60, 230, 172, 0.2)",
127
- } as const;
128
-
129
- type BoxShadowPreset = keyof typeof BOX_SHADOW_PRESETS | "custom";
130
-
131
- function colorFromCss(value: string): ParsedColor {
132
- return parseCssColor(value) ?? { red: 0, green: 0, blue: 0, alpha: 1 };
133
- }
134
-
135
- declare global {
136
- interface Window {
137
- queryLocalFonts?: () => Promise<LocalFontData[]>;
138
- }
139
- }
140
-
141
- function sanitizeFontFilePart(value: string): string {
142
- return value
143
- .replace(/[^\w .-]+/g, " ")
144
- .replace(/\s+/g, " ")
145
- .trim();
146
- }
147
-
148
- function localFontSortScore(font: LocalFontData): number {
149
- const style = font.style?.toLowerCase() ?? "";
150
- const fullName = font.fullName?.toLowerCase() ?? "";
151
- if (style === "regular" || fullName.endsWith(" regular")) return 0;
152
- if (style === "normal" || fullName.endsWith(" normal")) return 1;
153
- if (style === "medium" || fullName.endsWith(" medium")) return 2;
154
- return 3;
155
- }
156
-
157
- function parseNumericValue(value: string | undefined): number | null {
158
- if (!value) return null;
159
- const parsed = Number.parseFloat(value);
160
- return Number.isFinite(parsed) ? parsed : null;
161
- }
162
-
163
- function formatNumericValue(value: number): string {
164
- const rounded = Math.round(value * 100) / 100;
165
- return Number.isInteger(rounded)
166
- ? `${rounded}`
167
- : rounded.toFixed(2).replace(/0+$/, "").replace(/\.$/, "");
168
- }
169
-
170
- interface ParsedNumericToken {
171
- value: number;
172
- unit: string;
173
- }
174
-
175
- function parseNumericToken(value: string | undefined): ParsedNumericToken | null {
176
- if (!value) return null;
177
- const match = value.trim().match(/^(-?\d+(?:\.\d+)?)([a-z%]*)$/i);
178
- if (!match) return null;
179
- const parsed = Number.parseFloat(match[1]);
180
- if (!Number.isFinite(parsed)) return null;
181
- return {
182
- value: parsed,
183
- unit: match[2] ?? "",
184
- };
185
- }
186
-
187
- function parsePxMetricValue(value: string): number | null {
188
- const token = parseNumericToken(value);
189
- if (!token) return null;
190
- if (token.unit && token.unit.toLowerCase() !== "px") return null;
191
- return token.value;
192
- }
193
-
194
- export function clampPanelNumber(
195
- value: number,
196
- min: number,
197
- max: number,
198
- fallback: number,
199
- ): number {
200
- if (!Number.isFinite(value)) return fallback;
201
- return Math.max(min, Math.min(max, value));
202
- }
203
-
204
- export function normalizePanelPxValue(
205
- value: string,
206
- options: { min?: number; max?: number; fallback?: number } = {},
207
- ): string | null {
208
- const token = parseNumericToken(value.trim());
209
- if (!token) return null;
210
- if (token.unit && token.unit.toLowerCase() !== "px") return null;
211
- const next = clampPanelNumber(
212
- token.value,
213
- options.min ?? Number.NEGATIVE_INFINITY,
214
- options.max ?? Number.POSITIVE_INFINITY,
215
- options.fallback ?? 0,
216
- );
217
- return `${formatNumericValue(next)}px`;
218
- }
219
-
220
- function formatPxMetricValue(value: number): string {
221
- return `${formatNumericValue(value)}px`;
222
- }
223
-
224
- function normalizeTextMetricValue(property: "letter-spacing" | "line-height", value: string) {
225
- const trimmed = value.trim();
226
- if (!trimmed || trimmed === "normal") return trimmed || "normal";
227
- const token = parseNumericToken(trimmed);
228
- if (!token) return trimmed;
229
- if (property === "letter-spacing") {
230
- return token.unit ? trimmed : `${formatNumericValue(token.value)}px`;
231
- }
232
- if (token.unit) return trimmed;
233
- return token.value > 4 ? `${formatNumericValue(token.value)}px` : formatNumericValue(token.value);
234
- }
235
-
236
- function splitCssFunctions(value: string): string[] {
237
- const functions: string[] = [];
238
- let current = "";
239
- let depth = 0;
240
-
241
- for (const char of value.trim()) {
242
- if (char === "(") depth += 1;
243
- if (char === ")") depth = Math.max(0, depth - 1);
244
- if (/\s/.test(char) && depth === 0) {
245
- if (current.trim()) functions.push(current.trim());
246
- current = "";
247
- continue;
248
- }
249
- current += char;
250
- }
251
-
252
- if (current.trim()) functions.push(current.trim());
253
- return functions;
254
- }
255
-
256
- export function getCssFilterFunctionPx(value: string | undefined, name: string): number {
257
- const normalized = value?.trim();
258
- if (!normalized || normalized === EMPTY_FILTER_VALUE) return 0;
259
- const escapedName = name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
260
- const match = new RegExp(`(?:^|\\s)${escapedName}\\((-?\\d+(?:\\.\\d+)?)px\\)`, "i").exec(
261
- normalized,
262
- );
263
- if (!match) return 0;
264
- const parsed = Number.parseFloat(match[1]);
265
- return Number.isFinite(parsed) ? Math.max(0, parsed) : 0;
266
- }
267
-
268
- export function setCssFilterFunctionPx(
269
- value: string | undefined,
270
- name: string,
271
- nextPx: number,
272
- ): string {
273
- const nextValue = clampPanelNumber(nextPx, 0, 200, 0);
274
- const functions = splitCssFunctions(value && value.trim() !== EMPTY_FILTER_VALUE ? value : "");
275
- const lowerName = name.toLowerCase();
276
- const filtered = functions.filter((entry) => !entry.toLowerCase().startsWith(`${lowerName}(`));
277
- if (nextValue > 0) filtered.push(`${name}(${formatNumericValue(nextValue)}px)`);
278
- return filtered.length > 0 ? filtered.join(" ") : EMPTY_FILTER_VALUE;
279
- }
280
-
281
- export function inferBoxShadowPreset(value: string | undefined): BoxShadowPreset {
282
- const normalized = value?.trim() || "none";
283
- for (const [preset, shadow] of Object.entries(BOX_SHADOW_PRESETS)) {
284
- if (normalized === shadow) return preset as BoxShadowPreset;
285
- }
286
- return normalized === "none" ? "none" : "custom";
287
- }
288
-
289
- function buildBoxShadowPresetValue(preset: BoxShadowPreset, fallback: string | undefined): string {
290
- if (preset === "custom") return fallback?.trim() || "none";
291
- return BOX_SHADOW_PRESETS[preset];
292
- }
293
-
294
- export function inferClipPathPreset(
295
- value: string | undefined,
296
- ): "none" | "inset" | "circle" | "custom" {
297
- const normalized = value?.trim();
298
- if (!normalized || normalized === "none") return "none";
299
- if (/^inset\(/i.test(normalized)) return "inset";
300
- if (/^circle\(/i.test(normalized)) return "circle";
301
- return "custom";
302
- }
303
-
304
- export function getClipPathInsetPx(value: string | undefined): number {
305
- const match = /^inset\(\s*(-?\d+(?:\.\d+)?)px\b/i.exec(value?.trim() ?? "");
306
- if (!match) return 0;
307
- const parsed = Number.parseFloat(match[1]);
308
- return Number.isFinite(parsed) ? Math.max(0, parsed) : 0;
309
- }
310
-
311
- export function buildStrokeWidthStyleUpdates(
312
- nextWidth: string,
313
- currentBorderStyle: string | undefined,
314
- ): Array<[property: string, value: string]> {
315
- const updates: Array<[property: string, value: string]> = [["border-width", nextWidth]];
316
- const token = parseNumericToken(nextWidth);
317
- const style = currentBorderStyle?.trim().toLowerCase() || "none";
318
- if (token && token.value > 0 && (style === "none" || style === "hidden")) {
319
- updates.push(["border-style", "solid"]);
320
- }
321
- return updates;
322
- }
323
-
324
- export function buildStrokeStyleUpdates(
325
- nextStyle: string,
326
- currentBorderWidth: string | undefined,
327
- ): Array<[property: string, value: string]> {
328
- const updates: Array<[property: string, value: string]> = [["border-style", nextStyle]];
329
- const style = nextStyle.trim().toLowerCase();
330
- if (!style || style === "none" || style === "hidden") return updates;
331
-
332
- const token = parseNumericToken(currentBorderWidth?.trim() || "0");
333
- if (!token || token.value <= 0) {
334
- updates.push(["border-width", "1px"]);
335
- }
336
- return updates;
337
- }
338
-
339
- function buildClipPathValue(
340
- preset: "none" | "inset" | "circle" | "custom",
341
- radiusValue: number,
342
- fallback: string | undefined,
343
- ) {
344
- if (preset === "custom") return fallback?.trim() || "none";
345
- if (preset === "circle") return "circle(50% at 50% 50%)";
346
- if (preset === "inset") {
347
- return `inset(0 round ${formatNumericValue(Math.max(0, radiusValue))}px)`;
348
- }
349
- return "none";
350
- }
351
-
352
- function buildInsetClipPathValue(insetPx: number, radiusValue: number): string {
353
- return `inset(${formatNumericValue(Math.max(0, insetPx))}px round ${formatNumericValue(Math.max(0, radiusValue))}px)`;
354
- }
355
-
356
- function adjustNumericToken(
357
- value: string,
358
- direction: 1 | -1,
359
- modifiers?: { shiftKey?: boolean; altKey?: boolean },
360
- ): string | null {
361
- const token = parseNumericToken(value);
362
- if (!token) return null;
363
-
364
- const baseStep = modifiers?.altKey ? 0.1 : modifiers?.shiftKey ? 10 : 1;
365
- const nextValue = token.value + baseStep * direction;
366
- return `${formatNumericValue(nextValue)}${token.unit}`;
367
- }
58
+ /* ------------------------------------------------------------------ */
59
+ /* LayerTree */
60
+ /* ------------------------------------------------------------------ */
368
61
 
369
- function extractBackgroundImageUrl(value: string | undefined): string {
370
- if (!value) return "";
371
- const lowerValue = value.toLowerCase();
372
- const urlStart = lowerValue.indexOf("url(");
373
- if (urlStart < 0) return "";
374
-
375
- let index = urlStart + 4;
376
- while (
377
- index < value.length &&
378
- (value[index] === " " ||
379
- value[index] === "\n" ||
380
- value[index] === "\r" ||
381
- value[index] === "\t" ||
382
- value[index] === "\f")
383
- ) {
384
- index += 1;
385
- }
386
-
387
- const quote = value[index] === '"' || value[index] === "'" ? value[index] : null;
388
- if (quote) {
389
- index += 1;
390
- const endQuote = value.indexOf(quote, index);
391
- return endQuote >= index ? value.slice(index, endQuote) : "";
392
- }
393
-
394
- const endParen = value.indexOf(")", index);
395
- if (endParen < index) return "";
396
- return value.slice(index, endParen).trim();
397
- }
398
-
399
- function normalizeProjectPath(value: string): string {
400
- const trimmed = value.trim();
401
- const maybeUrl = /^[a-z]+:\/\//i.test(trimmed) ? new URL(trimmed).pathname : trimmed;
402
- return decodeURIComponent(maybeUrl)
403
- .replace(/\\/g, "/")
404
- .replace(/^\.?\//, "");
405
- }
406
-
407
- function toRelativeProjectAssetPath(sourceFile: string, assetPath: string): string {
408
- const fromParts = normalizeProjectPath(sourceFile).split("/").filter(Boolean);
409
- const targetParts = normalizeProjectPath(assetPath).split("/").filter(Boolean);
410
-
411
- fromParts.pop();
412
-
413
- while (fromParts.length > 0 && targetParts.length > 0 && fromParts[0] === targetParts[0]) {
414
- fromParts.shift();
415
- targetParts.shift();
416
- }
417
-
418
- return [...fromParts.map(() => ".."), ...targetParts].join("/") || assetPath;
419
- }
420
-
421
- function toProjectRootAssetPath(assetPath: string): string {
422
- return normalizeProjectPath(assetPath);
423
- }
424
-
425
- function resolveSelectedAsset(
426
- imageUrl: string,
427
- sourceFile: string,
428
- assets: string[],
429
- ): string | null {
430
- const normalizedUrl = normalizeProjectPath(imageUrl);
431
- if (!normalizedUrl) return null;
432
-
433
- for (const asset of assets) {
434
- const normalizedAsset = normalizeProjectPath(asset);
435
- const relativeAsset = toRelativeProjectAssetPath(sourceFile, asset);
436
- if (
437
- normalizedUrl === normalizedAsset ||
438
- normalizedUrl === relativeAsset ||
439
- normalizedUrl.endsWith(`/${normalizedAsset}`) ||
440
- normalizedUrl.endsWith(`/${relativeAsset}`)
441
- ) {
442
- return asset;
443
- }
444
- }
445
-
446
- return null;
447
- }
448
-
449
- function CommitField({
450
- value,
451
- disabled,
452
- liveCommit,
453
- onCommit,
454
- }: {
455
- value: string;
456
- disabled?: boolean;
457
- liveCommit?: boolean;
458
- onCommit: (nextValue: string) => void;
459
- }) {
460
- const [draft, setDraft] = useState(value);
461
- const commitTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
462
- const valueRef = useRef(value);
463
- const draftRef = useRef(draft);
464
- const inputRef = useRef<HTMLInputElement>(null);
465
-
466
- valueRef.current = value;
467
- draftRef.current = draft;
468
-
469
- useEffect(() => {
470
- setDraft(value);
471
- }, [value]);
472
-
473
- useEffect(() => {
474
- const el = inputRef.current;
475
- if (!el) return;
476
- const handler = (e: WheelEvent) => {
477
- if (disabled) return;
478
- const delta = e.deltaY === 0 ? e.deltaX : e.deltaY;
479
- if (delta === 0) return;
480
- const nextDraft = adjustNumericToken(draftRef.current, delta < 0 ? 1 : -1, e);
481
- if (!nextDraft) return;
482
- e.preventDefault();
483
- e.stopPropagation();
484
- setDraft(nextDraft);
485
- scheduleCommitRef.current(nextDraft);
486
- };
487
- el.addEventListener("wheel", handler, { passive: false });
488
- return () => el.removeEventListener("wheel", handler);
489
- }, [disabled]);
490
-
491
- useEffect(
492
- () => () => {
493
- if (commitTimerRef.current) clearTimeout(commitTimerRef.current);
494
- },
495
- [],
496
- );
497
-
498
- const commitDraft = (nextDraft: string) => {
499
- if (commitTimerRef.current) clearTimeout(commitTimerRef.current);
500
- if (nextDraft !== valueRef.current) {
501
- onCommit(nextDraft);
502
- }
503
- };
504
-
505
- const scheduleCommit = (nextDraft: string) => {
506
- if (commitTimerRef.current) clearTimeout(commitTimerRef.current);
507
- commitTimerRef.current = setTimeout(() => {
508
- if (nextDraft !== valueRef.current) {
509
- onCommit(nextDraft);
510
- }
511
- }, 120);
512
- };
513
- const scheduleCommitRef = useRef(scheduleCommit);
514
- scheduleCommitRef.current = scheduleCommit;
515
-
516
- return (
517
- <input
518
- ref={inputRef}
519
- type="text"
520
- value={draft}
521
- disabled={disabled}
522
- onChange={(e) => {
523
- setDraft(e.target.value);
524
- if (liveCommit) scheduleCommit(e.target.value);
525
- }}
526
- onBlur={() => commitDraft(draft)}
527
- onKeyDown={(e) => {
528
- if (e.key === "Enter") {
529
- (e.target as HTMLInputElement).blur();
530
- return;
531
- }
532
- if (e.key !== "ArrowUp" && e.key !== "ArrowDown") return;
533
- const nextDraft = adjustNumericToken(draft, e.key === "ArrowUp" ? 1 : -1, e);
534
- if (!nextDraft) return;
535
- e.preventDefault();
536
- setDraft(nextDraft);
537
- scheduleCommit(nextDraft);
538
- }}
539
- title={parseNumericToken(value) ? "Scroll or use Arrow keys to adjust" : undefined}
540
- 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"
541
- />
542
- );
543
- }
544
-
545
- function MetricField({
546
- label,
547
- value,
548
- disabled,
549
- liveCommit,
550
- scrub,
551
- onCommit,
552
- }: {
553
- label: string;
554
- value: string;
555
- disabled?: boolean;
556
- liveCommit?: boolean;
557
- scrub?: boolean;
558
- onCommit: (nextValue: string) => void;
559
- }) {
560
- const scrubRef = useRef<{
561
- startX: number;
562
- startValue: number;
563
- pointerId: number;
564
- } | null>(null);
565
-
566
- const handleScrubPointerDown = useCallback(
567
- (e: React.PointerEvent<HTMLSpanElement>) => {
568
- if (disabled || !scrub) return;
569
- const parsed = parseFloat(value);
570
- if (!Number.isFinite(parsed)) return;
571
- (e.target as HTMLElement).setPointerCapture(e.pointerId);
572
- scrubRef.current = { startX: e.clientX, startValue: parsed, pointerId: e.pointerId };
573
- },
574
- [disabled, scrub, value],
575
- );
576
-
577
- const handleScrubPointerMove = useCallback(
578
- (e: React.PointerEvent<HTMLSpanElement>) => {
579
- const state = scrubRef.current;
580
- if (!state) return;
581
- const delta = e.clientX - state.startX;
582
- const next = Math.round(state.startValue + delta);
583
- onCommit(String(next));
584
- },
585
- [onCommit],
586
- );
587
-
588
- const handleScrubPointerUp = useCallback(() => {
589
- scrubRef.current = null;
590
- }, []);
591
-
592
- const scrubProps =
593
- scrub && !disabled
594
- ? ({
595
- className:
596
- "flex-shrink-0 text-[11px] font-medium text-neutral-500 cursor-ew-resize select-none",
597
- onPointerDown: handleScrubPointerDown,
598
- onPointerMove: handleScrubPointerMove,
599
- onPointerUp: handleScrubPointerUp,
600
- } as const)
601
- : ({
602
- className: "flex-shrink-0 text-[11px] font-medium text-neutral-500",
603
- } as const);
604
-
605
- return (
606
- <div className={FIELD}>
607
- <div className="flex min-w-0 items-center gap-3">
608
- <span {...scrubProps}>{label}</span>
609
- <CommitField
610
- value={value}
611
- disabled={disabled}
612
- liveCommit={liveCommit}
613
- onCommit={onCommit}
614
- />
615
- </div>
616
- </div>
617
- );
618
- }
619
-
620
- function DetailField({
621
- label,
622
- value,
623
- disabled,
624
- onCommit,
625
- }: {
626
- label: string;
627
- value: string;
628
- disabled?: boolean;
629
- onCommit: (nextValue: string) => void;
630
- }) {
631
- return (
632
- <label className="grid min-w-0 gap-1.5">
633
- <span className={LABEL}>{label}</span>
634
- <div className={FIELD}>
635
- <CommitField value={value} disabled={disabled} onCommit={onCommit} />
636
- </div>
637
- </label>
638
- );
639
- }
640
-
641
- function TextAreaField({
642
- label,
643
- value,
644
- disabled,
645
- autoFocus,
646
- onCommit,
62
+ function LayerTree({
63
+ element,
64
+ activeCompositionPath,
65
+ onSelectLayer,
647
66
  }: {
648
- label: string;
649
- value: string;
650
- disabled?: boolean;
651
- autoFocus?: boolean;
652
- onCommit: (nextValue: string) => void;
67
+ element: DomEditSelection | null;
68
+ activeCompositionPath: string | null;
69
+ onSelectLayer: (layer: DomEditLayerItem) => void;
653
70
  }) {
654
- const [draft, setDraft] = useState(value);
655
- const textareaRef = useRef<HTMLTextAreaElement | null>(null);
656
- const commitTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
657
- const focusedRef = useRef(false);
658
- const valueRef = useRef(value);
659
-
660
- valueRef.current = value;
661
-
662
- useEffect(() => {
663
- if (focusedRef.current) return;
664
- setDraft(value);
665
- }, [value]);
666
-
667
- useEffect(
668
- () => () => {
669
- if (commitTimerRef.current) clearTimeout(commitTimerRef.current);
670
- },
671
- [],
672
- );
673
-
674
- useEffect(() => {
675
- if (!autoFocus) return;
676
- textareaRef.current?.focus();
677
- }, [autoFocus]);
678
-
679
- const commitDraft = (nextDraft: string) => {
680
- if (commitTimerRef.current) clearTimeout(commitTimerRef.current);
681
- if (nextDraft !== valueRef.current) {
682
- onCommit(nextDraft);
683
- }
684
- };
685
-
686
- const scheduleCommit = (nextDraft: string) => {
687
- if (commitTimerRef.current) clearTimeout(commitTimerRef.current);
688
- commitTimerRef.current = setTimeout(() => {
689
- if (nextDraft !== valueRef.current) {
690
- onCommit(nextDraft);
691
- }
692
- }, 120);
693
- };
694
-
695
- return (
696
- <label className="grid min-w-0 gap-1.5">
697
- <span className={LABEL}>{label}</span>
698
- <div className={FIELD}>
699
- <textarea
700
- ref={textareaRef}
701
- value={draft}
702
- disabled={disabled}
703
- rows={4}
704
- onFocus={() => {
705
- focusedRef.current = true;
706
- }}
707
- onChange={(e) => {
708
- setDraft(e.target.value);
709
- scheduleCommit(e.target.value);
710
- }}
711
- onBlur={() => {
712
- focusedRef.current = false;
713
- commitDraft(draft);
714
- }}
715
- className="w-full resize-none bg-transparent text-[11px] font-medium text-neutral-100 outline-none disabled:cursor-not-allowed disabled:text-neutral-600"
716
- />
717
- </div>
718
- </label>
719
- );
720
- }
721
-
722
- function formatTextFieldPreview(value: string): string {
723
- const collapsed = value.trim().replace(/\s+/g, " ");
724
- if (collapsed.length <= 56) return collapsed;
725
- return `${collapsed.slice(0, 55)}…`;
726
- }
727
-
728
- function getTextFieldColor(
729
- field: { computedStyles: Record<string, string> },
730
- inheritedStyles: Record<string, string>,
731
- ): string {
732
- return field.computedStyles.color || inheritedStyles.color || "rgb(0, 0, 0)";
733
- }
734
-
735
- function splitFontFamilies(value: string): string[] {
736
- const families: string[] = [];
737
- let current = "";
738
- let quote: '"' | "'" | null = null;
739
-
740
- for (const char of value) {
741
- if ((char === '"' || char === "'") && !quote) {
742
- quote = char;
743
- continue;
744
- }
745
- if (char === quote) {
746
- quote = null;
747
- continue;
748
- }
749
- if (char === "," && !quote) {
750
- if (current.trim()) families.push(current.trim());
751
- current = "";
752
- continue;
753
- }
754
- current += char;
755
- }
756
-
757
- if (current.trim()) families.push(current.trim());
758
- return families.map((family) => family.replace(/^["']|["']$/g, "").trim()).filter(Boolean);
759
- }
760
-
761
- function primaryFontFamily(value: string): string {
762
- return splitFontFamilies(value)[0] ?? "inherit";
763
- }
764
-
765
- function quoteFontFamily(family: string): string {
766
- const trimmed = family.trim();
767
- if (GENERIC_FONT_FAMILIES.has(trimmed.toLowerCase())) return trimmed;
768
- return `"${trimmed.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`;
769
- }
770
-
771
- function buildFontFamilyValue(family: string): string {
772
- const trimmed = family.trim();
773
- if (!trimmed) return "inherit";
774
- if (GENERIC_FONT_FAMILIES.has(trimmed.toLowerCase())) return trimmed;
775
- return `${quoteFontFamily(trimmed)}, ui-sans-serif, system-ui, sans-serif`;
776
- }
777
-
778
- function collectDocumentFontFamilies(): string[] {
779
- if (typeof document === "undefined") return [];
780
- const fontSet = document.fonts;
781
- if (!fontSet) return [];
782
- return Array.from(fontSet, (fontFace) => fontFace.family.replace(/^["']|["']$/g, "").trim())
783
- .filter(Boolean)
784
- .sort((a, b) => a.localeCompare(b));
785
- }
786
-
787
- function uniqueFontFamilies(values: string[]): string[] {
788
- const seen = new Set<string>();
789
- const result: string[] = [];
790
- for (const value of values) {
791
- const family = value.trim();
792
- if (!family) continue;
793
- const key = family.toLowerCase();
794
- if (seen.has(key)) continue;
795
- seen.add(key);
796
- result.push(family);
797
- }
798
- return result;
799
- }
800
-
801
- function uniqueFontOptions(values: FontOption[]): FontOption[] {
802
- const seen = new Set<string>();
803
- const result: FontOption[] = [];
804
- for (const value of values) {
805
- const family = value.family.trim();
806
- if (!family) continue;
807
- const key = family.toLowerCase();
808
- if (seen.has(key)) continue;
809
- seen.add(key);
810
- result.push({ family, source: value.source });
811
- }
812
- return result;
813
- }
814
-
815
- function fontSourceRank(source: FontSource): number {
816
- if (source === "Current") return 0;
817
- if (source === "Document") return 1;
818
- if (source === "Imported") return 2;
819
- if (source === "Google") return 3;
820
- if (source === "Local") return 4;
821
- return 5;
822
- }
823
-
824
- function sortFontOptions(options: FontOption[]): FontOption[] {
825
- return [...options].sort((a, b) => {
826
- const rankDelta = fontSourceRank(a.source) - fontSourceRank(b.source);
827
- if (rankDelta !== 0) return rankDelta;
828
-
829
- const commonA = COMMON_LOCAL_FONT_FAMILIES.findIndex(
830
- (family) => family.toLowerCase() === a.family.toLowerCase(),
831
- );
832
- const commonB = COMMON_LOCAL_FONT_FAMILIES.findIndex(
833
- (family) => family.toLowerCase() === b.family.toLowerCase(),
834
- );
835
- const commonDelta =
836
- (commonA === -1 ? Number.MAX_SAFE_INTEGER : commonA) -
837
- (commonB === -1 ? Number.MAX_SAFE_INTEGER : commonB);
838
-
839
- 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,
840
75
  });
841
- }
76
+ if (layers.length <= 1) return null;
842
77
 
843
- function fontSearchKey(value: string): string {
844
- return value.toLowerCase().replace(/[^a-z0-9]+/g, "");
845
- }
846
-
847
- function fontMatchesQuery(family: string, query: string): boolean {
848
- const normalizedQuery = query.trim().toLowerCase();
849
- if (!normalizedQuery) return true;
850
- const normalizedFamily = family.toLowerCase();
851
- if (normalizedFamily.includes(normalizedQuery)) return true;
852
- return fontSearchKey(family).includes(fontSearchKey(normalizedQuery));
853
- }
854
-
855
- function loadGoogleFontStylesheet(family: string): void {
856
- if (typeof document === "undefined") return;
857
- const trimmed = family.trim();
858
- if (!trimmed) return;
859
-
860
- const id = `studio-google-font-${trimmed.toLowerCase().replace(/[^a-z0-9]+/g, "-")}`;
861
- if (document.getElementById(id)) return;
862
-
863
- const preconnect = document.querySelector('link[data-studio-google-font-preconnect="true"]');
864
- if (!preconnect) {
865
- const preconnectEl = document.createElement("link");
866
- preconnectEl.setAttribute("data-studio-google-font-preconnect", "true");
867
- preconnectEl.rel = "preconnect";
868
- preconnectEl.href = "https://fonts.gstatic.com";
869
- preconnectEl.crossOrigin = "anonymous";
870
- document.head.appendChild(preconnectEl);
871
- }
872
-
873
- const link = document.createElement("link");
874
- link.id = id;
875
- link.rel = "stylesheet";
876
- link.href = googleFontStylesheetUrl(trimmed);
877
- document.head.appendChild(link);
878
- }
879
-
880
- function loadImportedFontStylesheet(asset: ImportedFontAsset): void {
881
- if (typeof document === "undefined") return;
882
- const id = `studio-imported-font-${asset.family.toLowerCase().replace(/[^a-z0-9]+/g, "-")}`;
883
- if (document.getElementById(id)) return;
884
-
885
- const style = document.createElement("style");
886
- style.id = id;
887
- style.textContent = importedFontFaceCss(asset);
888
- document.head.appendChild(style);
889
- }
890
-
891
- const ALL_WEIGHTS = ["100", "200", "300", "400", "500", "600", "700", "800", "900"];
892
- const WEIGHT_LABELS: Record<string, string> = {
893
- "100": "100 · Thin",
894
- "200": "200 · Extra Light",
895
- "300": "300 · Light",
896
- "400": "400 · Regular",
897
- "500": "500 · Medium",
898
- "600": "600 · Semi Bold",
899
- "700": "700 · Bold",
900
- "800": "800 · Extra Bold",
901
- "900": "900 · Black",
902
- };
903
-
904
- function detectAvailableWeights(fontFamily: string): string[] {
905
- const fonts = document.fonts;
906
- if (!fonts) return ALL_WEIGHTS;
907
- const family = fontFamily.split(",")[0]?.trim().replace(/['"]/g, "");
908
- if (!family) return ALL_WEIGHTS;
909
- const available: string[] = [];
910
- for (const w of ALL_WEIGHTS) {
911
- if (fonts.check(`${w} 16px "${family}"`)) available.push(w);
912
- }
913
- return available.length > 0 ? available : ALL_WEIGHTS;
914
- }
915
-
916
- function FontWeightField({
917
- value,
918
- disabled,
919
- fontFamily,
920
- onCommit,
921
- }: {
922
- value: string;
923
- disabled?: boolean;
924
- fontFamily?: string;
925
- onCommit: (nextValue: string) => void;
926
- }) {
927
- const options = fontFamily ? detectAvailableWeights(fontFamily) : ALL_WEIGHTS;
928
- const displayOptions = value && !options.includes(value) ? [value, ...options] : options;
929
- return (
930
- <div className={FIELD}>
931
- <div className="flex min-w-0 items-center gap-3">
932
- <span className="flex-shrink-0 text-[11px] font-medium text-neutral-500">Weight</span>
933
- <select
934
- value={value}
935
- disabled={disabled}
936
- onChange={(e) => onCommit(e.target.value)}
937
- 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"
938
- >
939
- {displayOptions.map((option) => (
940
- <option key={option} value={option}>
941
- {WEIGHT_LABELS[option] ?? option}
942
- </option>
943
- ))}
944
- </select>
945
- </div>
946
- </div>
947
- );
948
- }
949
-
950
- function FontFamilyField({
951
- value,
952
- disabled,
953
- importedFonts,
954
- onImportFonts,
955
- onCommit,
956
- }: {
957
- value: string;
958
- disabled?: boolean;
959
- importedFonts: ImportedFontAsset[];
960
- onImportFonts?: (files: FileList | File[]) => Promise<ImportedFontAsset[]>;
961
- onCommit: (nextValue: string) => void;
962
- }) {
963
- const currentFamily = primaryFontFamily(value);
964
- const containerRef = useRef<HTMLDivElement | null>(null);
965
- const inputRef = useRef<HTMLInputElement | null>(null);
966
- const fontInputRef = useRef<HTMLInputElement | null>(null);
967
- const [open, setOpen] = useState(false);
968
- const [query, setQuery] = useState("");
969
- const [localFonts, setLocalFonts] = useState<string[]>([]);
970
- const [localFontData, setLocalFontData] = useState<LocalFontData[]>([]);
971
- const [googleFonts, setGoogleFonts] = useState<string[]>(() => [...POPULAR_GOOGLE_FONT_FAMILIES]);
972
- const [loadingLocalFonts, setLoadingLocalFonts] = useState(false);
973
- const [loadingGoogleFonts, setLoadingGoogleFonts] = useState(false);
974
- const [importingFonts, setImportingFonts] = useState(false);
975
- const [fontNotice, setFontNotice] = useState<string | null>(null);
976
- const canQueryLocalFonts =
977
- typeof window !== "undefined" && typeof window.queryLocalFonts === "function";
978
-
979
- useEffect(() => {
980
- if (!open) return;
981
- const handlePointerDown = (event: PointerEvent) => {
982
- const target = event.target;
983
- if (!(target instanceof Node)) return;
984
- if (!containerRef.current?.contains(target)) setOpen(false);
985
- };
986
- document.addEventListener("pointerdown", handlePointerDown);
987
- return () => {
988
- document.removeEventListener("pointerdown", handlePointerDown);
989
- };
990
- }, [open]);
991
-
992
- useEffect(() => {
993
- if (!open) return;
994
- requestAnimationFrame(() => inputRef.current?.focus());
995
- }, [open]);
996
-
997
- useEffect(() => {
998
- let cancelled = false;
999
- void fetch("/api/fonts")
1000
- .then((response) => (response.ok ? response.json() : null))
1001
- .then((data: { fonts?: string[] } | null) => {
1002
- const fonts = data?.fonts;
1003
- if (cancelled || !Array.isArray(fonts)) return;
1004
- setLocalFonts((current) => uniqueFontFamilies([...current, ...fonts]));
1005
- })
1006
- .catch(() => undefined);
1007
- return () => {
1008
- cancelled = true;
1009
- };
1010
- }, []);
1011
-
1012
- useEffect(() => {
1013
- let cancelled = false;
1014
- setLoadingGoogleFonts(true);
1015
- void fetch("/api/fonts/google")
1016
- .then((response) => (response.ok ? response.json() : null))
1017
- .then((data: { fonts?: string[] } | null) => {
1018
- const fonts = data?.fonts;
1019
- if (cancelled || !Array.isArray(fonts)) return;
1020
- setGoogleFonts(uniqueFontFamilies([...fonts, ...POPULAR_GOOGLE_FONT_FAMILIES]));
1021
- })
1022
- .catch(() => undefined)
1023
- .finally(() => {
1024
- if (!cancelled) setLoadingGoogleFonts(false);
1025
- });
1026
- return () => {
1027
- cancelled = true;
1028
- };
1029
- }, []);
1030
-
1031
- useEffect(() => {
1032
- if (googleFonts.some((family) => family.toLowerCase() === currentFamily.toLowerCase())) {
1033
- loadGoogleFontStylesheet(currentFamily);
1034
- }
1035
- const imported = importedFonts.find(
1036
- (font) => font.family.toLowerCase() === currentFamily.toLowerCase(),
1037
- );
1038
- if (imported) loadImportedFontStylesheet(imported);
1039
- }, [currentFamily, googleFonts, importedFonts]);
1040
-
1041
- const loadBrowserLocalFonts = async () => {
1042
- if (!canQueryLocalFonts || !window.queryLocalFonts) {
1043
- setFontNotice("This browser does not expose installed fonts. Import a font file instead.");
1044
- return;
1045
- }
1046
- setLoadingLocalFonts(true);
1047
- setFontNotice(null);
1048
- try {
1049
- const fonts = await window.queryLocalFonts();
1050
- const sortedFonts = [...fonts].sort((a, b) => localFontSortScore(a) - localFontSortScore(b));
1051
- const families = sortedFonts
1052
- .map((font) => font.family)
1053
- .filter((name): name is string => Boolean(name))
1054
- .map((name) => fontFamilyFromAssetPath(`${name}.ttf`));
1055
- setLocalFontData(sortedFonts);
1056
- setLocalFonts((current) => uniqueFontFamilies([...current, ...families]));
1057
- setFontNotice(fonts.length === 0 ? "No browser-local fonts were returned." : null);
1058
- } catch (error) {
1059
- const name = error instanceof Error ? error.name : "";
1060
- setFontNotice(
1061
- name === "NotAllowedError"
1062
- ? "Local font access was denied. Import a font file instead."
1063
- : "Local font access is unavailable. Import a font file instead.",
1064
- );
1065
- } finally {
1066
- setLoadingLocalFonts(false);
1067
- }
1068
- };
1069
-
1070
- const handleImportFonts = async (files: FileList | File[] | null) => {
1071
- if (!files?.length || !onImportFonts) return;
1072
- setImportingFonts(true);
1073
- setFontNotice(null);
1074
- try {
1075
- const imported = await onImportFonts(files);
1076
- for (const font of imported) loadImportedFontStylesheet(font);
1077
- const first = imported[0];
1078
- if (first) {
1079
- onCommit(buildFontFamilyValue(first.family));
1080
- setQuery("");
1081
- setOpen(false);
1082
- } else {
1083
- setFontNotice("No supported font files were imported.");
1084
- }
1085
- } finally {
1086
- setImportingFonts(false);
1087
- }
1088
- };
1089
-
1090
- const projectFontAssets = useMemo(
1091
- () =>
1092
- uniqueFontOptions(
1093
- importedFonts.map((font): FontOption => ({ family: font.family, source: "Imported" })),
1094
- ),
1095
- [importedFonts],
1096
- );
1097
-
1098
- const options = useMemo(() => {
1099
- const documentFonts = collectDocumentFontFamilies();
1100
- const googleSet = new Set(googleFonts.map((f) => f.toLowerCase()));
1101
- const taggedLocalFonts = localFonts.map(
1102
- (family): FontOption => ({
1103
- family,
1104
- source: googleSet.has(family.toLowerCase()) ? "Google" : "Local",
1105
- }),
1106
- );
1107
- return sortFontOptions(
1108
- uniqueFontOptions([
1109
- { family: currentFamily, source: "Current" },
1110
- ...documentFonts.map((family): FontOption => ({ family, source: "Document" })),
1111
- ...projectFontAssets,
1112
- ...googleFonts.map((family): FontOption => ({ family, source: "Google" })),
1113
- ...taggedLocalFonts,
1114
- ...DEFAULT_FONT_FAMILIES.map((family): FontOption => ({ family, source: "System" })),
1115
- ]),
1116
- );
1117
- }, [currentFamily, googleFonts, localFonts, projectFontAssets]);
1118
-
1119
- const filteredOptions = useMemo(() => {
1120
- const matches = options.filter((option) => fontMatchesQuery(option.family, query));
1121
- if (query.trim()) return matches.slice(0, 200);
1122
- const bySource = new Map<string, FontOption[]>();
1123
- for (const m of matches) {
1124
- const list = bySource.get(m.source) ?? [];
1125
- list.push(m);
1126
- bySource.set(m.source, list);
1127
- }
1128
- const result: FontOption[] = [];
1129
- for (const s of ["Current", "Document", "Imported"]) {
1130
- result.push(...(bySource.get(s) ?? []));
1131
- }
1132
- result.push(...(bySource.get("Google") ?? []).slice(0, 100));
1133
- result.push(...(bySource.get("Local") ?? []).slice(0, 80));
1134
- result.push(...(bySource.get("System") ?? []));
1135
- return result;
1136
- }, [options, query]);
1137
-
1138
- const importLocalFont = async (family: string): Promise<ImportedFontAsset | null> => {
1139
- if (!onImportFonts) return null;
1140
- const candidates = localFontData
1141
- .filter((font) => fontFamilyFromAssetPath(`${font.family}.ttf`) === family)
1142
- .sort((a, b) => localFontSortScore(a) - localFontSortScore(b));
1143
- const font = candidates.find((entry) => typeof entry.blob === "function");
1144
- if (!font?.blob) return null;
1145
-
1146
- const blob = await font.blob();
1147
- const style = sanitizeFontFilePart(font.style ?? "Regular") || "Regular";
1148
- const name = sanitizeFontFilePart(`${family} ${style}`) || family;
1149
- const file = new File([blob], `${name}.ttf`, {
1150
- type: blob.type || "font/ttf",
1151
- });
1152
- const imported = await onImportFonts([file]);
1153
- return (
1154
- imported.find((asset) => asset.family.toLowerCase() === family.toLowerCase()) ??
1155
- imported[0] ??
1156
- null
1157
- );
1158
- };
1159
-
1160
- const commitFamily = async (option: FontOption) => {
1161
- if (option.source === "Local") {
1162
- setImportingFonts(true);
1163
- setFontNotice(null);
1164
- try {
1165
- const imported = await importLocalFont(option.family);
1166
- if (imported) {
1167
- loadImportedFontStylesheet(imported);
1168
- onCommit(buildFontFamilyValue(imported.family));
1169
- setQuery("");
1170
- setOpen(false);
1171
- return;
1172
- }
1173
- onCommit(buildFontFamilyValue(option.family));
1174
- setQuery("");
1175
- setOpen(false);
1176
- } finally {
1177
- setImportingFonts(false);
1178
- }
1179
- return;
1180
- }
1181
-
1182
- if (option.source === "Google") {
1183
- loadGoogleFontStylesheet(option.family);
1184
- }
1185
- const imported = importedFonts.find(
1186
- (font) => font.family.toLowerCase() === option.family.toLowerCase(),
1187
- );
1188
- if (imported) loadImportedFontStylesheet(imported);
1189
- onCommit(buildFontFamilyValue(option.family));
1190
- setQuery("");
1191
- setOpen(false);
1192
- };
78
+ const selectedKey = element ? getDomEditLayerKey(element) : null;
1193
79
 
1194
80
  return (
1195
- <div ref={containerRef} className="relative grid min-w-0 gap-1.5">
1196
- <span className={LABEL}>Font family</span>
1197
- <button
1198
- type="button"
1199
- disabled={disabled}
1200
- onClick={() => setOpen((next) => !next)}
1201
- className={`${FIELD} flex h-10 items-center justify-between gap-3 text-left hover:border-neutral-700 disabled:cursor-not-allowed`}
1202
- >
1203
- <span
1204
- className="min-w-0 flex-1 truncate text-[11px] font-medium text-neutral-100"
1205
- style={{ fontFamily: value }}
1206
- >
1207
- {currentFamily}
1208
- </span>
1209
- <span className="flex-shrink-0 text-[10px] uppercase tracking-[0.14em] text-neutral-600">
1210
- Font
1211
- </span>
1212
- </button>
1213
-
1214
- {open && (
1215
- <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">
1216
- <div className="grid grid-cols-[minmax(0,1fr)_auto_auto] gap-2 border-b border-neutral-800 p-2">
1217
- <input
1218
- ref={inputRef}
1219
- type="text"
1220
- value={query}
1221
- disabled={disabled}
1222
- placeholder={loadingGoogleFonts ? "Loading Google Fonts..." : "Search fonts"}
1223
- onChange={(e) => setQuery(e.target.value)}
1224
- onKeyDown={(e) => {
1225
- if (e.key === "Escape") {
1226
- e.preventDefault();
1227
- setOpen(false);
1228
- }
1229
- if (e.key === "Enter" && filteredOptions[0]) {
1230
- e.preventDefault();
1231
- commitFamily(filteredOptions[0]);
1232
- }
1233
- }}
1234
- 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"
1235
- />
1236
- {canQueryLocalFonts && (
1237
- <button
1238
- type="button"
1239
- disabled={disabled || loadingLocalFonts}
1240
- onClick={loadBrowserLocalFonts}
1241
- 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"
1242
- >
1243
- {loadingLocalFonts ? "..." : "Local"}
1244
- </button>
1245
- )}
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 (
1246
86
  <button
87
+ key={layer.key}
1247
88
  type="button"
1248
- disabled={disabled || importingFonts || !onImportFonts}
1249
- onClick={() => fontInputRef.current?.click()}
1250
- 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"
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 }}
1251
96
  >
1252
- {importingFonts ? "..." : "Import"}
1253
- </button>
1254
- <input
1255
- ref={fontInputRef}
1256
- type="file"
1257
- accept=".ttf,.otf,.ttc,.woff,.woff2,.eot,font/*"
1258
- multiple
1259
- aria-label="Import local font files"
1260
- disabled={disabled || importingFonts || !onImportFonts}
1261
- className="hidden"
1262
- onChange={async (event) => {
1263
- await handleImportFonts(event.target.files);
1264
- event.target.value = "";
1265
- }}
1266
- />
1267
- </div>
1268
- {fontNotice && (
1269
- <div className="border-b border-neutral-800 px-3 py-2 text-[10px] leading-4 text-neutral-500">
1270
- {fontNotice}
1271
- </div>
1272
- )}
1273
- <div className="max-h-64 overflow-y-auto p-1">
1274
- {filteredOptions.length === 0 ? (
1275
- <div className="px-2 py-3 text-[11px] text-neutral-500">No fonts found.</div>
1276
- ) : (
1277
- filteredOptions.map((option) => (
1278
- <button
1279
- key={`${option.source}-${option.family}`}
1280
- type="button"
1281
- onClick={() => commitFamily(option)}
1282
- 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 ${
1283
- option.family === currentFamily
1284
- ? "bg-studio-accent/15 text-neutral-50"
1285
- : "text-neutral-300 hover:bg-neutral-900 hover:text-neutral-100"
1286
- }`}
1287
- >
1288
- <span className="min-w-0 truncate font-medium">{option.family}</span>
1289
- <span className="flex-shrink-0 text-[9px] uppercase tracking-[0.14em] text-neutral-600">
1290
- {option.source}
1291
- </span>
1292
- </button>
1293
- ))
1294
- )}
1295
- </div>
1296
- </div>
1297
- )}
1298
- </div>
1299
- );
1300
- }
1301
-
1302
- function getTextStyleValue(
1303
- field: DomEditSelection["textFields"][number],
1304
- inheritedStyles: Record<string, string>,
1305
- property: string,
1306
- fallback: string,
1307
- ): string {
1308
- return field.computedStyles[property] || inheritedStyles[property] || fallback;
1309
- }
1310
-
1311
- function AdvancedTextControls({
1312
- field,
1313
- inheritedStyles,
1314
- disabled,
1315
- onCommit,
1316
- }: {
1317
- field: DomEditSelection["textFields"][number];
1318
- inheritedStyles: Record<string, string>;
1319
- disabled?: boolean;
1320
- onCommit: (property: string, value: string) => void;
1321
- }) {
1322
- return (
1323
- <div className="space-y-4">
1324
- <div className={RESPONSIVE_GRID}>
1325
- <SelectField
1326
- label="Line"
1327
- value={getTextStyleValue(field, inheritedStyles, "line-height", "normal")}
1328
- disabled={disabled}
1329
- options={["normal", "1", "1.1", "1.2", "1.25", "1.3", "1.4", "1.5", "1.6", "1.75", "2"]}
1330
- onChange={(next) =>
1331
- onCommit("line-height", normalizeTextMetricValue("line-height", next))
1332
- }
1333
- />
1334
- <SelectField
1335
- label="Track"
1336
- value={getTextStyleValue(field, inheritedStyles, "letter-spacing", "0px")}
1337
- disabled={disabled}
1338
- options={[
1339
- "0px",
1340
- "-0.05em",
1341
- "-0.04em",
1342
- "-0.03em",
1343
- "-0.02em",
1344
- "-0.01em",
1345
- "0em",
1346
- "0.01em",
1347
- "0.02em",
1348
- "0.03em",
1349
- "0.05em",
1350
- "0.1em",
1351
- "0.15em",
1352
- "0.2em",
1353
- ]}
1354
- onChange={(next) =>
1355
- onCommit("letter-spacing", normalizeTextMetricValue("letter-spacing", next))
1356
- }
1357
- />
1358
- </div>
1359
- <div className={RESPONSIVE_GRID}>
1360
- <SelectField
1361
- label="Align"
1362
- value={getTextStyleValue(field, inheritedStyles, "text-align", "start")}
1363
- disabled={disabled}
1364
- onChange={(next) => onCommit("text-align", next)}
1365
- options={["start", "left", "center", "right", "justify", "end"]}
1366
- />
1367
- <SelectField
1368
- label="Case"
1369
- value={getTextStyleValue(field, inheritedStyles, "text-transform", "none")}
1370
- disabled={disabled}
1371
- onChange={(next) => onCommit("text-transform", next)}
1372
- options={["none", "uppercase", "lowercase", "capitalize"]}
1373
- />
1374
- </div>
1375
- <SelectField
1376
- label="Style"
1377
- value={getTextStyleValue(field, inheritedStyles, "font-style", "normal")}
1378
- disabled={disabled}
1379
- onChange={(next) => onCommit("font-style", next)}
1380
- options={["normal", "italic"]}
1381
- />
1382
- </div>
1383
- );
1384
- }
1385
-
1386
- function ColorField({
1387
- label,
1388
- value,
1389
- disabled,
1390
- onCommit,
1391
- }: {
1392
- label: string;
1393
- value: string;
1394
- disabled?: boolean;
1395
- onCommit: (nextValue: string) => void;
1396
- }) {
1397
- const buttonRef = useRef<HTMLButtonElement | null>(null);
1398
- const panelRef = useRef<HTMLDivElement | null>(null);
1399
- const [open, setOpen] = useState(false);
1400
- const [panelPosition, setPanelPosition] = useState<FloatingPosition | null>(null);
1401
- const [draftColor, setDraftColor] = useState<ParsedColor>(() => colorFromCss(value));
1402
- const [hexDraft, setHexDraft] = useState(() => toHexColor(colorFromCss(value)).toUpperCase());
1403
- const hsv = rgbToHsv(draftColor);
1404
- const hueColor = formatCssColor({
1405
- ...hsvToRgb({ hue: hsv.hue, saturation: 1, value: 1 }),
1406
- alpha: 1,
1407
- });
1408
- const opaqueColor = formatCssColor({ ...draftColor, alpha: 1 });
1409
- const currentColor = formatCssColor(draftColor);
1410
- const saturationPercent = Math.round(hsv.saturation * 100);
1411
- const brightnessPercent = Math.round(hsv.value * 100);
1412
- const alphaPercent = Math.round(draftColor.alpha * 100);
1413
-
1414
- useEffect(() => {
1415
- const nextColor = colorFromCss(value);
1416
- setDraftColor(nextColor);
1417
- setHexDraft(toHexColor(nextColor).toUpperCase());
1418
- }, [value]);
1419
-
1420
- const updatePanelPosition = useCallback(() => {
1421
- const anchor = buttonRef.current?.getBoundingClientRect();
1422
- if (!anchor) return;
1423
- const measured = panelRef.current?.getBoundingClientRect();
1424
- setPanelPosition(
1425
- resolveFloatingPanelPosition(
1426
- anchor,
1427
- { width: window.innerWidth, height: window.innerHeight },
1428
- {
1429
- width: measured?.width || COLOR_PICKER_SIZE.width,
1430
- height: measured?.height || COLOR_PICKER_SIZE.height,
1431
- },
1432
- ),
1433
- );
1434
- }, []);
1435
-
1436
- useLayoutEffect(() => {
1437
- if (!open) return;
1438
- updatePanelPosition();
1439
-
1440
- const handlePositionInvalidated = () => updatePanelPosition();
1441
- window.addEventListener("resize", handlePositionInvalidated);
1442
- window.addEventListener("scroll", handlePositionInvalidated, true);
1443
- return () => {
1444
- window.removeEventListener("resize", handlePositionInvalidated);
1445
- window.removeEventListener("scroll", handlePositionInvalidated, true);
1446
- };
1447
- }, [open, updatePanelPosition]);
1448
-
1449
- useEffect(() => {
1450
- if (!open) return;
1451
-
1452
- const handlePointerDown = (event: PointerEvent) => {
1453
- const target = event.target as Node | null;
1454
- if (!target) return;
1455
- if (panelRef.current?.contains(target) || buttonRef.current?.contains(target)) return;
1456
- setOpen(false);
1457
- };
1458
- const handleKeyDown = (event: KeyboardEvent) => {
1459
- if (event.key === "Escape") setOpen(false);
1460
- };
1461
-
1462
- document.addEventListener("pointerdown", handlePointerDown);
1463
- document.addEventListener("keydown", handleKeyDown);
1464
- return () => {
1465
- document.removeEventListener("pointerdown", handlePointerDown);
1466
- document.removeEventListener("keydown", handleKeyDown);
1467
- };
1468
- }, [open]);
1469
-
1470
- const commitColor = (nextColor: ParsedColor) => {
1471
- setDraftColor(nextColor);
1472
- setHexDraft(toHexColor(nextColor).toUpperCase());
1473
- onCommit(formatCssColor(nextColor));
1474
- };
1475
-
1476
- const commitHsv = (nextHsv: { hue?: number; saturation?: number; value?: number }) => {
1477
- const rgb = hsvToRgb({
1478
- hue: nextHsv.hue ?? hsv.hue,
1479
- saturation: nextHsv.saturation ?? hsv.saturation,
1480
- value: nextHsv.value ?? hsv.value,
1481
- });
1482
- commitColor({ ...rgb, alpha: draftColor.alpha });
1483
- };
1484
-
1485
- const updateSaturationValue = (clientX: number, clientY: number, target: HTMLDivElement) => {
1486
- const rect = target.getBoundingClientRect();
1487
- const saturation = Math.max(0, Math.min(1, (clientX - rect.left) / rect.width));
1488
- const nextValue = Math.max(0, Math.min(1, 1 - (clientY - rect.top) / rect.height));
1489
- commitHsv({ saturation, value: nextValue });
1490
- };
1491
-
1492
- const handleHexCommit = (nextHex: string) => {
1493
- setHexDraft(nextHex);
1494
- const normalized = nextHex.trim().startsWith("#") ? nextHex.trim() : `#${nextHex.trim()}`;
1495
- const parsed = parseCssColor(normalized);
1496
- if (!parsed) return;
1497
- commitColor({ ...parsed, alpha: draftColor.alpha });
1498
- };
1499
-
1500
- const picker = open
1501
- ? createPortal(
1502
- <div
1503
- ref={panelRef}
1504
- className="fixed z-[9999] w-[292px] overflow-hidden rounded-2xl border border-neutral-700 bg-neutral-950 shadow-2xl shadow-black/50"
1505
- style={{
1506
- left: panelPosition?.left ?? -9999,
1507
- top: panelPosition?.top ?? -9999,
1508
- }}
1509
- >
1510
- <div className="flex items-center justify-between border-b border-neutral-800 px-3 py-2">
1511
- <div className="min-w-0">
1512
- <div className="truncate text-[11px] font-medium text-neutral-100">{label}</div>
1513
- <div className="text-[9px] uppercase tracking-[0.16em] text-neutral-600">Color</div>
1514
- </div>
1515
- <button
1516
- type="button"
1517
- onClick={() => setOpen(false)}
1518
- className="flex h-7 w-7 items-center justify-center rounded-lg text-neutral-500 transition-colors hover:bg-neutral-900 hover:text-neutral-200"
1519
- aria-label="Close color picker"
1520
- >
1521
- <X size={13} />
1522
- </button>
1523
- </div>
1524
- <div className="space-y-3 p-3">
1525
- <div
1526
- 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)]"
1527
- style={{
1528
- backgroundColor: hueColor,
1529
- }}
1530
- onPointerDown={(event) => {
1531
- event.currentTarget.setPointerCapture(event.pointerId);
1532
- updateSaturationValue(event.clientX, event.clientY, event.currentTarget);
1533
- }}
1534
- onPointerMove={(event) => {
1535
- if (event.buttons !== 1) return;
1536
- updateSaturationValue(event.clientX, event.clientY, event.currentTarget);
1537
- }}
1538
- >
1539
- <div className="absolute inset-0 bg-gradient-to-r from-white to-transparent" />
1540
- <div className="absolute inset-0 bg-gradient-to-t from-black to-transparent" />
1541
- <div
1542
- className="pointer-events-none absolute top-0 h-full w-px -translate-x-1/2 bg-white/70 shadow-[0_0_0_1px_rgba(0,0,0,0.45)] mix-blend-difference"
1543
- style={{ left: `${hsv.saturation * 100}%` }}
1544
- />
1545
- <div
1546
- 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"
1547
- style={{ top: `${(1 - hsv.value) * 100}%` }}
1548
- />
1549
- <div
1550
- 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)]"
1551
- style={{
1552
- left: `${hsv.saturation * 100}%`,
1553
- top: `${(1 - hsv.value) * 100}%`,
1554
- backgroundColor: opaqueColor,
1555
- }}
1556
- />
1557
- </div>
1558
-
1559
- <div className="flex min-w-0 items-center gap-3">
1560
- <div
1561
- 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)]"
1562
- style={{ backgroundColor: currentColor }}
1563
- />
1564
- <div className="min-w-0 flex-1">
1565
- <div className="truncate text-[11px] font-medium text-neutral-100">
1566
- {currentColor}
1567
- </div>
1568
- <div className="mt-0.5 text-[9px] uppercase tracking-[0.12em] text-neutral-600">
1569
- S {saturationPercent}% · B {brightnessPercent}% · A {alphaPercent}%
1570
- </div>
1571
- </div>
1572
- </div>
1573
-
1574
- <ColorSlider
1575
- label="Hue"
1576
- value={hsv.hue}
1577
- min={0}
1578
- max={360}
1579
- step={1}
1580
- displayValue={`${Math.round(hsv.hue)}°`}
1581
- background="linear-gradient(90deg, #f00, #ff0, #0f0, #0ff, #00f, #f0f, #f00)"
1582
- thumbColor={hueColor}
1583
- disabled={disabled}
1584
- onCommit={(nextHue) => commitHsv({ hue: nextHue })}
1585
- />
1586
-
1587
- <ColorSlider
1588
- label="Alpha"
1589
- value={draftColor.alpha}
1590
- min={0}
1591
- max={1}
1592
- step={0.01}
1593
- displayValue={`${alphaPercent}%`}
1594
- background={`linear-gradient(90deg, transparent, ${opaqueColor})`}
1595
- thumbColor={currentColor}
1596
- disabled={disabled}
1597
- onCommit={(nextAlpha) => commitColor({ ...draftColor, alpha: nextAlpha })}
1598
- />
1599
-
1600
- <label className="grid gap-1.5">
1601
- <span className={LABEL}>Hex</span>
1602
- <input
1603
- value={hexDraft}
1604
- onChange={(event) => handleHexCommit(event.target.value)}
1605
- className={`${FIELD} h-10 w-full text-[11px] font-medium outline-none`}
1606
- spellCheck={false}
1607
- />
1608
- </label>
1609
- </div>
1610
- </div>,
1611
- document.body,
1612
- )
1613
- : null;
1614
-
1615
- const openPicker = () => {
1616
- if (disabled) return;
1617
- setOpen((current) => !current);
1618
- if (!open) {
1619
- requestAnimationFrame(updatePanelPosition);
1620
- }
1621
- };
1622
-
1623
- return (
1624
- <div className="grid min-w-0 gap-1.5">
1625
- <span className={LABEL}>{label}</span>
1626
- <button
1627
- type="button"
1628
- disabled={disabled}
1629
- aria-label={`Pick ${label.toLowerCase()} color`}
1630
- ref={buttonRef}
1631
- onClick={openPicker}
1632
- className={`${FIELD} flex items-center gap-3 text-left hover:border-neutral-700 disabled:cursor-not-allowed ${open ? "border-neutral-600" : ""}`}
1633
- >
1634
- <div
1635
- 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)]"
1636
- style={{ backgroundColor: value || "transparent" }}
1637
- />
1638
- <span className="min-w-0 flex-1 truncate text-[11px] font-medium text-neutral-100">
1639
- {value}
1640
- </span>
1641
- </button>
1642
- {picker}
1643
- </div>
1644
- );
1645
- }
1646
-
1647
- function ColorSlider({
1648
- label,
1649
- value,
1650
- min,
1651
- max,
1652
- step,
1653
- displayValue,
1654
- background,
1655
- thumbColor,
1656
- disabled,
1657
- onCommit,
1658
- }: {
1659
- label: string;
1660
- value: number;
1661
- min: number;
1662
- max: number;
1663
- step: number;
1664
- displayValue: string;
1665
- background: string;
1666
- thumbColor: string;
1667
- disabled?: boolean;
1668
- onCommit: (nextValue: number) => void;
1669
- }) {
1670
- const trackRef = useRef<HTMLDivElement | null>(null);
1671
- const percent = ((value - min) / (max - min)) * 100;
1672
-
1673
- const commitFromClientX = (clientX: number) => {
1674
- const rect = trackRef.current?.getBoundingClientRect();
1675
- if (!rect || rect.width <= 0) return;
1676
- const rawValue = min + ((clientX - rect.left) / rect.width) * (max - min);
1677
- const stepped = Math.round(rawValue / step) * step;
1678
- onCommit(Math.max(min, Math.min(max, stepped)));
1679
- };
1680
-
1681
- const commitKeyboardValue = (nextValue: number) => {
1682
- onCommit(Math.max(min, Math.min(max, nextValue)));
1683
- };
1684
-
1685
- return (
1686
- <div className="grid gap-1.5">
1687
- <div className="flex items-center justify-between">
1688
- <span className={LABEL}>{label}</span>
1689
- <span className="text-[10px] font-medium text-neutral-400">{displayValue}</span>
1690
- </div>
1691
- <div
1692
- ref={trackRef}
1693
- role="slider"
1694
- tabIndex={disabled ? -1 : 0}
1695
- aria-label={label}
1696
- aria-valuemin={min}
1697
- aria-valuemax={max}
1698
- aria-valuenow={value}
1699
- aria-disabled={disabled}
1700
- 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 ${
1701
- disabled ? "cursor-not-allowed opacity-50" : "cursor-ew-resize"
1702
- }`}
1703
- style={{ background }}
1704
- onPointerDown={(event) => {
1705
- if (disabled) return;
1706
- event.currentTarget.setPointerCapture(event.pointerId);
1707
- commitFromClientX(event.clientX);
1708
- }}
1709
- onPointerMove={(event) => {
1710
- if (disabled || event.buttons !== 1) return;
1711
- commitFromClientX(event.clientX);
1712
- }}
1713
- onKeyDown={(event) => {
1714
- if (disabled) return;
1715
- if (event.key === "ArrowRight" || event.key === "ArrowUp") {
1716
- event.preventDefault();
1717
- commitKeyboardValue(value + step);
1718
- } else if (event.key === "ArrowLeft" || event.key === "ArrowDown") {
1719
- event.preventDefault();
1720
- commitKeyboardValue(value - step);
1721
- } else if (event.key === "Home") {
1722
- event.preventDefault();
1723
- commitKeyboardValue(min);
1724
- } else if (event.key === "End") {
1725
- event.preventDefault();
1726
- commitKeyboardValue(max);
1727
- }
1728
- }}
1729
- >
1730
- <div
1731
- 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)]"
1732
- style={{ left: `${Math.max(0, Math.min(100, percent))}%`, backgroundColor: thumbColor }}
1733
- />
1734
- </div>
1735
- </div>
1736
- );
1737
- }
1738
-
1739
- function ImageFillField({
1740
- projectId,
1741
- sourceFile,
1742
- value,
1743
- assets,
1744
- disabled,
1745
- onCommit,
1746
- onImportAssets,
1747
- }: {
1748
- projectId: string;
1749
- sourceFile: string;
1750
- value: string;
1751
- assets: string[];
1752
- disabled?: boolean;
1753
- onCommit: (nextValue: string) => void;
1754
- onImportAssets?: (files: FileList) => Promise<string[]>;
1755
- }) {
1756
- const fileInputRef = useRef<HTMLInputElement | null>(null);
1757
- const [uploading, setUploading] = useState(false);
1758
- const imageAssets = useMemo(() => assets.filter((asset) => IMAGE_EXT.test(asset)), [assets]);
1759
- const selectedAsset = useMemo(
1760
- () => resolveSelectedAsset(value, sourceFile, imageAssets),
1761
- [imageAssets, sourceFile, value],
1762
- );
1763
- const externalUrlValue = selectedAsset ? "" : value;
1764
-
1765
- const handleUpload = async (files: FileList | null) => {
1766
- if (!files?.length || !onImportAssets) return;
1767
- setUploading(true);
1768
- try {
1769
- const uploaded = await onImportAssets(files);
1770
- const nextImage = uploaded.find((asset) => IMAGE_EXT.test(asset));
1771
- if (nextImage) {
1772
- onCommit(`url("${toProjectRootAssetPath(nextImage)}")`);
1773
- }
1774
- } finally {
1775
- setUploading(false);
1776
- }
1777
- };
1778
-
1779
- return (
1780
- <div className="space-y-4">
1781
- <div className="grid min-w-0 gap-1.5">
1782
- <div className="flex min-w-0 flex-wrap items-center justify-between gap-2">
1783
- <span className={LABEL}>Project asset</span>
1784
- <button
1785
- type="button"
1786
- disabled={disabled || uploading}
1787
- onClick={() => fileInputRef.current?.click()}
1788
- 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 ${
1789
- disabled || uploading
1790
- ? "cursor-not-allowed text-neutral-600"
1791
- : "cursor-pointer hover:border-neutral-600 hover:text-white"
1792
- }`}
1793
- >
1794
- <Plus size={12} className="flex-shrink-0" />
1795
- <span className="truncate">{uploading ? "Uploading…" : "Upload image"}</span>
1796
- </button>
1797
- <input
1798
- ref={fileInputRef}
1799
- type="file"
1800
- accept="image/*"
1801
- aria-label="Upload image asset"
1802
- disabled={disabled || uploading}
1803
- className="hidden"
1804
- onChange={async (event) => {
1805
- await handleUpload(event.target.files);
1806
- event.target.value = "";
1807
- }}
1808
- />
1809
- </div>
1810
- {imageAssets.length > 0 ? (
1811
- <div className="space-y-3">
1812
- {selectedAsset && (
1813
- <div className="overflow-hidden rounded-xl border border-neutral-800 bg-neutral-900/80">
1814
- <img
1815
- src={`/api/projects/${projectId}/preview/${selectedAsset}`}
1816
- alt={selectedAsset.split("/").pop() ?? selectedAsset}
1817
- className="h-28 w-full object-contain bg-neutral-950/80"
1818
- />
1819
- </div>
1820
- )}
1821
- <div className={FIELD}>
1822
- <select
1823
- value={selectedAsset ?? ""}
1824
- disabled={disabled}
1825
- onChange={(e) => {
1826
- const nextAsset = e.target.value;
1827
- if (!nextAsset) {
1828
- onCommit("none");
1829
- return;
1830
- }
1831
- onCommit(`url("${toProjectRootAssetPath(nextAsset)}")`);
1832
- }}
1833
- 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
+ }`}
1834
103
  >
1835
- <option value="">None</option>
1836
- {imageAssets.map((asset) => (
1837
- <option key={asset} value={asset}>
1838
- {asset}
1839
- </option>
1840
- ))}
1841
- </select>
1842
- </div>
1843
- </div>
1844
- ) : (
1845
- <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">
1846
- No image assets yet. Upload one here and Studio will also add it to the Assets tab.
1847
- </div>
1848
- )}
1849
- </div>
1850
-
1851
- <DetailField
1852
- label="External URL"
1853
- value={externalUrlValue}
1854
- disabled={disabled}
1855
- onCommit={(next) => onCommit(next.trim() ? `url("${next.trim()}")` : "none")}
1856
- />
1857
- </div>
1858
- );
1859
- }
1860
-
1861
- function GradientField({
1862
- value,
1863
- fallbackColor,
1864
- disabled,
1865
- onCommit,
1866
- }: {
1867
- value: string;
1868
- fallbackColor: string | undefined;
1869
- disabled?: boolean;
1870
- onCommit: (nextValue: string) => void;
1871
- }) {
1872
- const previewRef = useRef<HTMLDivElement | null>(null);
1873
- const parsed = parseGradient(value) ?? buildDefaultGradientModel(fallbackColor);
1874
-
1875
- const commit = (next: GradientModel) => onCommit(serializeGradient(next));
1876
-
1877
- const patch = (partial: Partial<GradientModel>) => commit({ ...parsed, ...partial });
1878
-
1879
- const updateStop = (index: number, partial: Partial<GradientModel["stops"][number]>) => {
1880
- const stops = parsed.stops.map((stop, stopIndex) =>
1881
- stopIndex === index ? { ...stop, ...partial } : stop,
1882
- );
1883
- commit({ ...parsed, stops });
1884
- };
1885
-
1886
- const addStop = (position?: number) => {
1887
- const nextGradient =
1888
- position != null
1889
- ? insertGradientStop(parsed, position)
1890
- : insertGradientStop(
1891
- parsed,
1892
- parsed.stops.at(-1)?.position != null
1893
- ? Math.min(100, (parsed.stops.at(-1)?.position ?? 90) + 10)
1894
- : 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>
1895
111
  );
1896
- commit(nextGradient);
1897
- };
1898
-
1899
- const removeStop = (index: number) => {
1900
- if (parsed.stops.length <= 2) return;
1901
- commit({ ...parsed, stops: parsed.stops.filter((_, stopIndex) => stopIndex !== index) });
1902
- };
1903
-
1904
- const previewStyle = {
1905
- backgroundImage: serializeGradient(parsed),
1906
- };
1907
-
1908
- return (
1909
- <div className="space-y-4">
1910
- <div className={`${FIELD} space-y-3 p-3`}>
1911
- <div
1912
- ref={previewRef}
1913
- className="relative h-11 overflow-hidden rounded-lg border border-neutral-700"
1914
- style={previewStyle}
1915
- onClick={(event) => {
1916
- if (disabled) return;
1917
- const rect = previewRef.current?.getBoundingClientRect();
1918
- if (!rect || rect.width <= 0) return;
1919
- const position = ((event.clientX - rect.left) / rect.width) * 100;
1920
- addStop(position);
1921
- }}
1922
- >
1923
- {parsed.stops.map((stop, index) => (
1924
- <div
1925
- key={`stop-preview-${index}`}
1926
- 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)]"
1927
- style={{
1928
- left: `calc(${stop.position}% - 8px)`,
1929
- backgroundColor: stop.color,
1930
- }}
1931
- />
1932
- ))}
1933
- </div>
1934
- <div className="flex min-w-0 flex-wrap items-center gap-2">
1935
- <SegmentedControl
1936
- disabled={disabled}
1937
- value={parsed.kind}
1938
- onChange={(next) => patch({ kind: next as GradientModel["kind"] })}
1939
- options={[
1940
- { label: "Linear", value: "linear" },
1941
- { label: "Radial", value: "radial" },
1942
- { label: "Conic", value: "conic" },
1943
- ]}
1944
- />
1945
- <label className="flex items-center gap-2 text-[11px] font-medium text-neutral-400">
1946
- <input
1947
- type="checkbox"
1948
- checked={parsed.repeating}
1949
- disabled={disabled}
1950
- onChange={(e) => patch({ repeating: e.target.checked })}
1951
- className="h-4 w-4 rounded border-neutral-700 bg-neutral-950 text-[#3ce6ac] focus:ring-[#3ce6ac]"
1952
- />
1953
- Repeat
1954
- </label>
1955
- <button
1956
- type="button"
1957
- disabled={disabled}
1958
- onClick={() =>
1959
- commit({
1960
- ...parsed,
1961
- stops: [...parsed.stops].reverse().map((stop) => ({
1962
- ...stop,
1963
- position: 100 - stop.position,
1964
- })),
1965
- })
1966
- }
1967
- 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"
1968
- >
1969
- <RotateCcw size={12} />
1970
- Reverse
1971
- </button>
1972
- </div>
112
+ })}
1973
113
  </div>
1974
-
1975
- {(parsed.kind === "linear" || parsed.kind === "conic") && (
1976
- <div className="grid gap-1.5">
1977
- <span className={LABEL}>{parsed.kind === "linear" ? "Angle" : "Start angle"}</span>
1978
- <SliderControl
1979
- value={parsed.angle}
1980
- min={0}
1981
- max={360}
1982
- step={1}
1983
- disabled={disabled}
1984
- displayValue={`${Math.round(parsed.angle)}°`}
1985
- formatDisplayValue={(next) => `${Math.round(next)}°`}
1986
- onCommit={(next) => patch({ angle: next })}
1987
- />
1988
- </div>
1989
- )}
1990
-
1991
- {parsed.kind === "radial" && (
1992
- <div className={RESPONSIVE_GRID}>
1993
- <SelectField
1994
- label="Shape"
1995
- value={parsed.shape}
1996
- disabled={disabled}
1997
- onChange={(next) => patch({ shape: next as GradientModel["shape"] })}
1998
- options={["ellipse", "circle"]}
1999
- />
2000
- <SelectField
2001
- label="Size"
2002
- value={parsed.radialSize}
2003
- disabled={disabled}
2004
- onChange={(next) => patch({ radialSize: next as GradientModel["radialSize"] })}
2005
- options={["closest-side", "closest-corner", "farthest-side", "farthest-corner"]}
2006
- />
2007
- </div>
2008
- )}
2009
-
2010
- {(parsed.kind === "radial" || parsed.kind === "conic") && (
2011
- <div className={RESPONSIVE_GRID}>
2012
- <div className="grid min-w-0 gap-1.5">
2013
- <span className={LABEL}>Center X</span>
2014
- <SliderControl
2015
- value={parsed.centerX}
2016
- min={0}
2017
- max={100}
2018
- step={1}
2019
- disabled={disabled}
2020
- displayValue={`${Math.round(parsed.centerX)}%`}
2021
- formatDisplayValue={(next) => `${Math.round(next)}%`}
2022
- onCommit={(next) => patch({ centerX: next })}
2023
- />
2024
- </div>
2025
- <div className="grid min-w-0 gap-1.5">
2026
- <span className={LABEL}>Center Y</span>
2027
- <SliderControl
2028
- value={parsed.centerY}
2029
- min={0}
2030
- max={100}
2031
- step={1}
2032
- disabled={disabled}
2033
- displayValue={`${Math.round(parsed.centerY)}%`}
2034
- formatDisplayValue={(next) => `${Math.round(next)}%`}
2035
- onCommit={(next) => patch({ centerY: next })}
2036
- />
2037
- </div>
2038
- </div>
2039
- )}
2040
-
2041
- <div className="space-y-3">
2042
- <div className="flex items-center justify-between">
2043
- <span className={LABEL}>Stops</span>
2044
- <button
2045
- type="button"
2046
- disabled={disabled || parsed.stops.length >= 6}
2047
- onClick={() => addStop()}
2048
- 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"
2049
- >
2050
- <Plus size={12} />
2051
- Add stop
2052
- </button>
2053
- </div>
2054
- <div className="space-y-3">
2055
- {parsed.stops.map((stop, index) => (
2056
- <div
2057
- key={`stop-editor-${index}`}
2058
- className="grid min-w-0 grid-cols-[minmax(0,1fr)_68px_28px] gap-2"
2059
- >
2060
- <ColorField
2061
- label={`Stop ${index + 1}`}
2062
- value={stop.color}
2063
- disabled={disabled}
2064
- onCommit={(next) => updateStop(index, { color: next })}
2065
- />
2066
- <DetailField
2067
- label="Pos"
2068
- value={`${Math.round(stop.position)}%`}
2069
- disabled={disabled}
2070
- onCommit={(next) =>
2071
- updateStop(index, {
2072
- position: Number.parseFloat(next.replace("%", "")) || 0,
2073
- })
2074
- }
2075
- />
2076
- <button
2077
- type="button"
2078
- disabled={disabled || parsed.stops.length <= 2}
2079
- onClick={() => removeStop(index)}
2080
- 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"
2081
- aria-label={`Remove stop ${index + 1}`}
2082
- >
2083
- <X size={12} />
2084
- </button>
2085
- </div>
2086
- ))}
2087
- </div>
2088
- </div>
2089
- </div>
114
+ </Section>
2090
115
  );
2091
116
  }
2092
117
 
2093
- function SliderControl({
2094
- value,
2095
- min,
2096
- max,
2097
- step,
2098
- displayValue,
2099
- formatDisplayValue,
2100
- disabled,
2101
- onCommit,
2102
- }: {
2103
- value: number;
2104
- min: number;
2105
- max: number;
2106
- step: number;
2107
- displayValue: string;
2108
- formatDisplayValue?: (nextValue: number) => string;
2109
- disabled?: boolean;
2110
- onCommit: (nextValue: number) => void;
2111
- }) {
2112
- const [draft, setDraft] = useState(value);
2113
- const commitTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
2114
- const valueRef = useRef(value);
2115
-
2116
- valueRef.current = value;
2117
-
2118
- useEffect(() => {
2119
- setDraft(value);
2120
- }, [value]);
2121
-
2122
- useEffect(
2123
- () => () => {
2124
- if (commitTimerRef.current) clearTimeout(commitTimerRef.current);
2125
- },
2126
- [],
2127
- );
2128
-
2129
- const commitDraft = (nextDraft: number) => {
2130
- if (commitTimerRef.current) clearTimeout(commitTimerRef.current);
2131
- if (nextDraft !== valueRef.current) {
2132
- onCommit(nextDraft);
2133
- }
2134
- };
2135
-
2136
- const scheduleCommit = (nextDraft: number) => {
2137
- if (commitTimerRef.current) clearTimeout(commitTimerRef.current);
2138
- commitTimerRef.current = setTimeout(() => {
2139
- if (nextDraft !== valueRef.current) {
2140
- onCommit(nextDraft);
2141
- }
2142
- }, 40);
2143
- };
2144
-
2145
- const renderedDisplayValue = formatDisplayValue?.(draft) ?? displayValue;
2146
-
2147
- return (
2148
- <div className="grid min-w-0 grid-cols-[minmax(0,1fr)_auto] items-center gap-2">
2149
- <input
2150
- type="range"
2151
- min={min}
2152
- max={max}
2153
- step={step}
2154
- value={draft}
2155
- disabled={disabled}
2156
- onChange={(e) => {
2157
- const nextDraft = Number(e.target.value);
2158
- setDraft(nextDraft);
2159
- scheduleCommit(nextDraft);
2160
- }}
2161
- onMouseUp={() => commitDraft(draft)}
2162
- onTouchEnd={() => commitDraft(draft)}
2163
- onBlur={() => commitDraft(draft)}
2164
- className="h-2 min-w-0 w-full cursor-pointer appearance-none rounded-full bg-neutral-800 accent-[#3ce6ac] disabled:cursor-not-allowed"
2165
- />
2166
- <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)]">
2167
- {renderedDisplayValue}
2168
- </div>
2169
- </div>
2170
- );
2171
- }
2172
-
2173
- function SegmentedControl({
2174
- options,
2175
- value,
2176
- disabled,
2177
- onChange,
2178
- }: {
2179
- options: Array<{ label: string; value: string }>;
2180
- value: string;
2181
- disabled?: boolean;
2182
- onChange: (nextValue: string) => void;
2183
- }) {
2184
- return (
2185
- <div
2186
- 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)]"
2187
- style={{ gridTemplateColumns: `repeat(${options.length}, minmax(0, 1fr))` }}
2188
- >
2189
- {options.map((option) => {
2190
- const selected = option.value === value;
2191
- return (
2192
- <button
2193
- key={option.value}
2194
- type="button"
2195
- disabled={disabled}
2196
- onClick={() => onChange(option.value)}
2197
- className={`min-w-0 truncate rounded-lg px-2 py-1.5 text-[11px] font-medium transition-colors disabled:cursor-not-allowed ${
2198
- selected
2199
- ? "bg-neutral-800 text-white shadow-[0_1px_3px_rgba(0,0,0,0.28)]"
2200
- : "text-neutral-500 hover:text-neutral-200"
2201
- }`}
2202
- >
2203
- {option.label}
2204
- </button>
2205
- );
2206
- })}
2207
- </div>
2208
- );
2209
- }
2210
-
2211
- function SelectField({
2212
- label,
2213
- value,
2214
- disabled,
2215
- options,
2216
- onChange,
2217
- }: {
2218
- label: string;
2219
- value: string;
2220
- disabled?: boolean;
2221
- options: string[];
2222
- onChange: (nextValue: string) => void;
2223
- }) {
2224
- const renderedOptions = value && !options.includes(value) ? [value, ...options] : options;
2225
-
2226
- return (
2227
- <label className={`${FIELD} flex items-center gap-3`}>
2228
- <span className="flex-shrink-0 text-[11px] font-medium text-neutral-500">{label}</span>
2229
- <select
2230
- value={value}
2231
- disabled={disabled}
2232
- onChange={(e) => onChange(e.target.value)}
2233
- 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"
2234
- >
2235
- {renderedOptions.map((option) => (
2236
- <option key={option} value={option}>
2237
- {option}
2238
- </option>
2239
- ))}
2240
- </select>
2241
- </label>
2242
- );
2243
- }
2244
-
2245
- function Section({
2246
- title,
2247
- icon,
2248
- children,
2249
- accessory,
2250
- }: {
2251
- title: string;
2252
- icon: ReactNode;
2253
- children: ReactNode;
2254
- accessory?: ReactNode;
2255
- }) {
2256
- return (
2257
- <section className="min-w-0 border-t border-neutral-800/80 px-4 py-4">
2258
- <div className="mb-3 flex min-w-0 flex-wrap items-center justify-between gap-2">
2259
- <div className="flex min-w-0 items-center gap-2.5">
2260
- <span className="flex-shrink-0 text-neutral-500">{icon}</span>
2261
- <h3 className="text-[11px] font-semibold uppercase tracking-[0.12em] text-neutral-300">
2262
- {title}
2263
- </h3>
2264
- </div>
2265
- {accessory}
2266
- </div>
2267
- {children}
2268
- </section>
2269
- );
2270
- }
118
+ /* ------------------------------------------------------------------ */
119
+ /* PropertyPanel */
120
+ /* ------------------------------------------------------------------ */
2271
121
 
2272
122
  export const PropertyPanel = memo(function PropertyPanel({
2273
123
  projectId,
@@ -2279,6 +129,7 @@ export const PropertyPanel = memo(function PropertyPanel({
2279
129
  onSetStyle,
2280
130
  onSetManualOffset,
2281
131
  onSetManualSize,
132
+ onSetManualRotation,
2282
133
  onSetText,
2283
134
  onSetTextFieldStyle,
2284
135
  onAddTextField,
@@ -2288,33 +139,10 @@ export const PropertyPanel = memo(function PropertyPanel({
2288
139
  onImportAssets,
2289
140
  fontAssets = [],
2290
141
  onImportFonts,
142
+ activeCompositionPath = null,
143
+ onSelectLayer,
2291
144
  }: PropertyPanelProps) {
2292
145
  const styles = element?.computedStyles ?? EMPTY_STYLES;
2293
- const backgroundImage = styles["background-image"] ?? "none";
2294
- const fillMode =
2295
- backgroundImage && backgroundImage !== "none"
2296
- ? backgroundImage.includes("gradient")
2297
- ? "Gradient"
2298
- : "Image"
2299
- : "Solid";
2300
- const [preferredFillMode, setPreferredFillMode] = useState(fillMode);
2301
- const imageUrl = extractBackgroundImageUrl(backgroundImage);
2302
- const [activeTextFieldKey, setActiveTextFieldKey] = useState<string | null>(
2303
- element?.textFields[0]?.key ?? null,
2304
- );
2305
- const hasTextControls = element != null && isTextEditableSelection(element);
2306
-
2307
- useEffect(() => {
2308
- setPreferredFillMode(fillMode);
2309
- }, [fillMode, element?.id, element?.selector, backgroundImage]);
2310
-
2311
- useEffect(() => {
2312
- const nextFields = element?.textFields ?? [];
2313
- setActiveTextFieldKey((current) => {
2314
- if (current && nextFields.some((field) => field.key === current)) return current;
2315
- return nextFields[0]?.key ?? null;
2316
- });
2317
- }, [element?.id, element?.selector, element?.textFields]);
2318
146
 
2319
147
  if (!element) {
2320
148
  return (
@@ -2346,35 +174,8 @@ export const PropertyPanel = memo(function PropertyPanel({
2346
174
  );
2347
175
  }
2348
176
 
2349
- const styleEditingDisabled = !element.capabilities.canEditStyles;
2350
177
  const manualOffsetEditingDisabled = !element.capabilities.canApplyManualOffset;
2351
178
  const manualSizeEditingDisabled = !element.capabilities.canApplyManualSize;
2352
- const isFlex = styles.display === "flex" || styles.display === "inline-flex";
2353
- const radiusValue = parseNumericValue(styles["border-radius"]) ?? 0;
2354
- const opacityValue = Math.round((parseNumericValue(styles.opacity) ?? 1) * 100);
2355
- const borderWidthValue =
2356
- parsePxMetricValue(styles["border-width"] ?? "") ??
2357
- parsePxMetricValue(styles["border-top-width"] ?? "") ??
2358
- 0;
2359
- const hasVisualBackground =
2360
- (styles.background != null && styles.background !== "none" && styles.background !== "") ||
2361
- (styles["background-color"] != null &&
2362
- styles["background-color"] !== "transparent" &&
2363
- styles["background-color"] !== "rgba(0, 0, 0, 0)" &&
2364
- styles["background-color"] !== "") ||
2365
- (styles["background-image"] != null &&
2366
- styles["background-image"] !== "none" &&
2367
- styles["background-image"] !== "") ||
2368
- borderWidthValue > 0;
2369
- const borderStyleValue = styles["border-style"] || styles["border-top-style"] || "none";
2370
- const borderColorValue =
2371
- styles["border-color"] || styles["border-top-color"] || "rgba(255, 255, 255, 0.18)";
2372
- const boxShadowPreset = inferBoxShadowPreset(styles["box-shadow"]);
2373
- const filterBlurValue = getCssFilterFunctionPx(styles.filter, "blur");
2374
- const backdropBlurValue = getCssFilterFunctionPx(styles["backdrop-filter"], "blur");
2375
- const clipPathValue = styles["clip-path"] || "none";
2376
- const clipPathPreset = inferClipPathPreset(clipPathValue);
2377
- const clipInsetValue = getClipPathInsetPx(clipPathValue);
2378
179
  const sourceLabel = element.id ? `#${element.id}` : element.selector;
2379
180
  const showEditableSections = element.capabilities.canEditStyles;
2380
181
  const manualOffset = readStudioPathOffset(element.element);
@@ -2416,18 +217,11 @@ export const PropertyPanel = memo(function PropertyPanel({
2416
217
  });
2417
218
  };
2418
219
 
2419
- const handleFillModeChange = (nextMode: string) => {
2420
- setPreferredFillMode(nextMode);
2421
- if (nextMode === "Solid") {
2422
- onSetStyle("background-image", "none");
2423
- return;
2424
- }
2425
- if (nextMode === "Gradient" && !backgroundImage.includes("gradient")) {
2426
- onSetStyle(
2427
- "background-image",
2428
- serializeGradient(buildDefaultGradientModel(styles["background-color"])),
2429
- );
2430
- }
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 });
2431
225
  };
2432
226
 
2433
227
  return (
@@ -2472,221 +266,23 @@ export const PropertyPanel = memo(function PropertyPanel({
2472
266
  </div>
2473
267
 
2474
268
  <div className="flex-1 overflow-y-auto">
2475
- {hasTextControls && (
2476
- <Section title="Text" icon={<Type size={15} />}>
2477
- {(() => {
2478
- const textFields = element.textFields;
2479
- const activeField =
2480
- textFields.find((field) => field.key === activeTextFieldKey) ?? textFields[0];
2481
- if (!activeField) return null;
2482
-
2483
- if (textFields.length === 1) {
2484
- return (
2485
- <div className="space-y-4 rounded-xl border border-neutral-800 bg-neutral-900/60 p-3">
2486
- <div className="min-w-0">
2487
- <div className="truncate text-[11px] font-medium text-neutral-100">
2488
- {formatTextFieldPreview(activeField.value) || "Text"}
2489
- </div>
2490
- <div className="text-[10px] uppercase tracking-[0.12em] text-neutral-500">
2491
- {activeField.tagName}
2492
- </div>
2493
- </div>
2494
-
2495
- <TextAreaField
2496
- key={activeField.key}
2497
- label="Content"
2498
- value={activeField.value}
2499
- disabled={false}
2500
- onCommit={(next) => onSetText(next, activeField.key)}
2501
- />
2502
-
2503
- <ColorField
2504
- label="Text color"
2505
- value={getTextFieldColor(activeField, styles)}
2506
- disabled={false}
2507
- onCommit={(next) => onSetTextFieldStyle(activeField.key, "color", next)}
2508
- />
2509
-
2510
- <div className={RESPONSIVE_GRID}>
2511
- <MetricField
2512
- label="Size"
2513
- value={
2514
- activeField.computedStyles["font-size"] || styles["font-size"] || "16px"
2515
- }
2516
- disabled={false}
2517
- liveCommit
2518
- onCommit={(next) => onSetTextFieldStyle(activeField.key, "font-size", next)}
2519
- />
2520
- <FontWeightField
2521
- value={
2522
- activeField.computedStyles["font-weight"] ||
2523
- styles["font-weight"] ||
2524
- "400"
2525
- }
2526
- fontFamily={
2527
- activeField.computedStyles["font-family"] || styles["font-family"]
2528
- }
2529
- disabled={false}
2530
- onCommit={(next) =>
2531
- onSetTextFieldStyle(activeField.key, "font-weight", next)
2532
- }
2533
- />
2534
- </div>
2535
-
2536
- <FontFamilyField
2537
- value={
2538
- activeField.computedStyles["font-family"] ||
2539
- styles["font-family"] ||
2540
- "inherit"
2541
- }
2542
- disabled={false}
2543
- importedFonts={fontAssets}
2544
- onImportFonts={onImportFonts}
2545
- onCommit={(next) => onSetTextFieldStyle(activeField.key, "font-family", next)}
2546
- />
2547
-
2548
- <AdvancedTextControls
2549
- field={activeField}
2550
- inheritedStyles={styles}
2551
- disabled={false}
2552
- onCommit={(property, value) =>
2553
- onSetTextFieldStyle(activeField.key, property, value)
2554
- }
2555
- />
2556
- </div>
2557
- );
2558
- }
2559
-
2560
- return (
2561
- <div className="space-y-4">
2562
- <div className="grid gap-1.5">
2563
- <div className="flex min-w-0 flex-wrap items-center justify-between gap-2">
2564
- <span className={LABEL}>Text layers</span>
2565
- <button
2566
- type="button"
2567
- onClick={() => {
2568
- void Promise.resolve(onAddTextField(activeField.key)).then((nextKey) => {
2569
- if (nextKey) setActiveTextFieldKey(nextKey);
2570
- });
2571
- }}
2572
- 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"
2573
- >
2574
- <Plus size={12} className="flex-shrink-0" />
2575
- <span className="truncate">Add text</span>
2576
- </button>
2577
- </div>
2578
- <div className="grid gap-2">
2579
- {textFields.map((field, index) => {
2580
- const active = field.key === activeField.key;
2581
- return (
2582
- <button
2583
- key={field.key}
2584
- type="button"
2585
- onClick={() => setActiveTextFieldKey(field.key)}
2586
- className={`min-w-0 w-full rounded-xl border px-3 py-2 text-left transition-colors ${
2587
- active
2588
- ? "border-studio-accent/50 bg-studio-accent/10"
2589
- : "border-neutral-800 bg-neutral-900/80 hover:border-neutral-700 hover:bg-neutral-900"
2590
- }`}
2591
- >
2592
- <div className="flex min-w-0 items-center justify-between gap-2">
2593
- <div className="flex min-w-0 items-center gap-2">
2594
- <span
2595
- className="h-4 w-4 flex-shrink-0 rounded border border-neutral-700 bg-neutral-950"
2596
- style={{ backgroundColor: getTextFieldColor(field, styles) }}
2597
- />
2598
- <span className="min-w-0 truncate text-[11px] font-medium text-neutral-100">
2599
- {formatTextFieldPreview(field.value) || `Text ${index + 1}`}
2600
- </span>
2601
- </div>
2602
- <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">
2603
- {field.tagName}
2604
- </span>
2605
- </div>
2606
- </button>
2607
- );
2608
- })}
2609
- </div>
2610
- </div>
2611
-
2612
- <div className="space-y-4 rounded-xl border border-neutral-800 bg-neutral-900/60 p-3">
2613
- <div className="flex min-w-0 items-center justify-between gap-2">
2614
- <div className="min-w-0">
2615
- <div className="truncate text-[11px] font-medium text-neutral-100">
2616
- {formatTextFieldPreview(activeField.value) || "Text"}
2617
- </div>
2618
- <div className="text-[10px] uppercase tracking-[0.12em] text-neutral-500">
2619
- {activeField.tagName}
2620
- </div>
2621
- </div>
2622
- <button
2623
- type="button"
2624
- onClick={() => onRemoveTextField(activeField.key)}
2625
- 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"
2626
- >
2627
- Remove
2628
- </button>
2629
- </div>
2630
-
2631
- <TextAreaField
2632
- key={activeField.key}
2633
- label="Content"
2634
- value={activeField.value}
2635
- disabled={false}
2636
- autoFocus
2637
- onCommit={(next) => onSetText(next, activeField.key)}
2638
- />
2639
-
2640
- <ColorField
2641
- label="Text color"
2642
- value={getTextFieldColor(activeField, styles)}
2643
- disabled={false}
2644
- onCommit={(next) => onSetTextFieldStyle(activeField.key, "color", next)}
2645
- />
2646
-
2647
- <div className={RESPONSIVE_GRID}>
2648
- <MetricField
2649
- label="Size"
2650
- value={activeField.computedStyles["font-size"] || "16px"}
2651
- disabled={false}
2652
- liveCommit
2653
- onCommit={(next) => onSetTextFieldStyle(activeField.key, "font-size", next)}
2654
- />
2655
- <FontWeightField
2656
- value={activeField.computedStyles["font-weight"] || "400"}
2657
- fontFamily={activeField.computedStyles["font-family"]}
2658
- disabled={false}
2659
- onCommit={(next) =>
2660
- onSetTextFieldStyle(activeField.key, "font-weight", next)
2661
- }
2662
- />
2663
- </div>
2664
-
2665
- <FontFamilyField
2666
- value={
2667
- activeField.computedStyles["font-family"] ||
2668
- styles["font-family"] ||
2669
- "inherit"
2670
- }
2671
- disabled={false}
2672
- importedFonts={fontAssets}
2673
- onImportFonts={onImportFonts}
2674
- onCommit={(next) => onSetTextFieldStyle(activeField.key, "font-family", next)}
2675
- />
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
+ />
2676
279
 
2677
- <AdvancedTextControls
2678
- field={activeField}
2679
- inheritedStyles={styles}
2680
- disabled={false}
2681
- onCommit={(property, value) =>
2682
- onSetTextFieldStyle(activeField.key, property, value)
2683
- }
2684
- />
2685
- </div>
2686
- </div>
2687
- );
2688
- })()}
2689
- </Section>
280
+ {onSelectLayer && (
281
+ <LayerTree
282
+ element={element}
283
+ activeCompositionPath={activeCompositionPath}
284
+ onSelectLayer={onSelectLayer}
285
+ />
2690
286
  )}
2691
287
 
2692
288
  <Section title="Layout" icon={<Move size={15} />}>
@@ -2719,310 +315,31 @@ export const PropertyPanel = memo(function PropertyPanel({
2719
315
  scrub
2720
316
  onCommit={(next) => commitManualSize("height", next)}
2721
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
+ />
2722
331
  </div>
2723
332
  </Section>
2724
333
 
2725
- {showEditableSections && isFlex && (
2726
- <Section title="Flex" icon={<Layers size={15} />}>
2727
- <div className="space-y-4">
2728
- <SegmentedControl
2729
- disabled={styleEditingDisabled}
2730
- value={styles["flex-direction"] || "row"}
2731
- onChange={(next) => onSetStyle("flex-direction", next)}
2732
- options={[
2733
- { label: "→ Row", value: "row" },
2734
- { label: "↓ Column", value: "column" },
2735
- ]}
2736
- />
2737
- <div className={RESPONSIVE_GRID}>
2738
- <SelectField
2739
- label="Justify"
2740
- value={styles["justify-content"] || "flex-start"}
2741
- disabled={styleEditingDisabled}
2742
- onChange={(next) => onSetStyle("justify-content", next)}
2743
- options={[
2744
- "flex-start",
2745
- "center",
2746
- "space-between",
2747
- "space-around",
2748
- "space-evenly",
2749
- "flex-end",
2750
- ]}
2751
- />
2752
- <SelectField
2753
- label="Align"
2754
- value={styles["align-items"] || "stretch"}
2755
- disabled={styleEditingDisabled}
2756
- onChange={(next) => onSetStyle("align-items", next)}
2757
- options={["stretch", "flex-start", "center", "flex-end", "baseline"]}
2758
- />
2759
- </div>
2760
- <DetailField
2761
- label="Gap"
2762
- value={styles.gap ?? "0px"}
2763
- disabled={styleEditingDisabled}
2764
- onCommit={(next) => onSetStyle("gap", next.endsWith("px") ? next : `${next}px`)}
2765
- />
2766
- </div>
2767
- </Section>
2768
- )}
2769
-
2770
334
  {showEditableSections && (
2771
- <>
2772
- {hasVisualBackground && (
2773
- <Section title="Radius" icon={<Settings size={15} />}>
2774
- <SliderControl
2775
- value={radiusValue}
2776
- min={0}
2777
- max={Math.max(240, Math.ceil(radiusValue))}
2778
- step={1}
2779
- disabled={styleEditingDisabled}
2780
- displayValue={`${formatNumericValue(radiusValue)}px`}
2781
- formatDisplayValue={(next) => `${formatNumericValue(next)}px`}
2782
- onCommit={(next) => onSetStyle("border-radius", `${formatNumericValue(next)}px`)}
2783
- />
2784
- </Section>
2785
- )}
2786
-
2787
- <Section title="Stroke" icon={<Square size={15} />}>
2788
- <div className="space-y-4">
2789
- <div className={RESPONSIVE_GRID}>
2790
- <MetricField
2791
- label="Width"
2792
- value={formatPxMetricValue(borderWidthValue)}
2793
- disabled={styleEditingDisabled}
2794
- liveCommit
2795
- onCommit={async (next) => {
2796
- const normalized = normalizePanelPxValue(next, {
2797
- min: 0,
2798
- max: 200,
2799
- fallback: borderWidthValue,
2800
- });
2801
- if (!normalized) return;
2802
- for (const [property, value] of buildStrokeWidthStyleUpdates(
2803
- normalized,
2804
- borderStyleValue,
2805
- )) {
2806
- await onSetStyle(property, value);
2807
- }
2808
- }}
2809
- />
2810
- <SelectField
2811
- label="Style"
2812
- value={borderStyleValue}
2813
- disabled={styleEditingDisabled}
2814
- onChange={async (next) => {
2815
- for (const [property, value] of buildStrokeStyleUpdates(
2816
- next,
2817
- formatPxMetricValue(borderWidthValue),
2818
- )) {
2819
- await onSetStyle(property, value);
2820
- }
2821
- }}
2822
- options={[
2823
- "none",
2824
- "solid",
2825
- "dashed",
2826
- "dotted",
2827
- "double",
2828
- "hidden",
2829
- "groove",
2830
- "ridge",
2831
- "inset",
2832
- "outset",
2833
- ]}
2834
- />
2835
- </div>
2836
- <ColorField
2837
- label="Stroke color"
2838
- value={borderColorValue}
2839
- disabled={styleEditingDisabled}
2840
- onCommit={(next) => onSetStyle("border-color", next)}
2841
- />
2842
- </div>
2843
- </Section>
2844
-
2845
- <Section title="Effects" icon={<Zap size={15} />}>
2846
- <div className="space-y-4">
2847
- <SelectField
2848
- label="Shadow"
2849
- value={boxShadowPreset}
2850
- disabled={styleEditingDisabled}
2851
- onChange={(next) => {
2852
- if (next === "custom") return;
2853
- onSetStyle(
2854
- "box-shadow",
2855
- buildBoxShadowPresetValue(next as BoxShadowPreset, styles["box-shadow"]),
2856
- );
2857
- }}
2858
- options={["custom", "none", "soft", "lift", "glow"]}
2859
- />
2860
- <div className={RESPONSIVE_GRID}>
2861
- <div className="grid min-w-0 gap-1.5">
2862
- <span className={LABEL}>Layer blur</span>
2863
- <SliderControl
2864
- value={filterBlurValue}
2865
- min={0}
2866
- max={Math.max(40, Math.ceil(filterBlurValue))}
2867
- step={1}
2868
- disabled={styleEditingDisabled}
2869
- displayValue={`${formatNumericValue(filterBlurValue)}px`}
2870
- formatDisplayValue={(next) => `${formatNumericValue(next)}px`}
2871
- onCommit={(next) =>
2872
- onSetStyle("filter", setCssFilterFunctionPx(styles.filter, "blur", next))
2873
- }
2874
- />
2875
- </div>
2876
- <div className="grid min-w-0 gap-1.5">
2877
- <span className={LABEL}>Backdrop</span>
2878
- <SliderControl
2879
- value={backdropBlurValue}
2880
- min={0}
2881
- max={Math.max(60, Math.ceil(backdropBlurValue))}
2882
- step={1}
2883
- disabled={styleEditingDisabled}
2884
- displayValue={`${formatNumericValue(backdropBlurValue)}px`}
2885
- formatDisplayValue={(next) => `${formatNumericValue(next)}px`}
2886
- onCommit={(next) =>
2887
- onSetStyle(
2888
- "backdrop-filter",
2889
- setCssFilterFunctionPx(styles["backdrop-filter"], "blur", next),
2890
- )
2891
- }
2892
- />
2893
- </div>
2894
- </div>
2895
- </div>
2896
- </Section>
2897
-
2898
- <Section title="Clip" icon={<Layers size={15} />}>
2899
- <div className="space-y-4">
2900
- <div className={RESPONSIVE_GRID}>
2901
- <SelectField
2902
- label="Overflow"
2903
- value={styles.overflow || "visible"}
2904
- disabled={styleEditingDisabled}
2905
- onChange={(next) => onSetStyle("overflow", next)}
2906
- options={["visible", "hidden", "clip", "auto", "scroll"]}
2907
- />
2908
- <SelectField
2909
- label="Mask"
2910
- value={clipPathPreset}
2911
- disabled={styleEditingDisabled}
2912
- onChange={(next) => {
2913
- if (next === "custom") return;
2914
- onSetStyle(
2915
- "clip-path",
2916
- buildClipPathValue(
2917
- next as "none" | "inset" | "circle",
2918
- radiusValue,
2919
- clipPathValue,
2920
- ),
2921
- );
2922
- }}
2923
- options={["custom", "none", "inset", "circle"]}
2924
- />
2925
- </div>
2926
- <div className="grid min-w-0 gap-1.5">
2927
- <span className={LABEL}>Mask inset</span>
2928
- <SliderControl
2929
- value={clipInsetValue}
2930
- min={0}
2931
- max={Math.max(120, Math.ceil(clipInsetValue))}
2932
- step={1}
2933
- disabled={styleEditingDisabled}
2934
- displayValue={`${formatNumericValue(clipInsetValue)}px`}
2935
- formatDisplayValue={(next) => `${formatNumericValue(next)}px`}
2936
- onCommit={(next) =>
2937
- onSetStyle("clip-path", buildInsetClipPathValue(next, radiusValue))
2938
- }
2939
- />
2940
- </div>
2941
- </div>
2942
- </Section>
2943
-
2944
- <Section title="Transparency" icon={<Eye size={15} />}>
2945
- <div className="space-y-4">
2946
- <SliderControl
2947
- value={opacityValue}
2948
- min={0}
2949
- max={100}
2950
- step={1}
2951
- disabled={styleEditingDisabled}
2952
- displayValue={`${opacityValue}%`}
2953
- formatDisplayValue={(next) => `${Math.round(next)}%`}
2954
- onCommit={(next) => onSetStyle("opacity", formatNumericValue(next / 100))}
2955
- />
2956
- <SelectField
2957
- label="Mode"
2958
- value={styles["mix-blend-mode"] || "normal"}
2959
- disabled={styleEditingDisabled}
2960
- onChange={(next) => onSetStyle("mix-blend-mode", next)}
2961
- options={["normal", "multiply", "screen", "overlay", "darken", "lighten"]}
2962
- />
2963
- </div>
2964
- </Section>
2965
-
2966
- <Section
2967
- title="Fill"
2968
- icon={<Palette size={15} />}
2969
- accessory={
2970
- <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">
2971
- {preferredFillMode}
2972
- </div>
2973
- }
2974
- >
2975
- <div className="space-y-4">
2976
- <SegmentedControl
2977
- disabled={styleEditingDisabled}
2978
- value={preferredFillMode}
2979
- onChange={handleFillModeChange}
2980
- options={[
2981
- { label: "Solid", value: "Solid" },
2982
- { label: "Gradient", value: "Gradient" },
2983
- { label: "Image", value: "Image" },
2984
- ]}
2985
- />
2986
- {preferredFillMode === "Solid" ? (
2987
- <ColorField
2988
- label="Fill color"
2989
- value={styles["background-color"] ?? "transparent"}
2990
- disabled={styleEditingDisabled}
2991
- onCommit={(next) => onSetStyle("background-color", next)}
2992
- />
2993
- ) : preferredFillMode === "Gradient" ? (
2994
- <GradientField
2995
- value={
2996
- backgroundImage !== "none"
2997
- ? backgroundImage
2998
- : serializeGradient(buildDefaultGradientModel(styles["background-color"]))
2999
- }
3000
- fallbackColor={styles["background-color"]}
3001
- disabled={styleEditingDisabled}
3002
- onCommit={(next) => onSetStyle("background-image", next)}
3003
- />
3004
- ) : (
3005
- <ImageFillField
3006
- projectId={projectId}
3007
- sourceFile={element.sourceFile}
3008
- value={imageUrl}
3009
- assets={assets}
3010
- disabled={styleEditingDisabled}
3011
- onCommit={(next) => onSetStyle("background-image", next)}
3012
- onImportAssets={onImportAssets}
3013
- />
3014
- )}
3015
- {!hasTextControls && (
3016
- <ColorField
3017
- label="Text color"
3018
- value={styles.color ?? "rgb(0, 0, 0)"}
3019
- disabled={styleEditingDisabled}
3020
- onCommit={(next) => onSetStyle("color", next)}
3021
- />
3022
- )}
3023
- </div>
3024
- </Section>
3025
- </>
335
+ <StyleSections
336
+ projectId={projectId}
337
+ element={element}
338
+ styles={styles}
339
+ assets={assets}
340
+ onSetStyle={onSetStyle}
341
+ onImportAssets={onImportAssets}
342
+ />
3026
343
  )}
3027
344
  </div>
3028
345
  </div>