@developer_tribe/react-builder 1.0.2 → 1.0.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (147) hide show
  1. package/dist/AttributesEditor.d.ts +3 -1
  2. package/dist/RenderPage.d.ts +2 -1
  3. package/dist/android.svg +43 -0
  4. package/dist/apple.svg +16 -0
  5. package/dist/attributes-editor/Field.d.ts +4 -2
  6. package/dist/attributes-editor/SizeField.d.ts +9 -0
  7. package/dist/attributes-editor/SpecialCategorySection.d.ts +2 -1
  8. package/dist/build-components/BackgroundImage/BackgroundImage.d.ts +5 -0
  9. package/dist/build-components/BackgroundImage/BackgroundImageProps.generated.d.ts +45 -0
  10. package/dist/build-components/Button/ButtonProps.generated.d.ts +8 -0
  11. package/dist/build-components/Carousel/CarouselProps.generated.d.ts +8 -0
  12. package/dist/build-components/CarouselButtons/CarouselButtonsProps.generated.d.ts +8 -0
  13. package/dist/build-components/CarouselDots/CarouselDotsProps.generated.d.ts +8 -0
  14. package/dist/build-components/CarouselItem/CarouselItemProps.generated.d.ts +8 -0
  15. package/dist/build-components/CarouselProvider/CarouselProviderProps.generated.d.ts +8 -0
  16. package/dist/build-components/Image/ImageProps.generated.d.ts +8 -0
  17. package/dist/build-components/Onboard/OnboardProps.generated.d.ts +8 -0
  18. package/dist/build-components/OnboardButton/OnboardButtonProps.generated.d.ts +8 -1
  19. package/dist/build-components/OnboardButtons/OnboardButtonsProps.generated.d.ts +8 -0
  20. package/dist/build-components/OnboardDot/OnboardDotProps.generated.d.ts +9 -3
  21. package/dist/build-components/OnboardFooter/OnboardFooterProps.generated.d.ts +8 -0
  22. package/dist/build-components/OnboardImage/OnboardImageProps.generated.d.ts +9 -1
  23. package/dist/build-components/OnboardItem/OnboardItemProps.generated.d.ts +8 -0
  24. package/dist/build-components/OnboardProvider/OnboardProviderProps.generated.d.ts +8 -1
  25. package/dist/build-components/OnboardSubtitle/OnboardSubtitleProps.generated.d.ts +8 -0
  26. package/dist/build-components/OnboardTitle/OnboardTitleProps.generated.d.ts +8 -0
  27. package/dist/build-components/Text/TextProps.generated.d.ts +8 -0
  28. package/dist/build-components/View/ViewProps.generated.d.ts +8 -0
  29. package/dist/build-components/index.d.ts +2 -1
  30. package/dist/build-components/patterns.generated.d.ts +1612 -46
  31. package/dist/components/AttributesEditorPanel.d.ts +3 -4
  32. package/dist/components/Builder.d.ts +2 -1
  33. package/dist/components/BuilderButton.d.ts +9 -0
  34. package/dist/components/JsonTextEditor.d.ts +9 -0
  35. package/dist/index.cjs.js +5 -5
  36. package/dist/index.cjs.js.map +1 -1
  37. package/dist/index.d.ts +2 -2
  38. package/dist/index.esm.js +5 -5
  39. package/dist/index.esm.js.map +1 -1
  40. package/dist/modals/ColorModal.d.ts +3 -1
  41. package/dist/pages/ProjectPage.d.ts +3 -3
  42. package/dist/pages/tabs/BuilderPanel.d.ts +8 -0
  43. package/dist/pages/tabs/SideTool.d.ts +8 -0
  44. package/dist/store.d.ts +9 -1
  45. package/dist/styles.css +1 -1
  46. package/dist/types/Project.d.ts +11 -0
  47. package/dist/utils/analyseNode.d.ts +1 -0
  48. package/dist/utils/extractImageStyle.d.ts +2 -1
  49. package/dist/utils/extractTextStyle.d.ts +8 -1
  50. package/dist/utils/extractViewStyle.d.ts +7 -1
  51. package/dist/utils/parseColor.d.ts +7 -0
  52. package/dist/utils/selection.d.ts +7 -0
  53. package/dist/utils/useMergedStyle.d.ts +2 -0
  54. package/package.json +2 -5
  55. package/src/.DS_Store +0 -0
  56. package/src/AttributesEditor.tsx +83 -16
  57. package/src/RenderPage.tsx +86 -4
  58. package/src/attributes-editor/Field.tsx +60 -165
  59. package/src/attributes-editor/SizeField.tsx +184 -0
  60. package/src/attributes-editor/SpecialCategorySection.tsx +12 -4
  61. package/src/build-components/BackgroundImage/BackgroundImage.tsx +77 -0
  62. package/src/build-components/BackgroundImage/BackgroundImageProps.generated.ts +61 -0
  63. package/src/build-components/BackgroundImage/pattern.json +45 -0
  64. package/src/build-components/Button/Button.tsx +29 -4
  65. package/src/build-components/Button/ButtonProps.generated.ts +8 -0
  66. package/src/build-components/Carousel/Carousel.tsx +25 -3
  67. package/src/build-components/Carousel/CarouselProps.generated.ts +8 -0
  68. package/src/build-components/CarouselButtons/CarouselButtons.tsx +19 -4
  69. package/src/build-components/CarouselButtons/CarouselButtonsProps.generated.ts +8 -0
  70. package/src/build-components/CarouselDots/CarouselDots.tsx +13 -4
  71. package/src/build-components/CarouselDots/CarouselDotsProps.generated.ts +8 -0
  72. package/src/build-components/CarouselItem/CarouselItem.tsx +20 -4
  73. package/src/build-components/CarouselItem/CarouselItemProps.generated.ts +8 -0
  74. package/src/build-components/CarouselProvider/CarouselProvider.tsx +14 -3
  75. package/src/build-components/CarouselProvider/CarouselProviderProps.generated.ts +8 -0
  76. package/src/build-components/Image/Image.tsx +27 -9
  77. package/src/build-components/Image/ImageProps.generated.ts +8 -0
  78. package/src/build-components/Image/pattern.json +1 -9
  79. package/src/build-components/Onboard/Onboard.tsx +2 -2
  80. package/src/build-components/Onboard/OnboardProps.generated.ts +8 -0
  81. package/src/build-components/OnboardButton/OnboardButton.tsx +11 -7
  82. package/src/build-components/OnboardButton/OnboardButtonProps.generated.ts +8 -1
  83. package/src/build-components/OnboardButtons/OnboardButtons.tsx +17 -5
  84. package/src/build-components/OnboardButtons/OnboardButtonsProps.generated.ts +8 -0
  85. package/src/build-components/OnboardDot/OnboardDot.tsx +68 -39
  86. package/src/build-components/OnboardDot/OnboardDotProps.generated.ts +9 -3
  87. package/src/build-components/OnboardDot/pattern.json +3 -19
  88. package/src/build-components/OnboardFooter/OnboardFooter.tsx +37 -14
  89. package/src/build-components/OnboardFooter/OnboardFooterProps.generated.ts +8 -0
  90. package/src/build-components/OnboardImage/OnboardImage.tsx +28 -6
  91. package/src/build-components/OnboardImage/OnboardImageProps.generated.ts +9 -1
  92. package/src/build-components/OnboardItem/OnboardItem.tsx +15 -14
  93. package/src/build-components/OnboardItem/OnboardItemProps.generated.ts +8 -0
  94. package/src/build-components/OnboardProvider/OnboardProvider.tsx +35 -20
  95. package/src/build-components/OnboardProvider/OnboardProviderProps.generated.ts +8 -1
  96. package/src/build-components/OnboardProvider/pattern.json +0 -8
  97. package/src/build-components/OnboardSubtitle/OnboardSubtitleProps.generated.ts +8 -0
  98. package/src/build-components/OnboardSubtitle/pattern.json +1 -1
  99. package/src/build-components/OnboardTitle/OnboardTitleProps.generated.ts +8 -0
  100. package/src/build-components/OnboardTitle/pattern.json +1 -1
  101. package/src/build-components/RenderNode.generated.tsx +3 -0
  102. package/src/build-components/Text/Text.tsx +28 -10
  103. package/src/build-components/Text/TextProps.generated.ts +8 -0
  104. package/src/build-components/View/View.tsx +25 -3
  105. package/src/build-components/View/ViewProps.generated.ts +8 -0
  106. package/src/build-components/View/pattern.json +67 -1
  107. package/src/build-components/index.ts +5 -0
  108. package/src/build-components/patterns.generated.ts +1620 -46
  109. package/src/components/AttributesEditorPanel.tsx +13 -6
  110. package/src/components/Builder.tsx +200 -56
  111. package/src/components/BuilderButton.tsx +127 -0
  112. package/src/components/DeviceNavigationBar.tsx +0 -1
  113. package/src/components/EditorHeader.tsx +11 -1
  114. package/src/components/JsonTextEditor.tsx +185 -0
  115. package/src/index.ts +2 -2
  116. package/src/mockOS/components/MockOSRouter.tsx +17 -3
  117. package/src/mockOS/context/MockOSContext.tsx +0 -5
  118. package/src/mockOS/managers/mockPermissionManager.ts +0 -4
  119. package/src/mockOS/managers/navigationManager.ts +1 -6
  120. package/src/modals/ColorModal.tsx +306 -71
  121. package/src/modals/LocalicationModal.tsx +4 -5
  122. package/src/modals/Modal.tsx +8 -1
  123. package/src/pages/ProjectPage.tsx +299 -55
  124. package/src/pages/tabs/{BuilderTab.tsx → BuilderPanel.tsx} +13 -9
  125. package/src/pages/tabs/SideTool.tsx +260 -0
  126. package/src/size-matters/index.ts +6 -0
  127. package/src/store.ts +18 -1
  128. package/src/styles/base/_global.scss +163 -7
  129. package/src/styles/components/_attributes-editor.scss +12 -0
  130. package/src/styles/components/_editor-shell.scss +25 -0
  131. package/src/styles/foundation/_sizes.scss +1 -1
  132. package/src/styles/layout/_builder.scss +66 -10
  133. package/src/styles/modals/_color-modal.scss +59 -1
  134. package/src/styles/utilities/_carousel.scss +9 -8
  135. package/src/types/Project.ts +14 -0
  136. package/src/utils/analyseNode.ts +98 -0
  137. package/src/utils/extractImageStyle.ts +3 -6
  138. package/src/utils/extractTextStyle.ts +19 -82
  139. package/src/utils/extractViewStyle.ts +41 -12
  140. package/src/utils/parseColor.ts +43 -0
  141. package/src/utils/selection.ts +24 -0
  142. package/src/utils/useMergedStyle.ts +16 -0
  143. package/dist/pages/tabs/BuilderTab.d.ts +0 -9
  144. package/dist/pages/tabs/DebugTab.d.ts +0 -7
  145. package/dist/pages/tabs/PreviewTab.d.ts +0 -3
  146. package/src/pages/tabs/DebugTab.tsx +0 -64
  147. package/src/pages/tabs/PreviewTab.tsx +0 -206
@@ -1,6 +1,7 @@
1
1
  import React, { useCallback, useEffect, useMemo, useState } from 'react';
2
2
  import type { ViewPropsGenerated } from '../build-components/View/ViewProps.generated';
3
- import { ColorModal } from '../modals/ColorModal';
3
+ import { ColorModal, resolveProjectColorValue } from '../modals/ColorModal';
4
+ import type { ProjectColors } from '../types/Project';
4
5
  import {
5
6
  getArrayItemType,
6
7
  getTypeSchema,
@@ -8,6 +9,7 @@ import {
8
9
  } from '../utils/patterns';
9
10
  import { Checkbox } from '../components/Checkbox';
10
11
  import { LayoutPreviewPicker } from './LayoutPreviewPicker';
12
+ import { SizeField, type PreferredScale } from './SizeField';
11
13
  import { LayoutContext, LayoutFieldName, isBooleanFieldType } from './types';
12
14
 
13
15
  export type FieldProps = {
@@ -16,11 +18,11 @@ export type FieldProps = {
16
18
  value: any;
17
19
  onChange: (v: any) => void;
18
20
  componentType?: string;
19
- projectColors?: string[];
21
+ projectColors?: ProjectColors;
20
22
  layoutContext?: LayoutContext;
21
23
  viewAttributes?: Partial<ViewPropsGenerated['attributes']>;
22
24
  label?: React.ReactNode;
23
- preferredScale?: string;
25
+ preferredScale?: PreferredScale;
24
26
  };
25
27
 
26
28
  const layoutFieldNames: LayoutFieldName[] = [
@@ -250,10 +252,54 @@ export function Field({
250
252
  );
251
253
  })}
252
254
  </div>
253
- <div style={{ marginTop: 8 }}>
255
+ <div
256
+ style={{
257
+ marginTop: 8,
258
+ display: 'flex',
259
+ alignItems: 'center',
260
+ justifyContent: 'space-between',
261
+ gap: 8,
262
+ }}
263
+ >
264
+ <div style={{ display: 'flex', gap: 4 }}>
265
+ <button
266
+ type="button"
267
+ onClick={(event) => {
268
+ event.stopPropagation();
269
+ if (idx <= 0) return;
270
+ const next = [...arr];
271
+ const tmp = next[idx - 1];
272
+ next[idx - 1] = next[idx];
273
+ next[idx] = tmp;
274
+ onChange(next);
275
+ }}
276
+ disabled={idx <= 0}
277
+ aria-label="Move up"
278
+ >
279
+
280
+ </button>
281
+ <button
282
+ type="button"
283
+ onClick={(event) => {
284
+ event.stopPropagation();
285
+ if (idx >= arr.length - 1) return;
286
+ const next = [...arr];
287
+ const tmp = next[idx + 1];
288
+ next[idx + 1] = next[idx];
289
+ next[idx] = tmp;
290
+ onChange(next);
291
+ }}
292
+ disabled={idx >= arr.length - 1}
293
+ aria-label="Move down"
294
+ >
295
+
296
+ </button>
297
+ </div>
298
+
254
299
  <button
255
300
  type="button"
256
- onClick={() => {
301
+ onClick={(event) => {
302
+ event.stopPropagation();
257
303
  const next = arr.filter((_, i) => i !== idx);
258
304
  onChange(next.length ? next : undefined);
259
305
  }}
@@ -441,16 +487,22 @@ export function Field({
441
487
  type ColorPickerButtonProps = {
442
488
  value?: string;
443
489
  onChange: (color?: string) => void;
444
- projectColors?: string[];
490
+ projectColors?: ProjectColors;
445
491
  };
446
492
 
447
493
  function ColorPickerButton({
448
494
  value,
449
495
  onChange,
450
- projectColors = [],
496
+ projectColors,
451
497
  }: ColorPickerButtonProps) {
452
498
  const [isOpen, setIsOpen] = useState(false);
453
499
  const normalizedValue = typeof value === 'string' ? value : undefined;
500
+ const previewColor = useMemo(
501
+ () =>
502
+ resolveProjectColorValue(normalizedValue, projectColors) ??
503
+ normalizedValue,
504
+ [normalizedValue, projectColors],
505
+ );
454
506
 
455
507
  return (
456
508
  <>
@@ -477,7 +529,7 @@ function ColorPickerButton({
477
529
  height: 32,
478
530
  borderRadius: 6,
479
531
  border: '1px solid rgba(0,0,0,0.1)',
480
- background: normalizedValue ?? 'transparent',
532
+ background: previewColor ?? 'transparent',
481
533
  }}
482
534
  />
483
535
  <span style={{ flex: 1, textAlign: 'left', fontWeight: 500 }}>
@@ -503,160 +555,3 @@ function ColorPickerButton({
503
555
  </>
504
556
  );
505
557
  }
506
-
507
- type SizeUnit = '' | 'vs' | 's' | 'f' | '%';
508
-
509
- function parseSizeValue(value: unknown): { amount: string; unit: SizeUnit } {
510
- const empty = { amount: '', unit: '' as SizeUnit };
511
- if (typeof value === 'number' && Number.isFinite(value)) {
512
- return { amount: String(value), unit: '' };
513
- }
514
- if (typeof value !== 'string') return empty;
515
- const trimmed = value.trim();
516
- if (!trimmed) return empty;
517
- if (trimmed.endsWith('%')) {
518
- return { amount: trimmed.slice(0, -1), unit: '%' };
519
- }
520
- const lower = trimmed.toLowerCase();
521
- if (lower.endsWith('@vs'))
522
- return { amount: trimmed.slice(0, -3), unit: 'vs' };
523
- if (lower.endsWith('vs')) return { amount: trimmed.slice(0, -2), unit: 'vs' };
524
- if (lower.endsWith('@fs')) return { amount: trimmed.slice(0, -3), unit: 'f' };
525
- if (lower.endsWith('@f')) return { amount: trimmed.slice(0, -2), unit: 'f' };
526
- if (lower.endsWith('fs')) return { amount: trimmed.slice(0, -2), unit: 'f' };
527
- if (lower.endsWith('f')) return { amount: trimmed.slice(0, -1), unit: 'f' };
528
- if (lower.endsWith('@s')) return { amount: trimmed.slice(0, -2), unit: 's' };
529
- if (lower.endsWith('s')) return { amount: trimmed.slice(0, -1), unit: 's' };
530
- if (lower.endsWith('px')) return { amount: trimmed.slice(0, -2), unit: '' };
531
- return { amount: trimmed, unit: '' };
532
- }
533
-
534
- function composeSizeValue(amount: string, unit: SizeUnit): string | number {
535
- const trimmed = amount.trim();
536
- if (unit === '%') {
537
- return `${trimmed}%`;
538
- }
539
- if (unit === '') {
540
- const numeric = Number(trimmed);
541
- return Number.isFinite(numeric) ? numeric : trimmed;
542
- }
543
- if (unit === 'f') {
544
- return `${trimmed}@fs`;
545
- }
546
- return `${trimmed}@${unit}`;
547
- }
548
-
549
- type SizeFieldProps = {
550
- value: unknown;
551
- onChange: (val: unknown) => void;
552
- preferredScale?: string;
553
- fieldName: string;
554
- };
555
-
556
- function normalizePreferredScale(
557
- preferredScale: string | undefined,
558
- fieldName: string,
559
- ): SizeUnit {
560
- const fallbackName = fieldName.trim().toLowerCase();
561
- const fallback: SizeUnit =
562
- fallbackName.includes('height') ||
563
- fallbackName.includes('top') ||
564
- fallbackName.includes('vertical')
565
- ? 'vs'
566
- : 's';
567
- if (typeof preferredScale !== 'string') return fallback;
568
- const normalized = preferredScale.trim().toLowerCase();
569
- if (
570
- normalized === 'vs' ||
571
- normalized === 's' ||
572
- normalized === 'f' ||
573
- normalized === '%'
574
- ) {
575
- return normalized as SizeUnit;
576
- }
577
- return fallback;
578
- }
579
-
580
- function SizeField({
581
- value,
582
- onChange,
583
- preferredScale,
584
- fieldName,
585
- }: SizeFieldProps) {
586
- const parsed = useMemo(() => parseSizeValue(value), [value]);
587
- const normalizedPreferred = useMemo(
588
- () => normalizePreferredScale(preferredScale, fieldName),
589
- [preferredScale, fieldName],
590
- );
591
- const [amount, setAmount] = useState(parsed.amount);
592
- const [unit, setUnit] = useState<SizeUnit>(
593
- parsed.unit || normalizedPreferred,
594
- );
595
-
596
- useEffect(() => {
597
- setAmount(parsed.amount);
598
- setUnit(parsed.unit || normalizedPreferred);
599
- }, [parsed.amount, parsed.unit, normalizedPreferred]);
600
-
601
- const emitValue = useCallback(
602
- (nextAmount: string, nextUnit: SizeUnit) => {
603
- const trimmed = nextAmount.trim();
604
- if (!trimmed) {
605
- onChange(undefined);
606
- return;
607
- }
608
- onChange(composeSizeValue(trimmed, nextUnit));
609
- },
610
- [onChange],
611
- );
612
-
613
- const handleAmountChange = (e: React.ChangeEvent<HTMLInputElement>) => {
614
- const nextAmount = e.target.value;
615
- setAmount(nextAmount);
616
- if (!nextAmount.trim()) {
617
- onChange(undefined);
618
- return;
619
- }
620
- emitValue(nextAmount, unit);
621
- };
622
-
623
- const handleUnitChange = (e: React.ChangeEvent<HTMLSelectElement>) => {
624
- const nextUnit = e.target.value as SizeUnit;
625
- setUnit(nextUnit);
626
- if (!amount.trim()) return;
627
- emitValue(amount, nextUnit);
628
- };
629
-
630
- const unitPriority: SizeUnit[] = ['vs', 's', 'f', '%'];
631
- const orderedUnits = [
632
- normalizedPreferred,
633
- ...unitPriority.filter((unit) => unit !== normalizedPreferred),
634
- ];
635
- const unitOptions: Array<{ value: SizeUnit; label: string }> =
636
- orderedUnits.map((unit) => ({
637
- value: unit,
638
- label: unit === normalizedPreferred ? `${unit}` : unit,
639
- }));
640
-
641
- return (
642
- <div className="attributes-editor__size-field">
643
- <input
644
- type="number"
645
- className="input attributes-editor__size-field-input"
646
- value={amount}
647
- onChange={handleAmountChange}
648
- />
649
- <select
650
- className="input attributes-editor__size-field-select"
651
- value={unit}
652
- onChange={handleUnitChange}
653
- >
654
- {unitOptions.map((opt) => (
655
- <option key={opt.value} value={opt.value}>
656
- {opt.label}
657
- </option>
658
- ))}
659
- </select>
660
- </div>
661
- );
662
- }
@@ -0,0 +1,184 @@
1
+ import React, { useCallback, useEffect, useMemo, useState } from 'react';
2
+
3
+ type SizeUnit = '' | 'vs' | 's' | 'f' | '%';
4
+ type SizeSelectUnit = SizeUnit | 'auto';
5
+
6
+ export type PreferredScale = 'vs' | 's' | 'f' | '%';
7
+
8
+ export function toPreferredScale(value: unknown): PreferredScale | undefined {
9
+ if (value === 'vs' || value === 's' || value === 'f' || value === '%') {
10
+ return value;
11
+ }
12
+ if (typeof value !== 'string') return undefined;
13
+ const normalized = value.trim().toLowerCase();
14
+ return normalized === 'vs' ||
15
+ normalized === 's' ||
16
+ normalized === 'f' ||
17
+ normalized === '%'
18
+ ? (normalized as PreferredScale)
19
+ : undefined;
20
+ }
21
+
22
+ export type SizeFieldProps = {
23
+ value: unknown;
24
+ onChange: (val: unknown) => void;
25
+ preferredScale?: PreferredScale;
26
+ fieldName: string;
27
+ };
28
+
29
+ function parseSizeValue(value: unknown): { amount: string; unit: SizeUnit } {
30
+ const empty = { amount: '', unit: '' as SizeUnit };
31
+ if (typeof value === 'number' && Number.isFinite(value)) {
32
+ return { amount: String(value), unit: '' };
33
+ }
34
+ if (typeof value !== 'string') return empty;
35
+ const trimmed = value.trim();
36
+ if (!trimmed) return empty;
37
+ if (trimmed.endsWith('%')) {
38
+ return { amount: trimmed.slice(0, -1), unit: '%' };
39
+ }
40
+ const lower = trimmed.toLowerCase();
41
+ if (lower.endsWith('@vs'))
42
+ return { amount: trimmed.slice(0, -3), unit: 'vs' };
43
+ if (lower.endsWith('vs')) return { amount: trimmed.slice(0, -2), unit: 'vs' };
44
+ if (lower.endsWith('@fs')) return { amount: trimmed.slice(0, -3), unit: 'f' };
45
+ if (lower.endsWith('@f')) return { amount: trimmed.slice(0, -2), unit: 'f' };
46
+ if (lower.endsWith('fs')) return { amount: trimmed.slice(0, -2), unit: 'f' };
47
+ if (lower.endsWith('f')) return { amount: trimmed.slice(0, -1), unit: 'f' };
48
+ if (lower.endsWith('@s')) return { amount: trimmed.slice(0, -2), unit: 's' };
49
+ if (lower.endsWith('s')) return { amount: trimmed.slice(0, -1), unit: 's' };
50
+ if (lower.endsWith('px')) return { amount: trimmed.slice(0, -2), unit: '' };
51
+ return { amount: trimmed, unit: '' };
52
+ }
53
+
54
+ function composeSizeValue(amount: string, unit: SizeUnit): string | number {
55
+ const trimmed = amount.trim();
56
+ if (unit === '%') {
57
+ return `${trimmed}%`;
58
+ }
59
+ if (unit === '') {
60
+ const numeric = Number(trimmed);
61
+ return Number.isFinite(numeric) ? numeric : trimmed;
62
+ }
63
+ if (unit === 'f') {
64
+ return `${trimmed}@fs`;
65
+ }
66
+ return `${trimmed}@${unit}`;
67
+ }
68
+
69
+ function normalizePreferredScale(
70
+ preferredScale: PreferredScale | undefined,
71
+ fieldName: string,
72
+ ): SizeUnit {
73
+ const fallbackName = fieldName.trim().toLowerCase();
74
+ const fallback: SizeUnit =
75
+ fallbackName.includes('height') ||
76
+ fallbackName.includes('top') ||
77
+ fallbackName.includes('vertical')
78
+ ? 'vs'
79
+ : 's';
80
+ return preferredScale ?? fallback;
81
+ }
82
+
83
+ export function SizeField({
84
+ value,
85
+ onChange,
86
+ preferredScale,
87
+ fieldName,
88
+ }: SizeFieldProps) {
89
+ const parsed = useMemo(() => parseSizeValue(value), [value]);
90
+ const normalizedPreferred = useMemo(
91
+ () => normalizePreferredScale(preferredScale, fieldName),
92
+ [preferredScale, fieldName],
93
+ );
94
+
95
+ const [amount, setAmount] = useState(parsed.amount);
96
+ const [unit, setUnit] = useState<SizeSelectUnit>(() => {
97
+ // Default to "auto" when the stored unit is already the preferred one (or empty)
98
+ // so that "auto" means "use preferredScale" without changing behavior.
99
+ return parsed.unit && parsed.unit !== normalizedPreferred
100
+ ? parsed.unit
101
+ : 'auto';
102
+ });
103
+
104
+ useEffect(() => {
105
+ setAmount(parsed.amount);
106
+
107
+ // Keep "auto" selected whenever value is preferred (or unscaled).
108
+ const nextUnit: SizeSelectUnit =
109
+ parsed.unit && parsed.unit !== normalizedPreferred ? parsed.unit : 'auto';
110
+ setUnit(nextUnit);
111
+ }, [parsed.amount, parsed.unit, normalizedPreferred]);
112
+
113
+ const resolveUnit = useCallback(
114
+ (nextUnit: SizeSelectUnit): SizeUnit =>
115
+ nextUnit === 'auto' ? normalizedPreferred : nextUnit,
116
+ [normalizedPreferred],
117
+ );
118
+
119
+ const emitValue = useCallback(
120
+ (nextAmount: string, nextUnit: SizeSelectUnit) => {
121
+ const trimmed = nextAmount.trim();
122
+ if (!trimmed) {
123
+ onChange(undefined);
124
+ return;
125
+ }
126
+ // If "auto" is selected, persist the preferredScale.
127
+ onChange(composeSizeValue(trimmed, resolveUnit(nextUnit)));
128
+ },
129
+ [onChange, resolveUnit],
130
+ );
131
+
132
+ const handleAmountChange = (e: React.ChangeEvent<HTMLInputElement>) => {
133
+ const nextAmount = e.target.value;
134
+ setAmount(nextAmount);
135
+ if (!nextAmount.trim()) {
136
+ onChange(undefined);
137
+ return;
138
+ }
139
+ emitValue(nextAmount, unit);
140
+ };
141
+
142
+ const handleUnitChange = (e: React.ChangeEvent<HTMLSelectElement>) => {
143
+ const nextUnit = e.target.value as SizeSelectUnit;
144
+ setUnit(nextUnit);
145
+ if (!amount.trim()) return;
146
+ emitValue(amount, nextUnit);
147
+ };
148
+
149
+ const unitPriority: SizeUnit[] = ['vs', 's', 'f', '%'];
150
+ const orderedUnits = [
151
+ normalizedPreferred,
152
+ ...unitPriority.filter((u) => u !== normalizedPreferred),
153
+ ];
154
+
155
+ const unitOptions: Array<{ value: SizeSelectUnit; label: string }> = [
156
+ { value: 'auto', label: 'auto' },
157
+ ...orderedUnits.map((u) => ({
158
+ value: u,
159
+ label: u === normalizedPreferred ? `${u} (preferred)` : u,
160
+ })),
161
+ ];
162
+
163
+ return (
164
+ <div className="attributes-editor__size-field">
165
+ <input
166
+ type="number"
167
+ className="input attributes-editor__size-field-input"
168
+ value={amount}
169
+ onChange={handleAmountChange}
170
+ />
171
+ <select
172
+ className="input attributes-editor__size-field-select"
173
+ value={unit}
174
+ onChange={handleUnitChange}
175
+ >
176
+ {unitOptions.map((opt) => (
177
+ <option key={opt.value} value={opt.value}>
178
+ {opt.label}
179
+ </option>
180
+ ))}
181
+ </select>
182
+ </div>
183
+ );
184
+ }
@@ -1,7 +1,9 @@
1
1
  import React, { useState } from 'react';
2
2
  import { NodeDefaultAttribute } from '../types/Node';
3
+ import type { ProjectColors } from '../types/Project';
3
4
  import { Field } from './Field';
4
5
  import { FieldInfoTooltip } from './FieldInfoTooltip';
6
+ import { toPreferredScale } from './SizeField';
5
7
  import {
6
8
  AttributeMetaMap,
7
9
  LayoutContext,
@@ -16,7 +18,7 @@ type SpecialCategorySectionProps = {
16
18
  attributes: NodeDefaultAttribute;
17
19
  onAttributeChange: (name: string, value: unknown) => void;
18
20
  componentType?: string;
19
- projectColors?: string[];
21
+ projectColors?: ProjectColors;
20
22
  layoutContext?: LayoutContext;
21
23
  viewAttributes?: NodeDefaultAttribute;
22
24
  meta?: {
@@ -91,7 +93,9 @@ export function SpecialCategorySection({
91
93
  {entries.map(({ name, type }) => {
92
94
  const label = attributeMeta?.[name]?.label ?? name;
93
95
  const description = attributeMeta?.[name]?.description;
94
- const preferredScale = attributeMeta?.[name]?.preferedScale;
96
+ const preferredScale = toPreferredScale(
97
+ attributeMeta?.[name]?.preferedScale,
98
+ );
95
99
  const currentValue = (attributes as Record<string, unknown>)[name];
96
100
  const isBoolean = isBooleanFieldType(type);
97
101
  const fieldSlot = detectFieldSlot(name);
@@ -168,7 +172,9 @@ export function SpecialCategorySection({
168
172
  {boxEntries.map(({ name, type }) => {
169
173
  const label = attributeMeta?.[name]?.label ?? name;
170
174
  const description = attributeMeta?.[name]?.description;
171
- const preferredScale = attributeMeta?.[name]?.preferedScale;
175
+ const preferredScale = toPreferredScale(
176
+ attributeMeta?.[name]?.preferedScale,
177
+ );
172
178
  const currentValue = (attributes as Record<string, unknown>)[name];
173
179
  const isBoolean = isBooleanFieldType(type);
174
180
  const fieldSlot = detectFieldSlot(name);
@@ -211,7 +217,9 @@ export function SpecialCategorySection({
211
217
  {baseEntries.map(({ name, type }) => {
212
218
  const label = attributeMeta?.[name]?.label ?? name;
213
219
  const description = attributeMeta?.[name]?.description;
214
- const preferredScale = attributeMeta?.[name]?.preferedScale;
220
+ const preferredScale = toPreferredScale(
221
+ attributeMeta?.[name]?.preferedScale,
222
+ );
215
223
  const currentValue = (attributes as Record<string, unknown>)[name];
216
224
  const isBoolean = isBooleanFieldType(type);
217
225
  const wrapperClassNames = [
@@ -0,0 +1,77 @@
1
+ import React, { useId, useMemo } from 'react';
2
+ import type { BackgroundImageComponentProps } from './BackgroundImageProps.generated';
3
+ import useNode from '../useNode';
4
+ import { useRenderStore } from '../../store';
5
+ import { extractViewStyle } from '../../utils/extractViewStyle';
6
+ import { useLogRender } from '../../utils/useLogRender';
7
+ import { isNodeSelected, SELECTED_OUTLINE_STYLE } from '../../utils/selection';
8
+ import { useMergedStyle } from '../../utils/useMergedStyle';
9
+
10
+ function BackgroundImage({ node }: BackgroundImageComponentProps) {
11
+ useLogRender('BackgroundImage');
12
+ node = useNode(node);
13
+ const generatedId = useId();
14
+ const attributeName =
15
+ (node as any)?.sourceType ?? node.type ?? 'background-image';
16
+ const attributeKey = node.key ?? generatedId;
17
+
18
+ const { previewMode, current, appConfig, projectColors } = useRenderStore(
19
+ (s) => ({
20
+ previewMode: s.previewMode,
21
+ current: s.current,
22
+ appConfig: s.appConfig,
23
+ projectColors: s.projectColors,
24
+ }),
25
+ );
26
+
27
+ const baseStyle = useMemo(
28
+ () => extractViewStyle(node, { appConfig, projectColors }),
29
+ [node, appConfig, projectColors],
30
+ );
31
+ const backgroundStyle = useMemo(() => {
32
+ const attrs = node.attributes;
33
+ const style: React.CSSProperties = {
34
+ backgroundRepeat: 'no-repeat',
35
+ backgroundPosition: 'center',
36
+ };
37
+
38
+ if (attrs?.src) {
39
+ style.backgroundImage = `url(${attrs.src})`;
40
+ }
41
+
42
+ switch (attrs?.resizeMode) {
43
+ case 'cover':
44
+ style.backgroundSize = 'cover';
45
+ break;
46
+ case 'contain':
47
+ style.backgroundSize = 'contain';
48
+ break;
49
+ case 'stretch':
50
+ style.backgroundSize = '100% 100%';
51
+ break;
52
+ case 'center':
53
+ style.backgroundSize = 'auto';
54
+ break;
55
+ default:
56
+ style.backgroundSize = 'cover';
57
+ }
58
+
59
+ return style;
60
+ }, [node]);
61
+ const isSelected = isNodeSelected({ previewMode, current, node });
62
+ const mergedStyle = useMergedStyle(baseStyle, backgroundStyle);
63
+ const style = useMergedStyle(
64
+ mergedStyle,
65
+ isSelected ? SELECTED_OUTLINE_STYLE : undefined,
66
+ );
67
+
68
+ return (
69
+ <div
70
+ attribute-name={attributeName}
71
+ attribute-key={attributeKey}
72
+ style={style}
73
+ />
74
+ );
75
+ }
76
+
77
+ export default React.memo(BackgroundImage);
@@ -0,0 +1,61 @@
1
+ /* AUTO-GENERATED FILE - DO NOT EDIT */
2
+
3
+ import type { NodeData } from '../../types/Node';
4
+
5
+ export type FlexDirectionOptionType = 'row' | 'column';
6
+ export type AlignItemsOptionType =
7
+ | 'flex-start'
8
+ | 'center'
9
+ | 'flex-end'
10
+ | 'stretch'
11
+ | 'baseline';
12
+ export type JustifyContentOptionType =
13
+ | 'flex-start'
14
+ | 'center'
15
+ | 'flex-end'
16
+ | 'space-between'
17
+ | 'space-around'
18
+ | 'space-evenly';
19
+ export type PositionOptionType = 'relative' | 'absolute';
20
+ export type ResizeModeOptionType = 'cover' | 'contain' | 'stretch' | 'center';
21
+
22
+ export interface BackgroundImagePropsGenerated {
23
+ child: string;
24
+ attributes: {
25
+ scrollable?: boolean;
26
+ flexDirection?: FlexDirectionOptionType;
27
+ alignItems?: AlignItemsOptionType;
28
+ justifyContent?: JustifyContentOptionType;
29
+ gap?: string;
30
+ padding?: string;
31
+ paddingHorizontal?: string;
32
+ paddingVertical?: string;
33
+ paddingTop?: string;
34
+ paddingBottom?: string;
35
+ paddingLeft?: string;
36
+ paddingRight?: string;
37
+ margin?: string;
38
+ marginVertical?: string;
39
+ marginTop?: string;
40
+ marginBottom?: string;
41
+ marginLeft?: string;
42
+ marginRight?: string;
43
+ backgroundColor?: string;
44
+ borderRadius?: string;
45
+ width?: string;
46
+ height?: string;
47
+ flex?: number;
48
+ position?: PositionOptionType;
49
+ top?: string;
50
+ bottom?: string;
51
+ left?: string;
52
+ right?: string;
53
+ zIndex?: number;
54
+ src?: string;
55
+ resizeMode?: ResizeModeOptionType;
56
+ };
57
+ }
58
+
59
+ export interface BackgroundImageComponentProps {
60
+ node: NodeData<BackgroundImagePropsGenerated['attributes']>;
61
+ }
@@ -0,0 +1,45 @@
1
+ {
2
+ "schemaVersion": 1,
3
+ "allowUnknownAttributes": false,
4
+ "pattern": {
5
+ "type": "background-image",
6
+ "children": "never",
7
+ "extends": "View",
8
+ "attributes": {
9
+ "src": "string",
10
+ "resizeMode": ["cover", "contain", "stretch", "center"]
11
+ },
12
+ "defaults": {
13
+ "resizeMode": "cover",
14
+ "width": "100%",
15
+ "height": "100%",
16
+ "position": "fixed",
17
+ "top": 0,
18
+ "left": 0,
19
+ "right": 0,
20
+ "bottom": 0,
21
+ "zIndex": 0
22
+ }
23
+ },
24
+ "meta": {
25
+ "desiredParent": ["all", "background"],
26
+ "label": "Background Image",
27
+ "description": "Background image.",
28
+ "attributes": {
29
+ "src": {
30
+ "label": "Src",
31
+ "description": "Image source URL.",
32
+ "category": "other",
33
+ "specialCategory": null,
34
+ "sort": 1
35
+ },
36
+ "resizeMode": {
37
+ "label": "Resize Mode",
38
+ "description": "How the image fits its container.",
39
+ "category": "style",
40
+ "specialCategory": null,
41
+ "sort": 4
42
+ }
43
+ }
44
+ }
45
+ }