@aprovan/bobbin 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.turbo/turbo-build.log +16 -0
- package/LICENSE +373 -0
- package/dist/index.d.ts +402 -0
- package/dist/index.js +3704 -0
- package/package.json +30 -0
- package/src/Bobbin.tsx +89 -0
- package/src/components/EditPanel/EditPanel.tsx +376 -0
- package/src/components/EditPanel/controls/ColorPicker.tsx +138 -0
- package/src/components/EditPanel/controls/QuickSelectDropdown.tsx +142 -0
- package/src/components/EditPanel/controls/SliderInput.tsx +94 -0
- package/src/components/EditPanel/controls/SpacingControl.tsx +285 -0
- package/src/components/EditPanel/controls/ToggleGroup.tsx +37 -0
- package/src/components/EditPanel/controls/TokenDropdown.tsx +33 -0
- package/src/components/EditPanel/sections/AnnotationSection.tsx +136 -0
- package/src/components/EditPanel/sections/BackgroundSection.tsx +79 -0
- package/src/components/EditPanel/sections/EffectsSection.tsx +85 -0
- package/src/components/EditPanel/sections/LayoutSection.tsx +224 -0
- package/src/components/EditPanel/sections/SectionWrapper.tsx +57 -0
- package/src/components/EditPanel/sections/SizeSection.tsx +166 -0
- package/src/components/EditPanel/sections/SpacingSection.tsx +69 -0
- package/src/components/EditPanel/sections/TypographySection.tsx +148 -0
- package/src/components/Inspector/Inspector.tsx +221 -0
- package/src/components/Overlay/ControlHandles.tsx +572 -0
- package/src/components/Overlay/MarginPaddingOverlay.tsx +229 -0
- package/src/components/Overlay/SelectionOverlay.tsx +73 -0
- package/src/components/Pill/Pill.tsx +155 -0
- package/src/components/ThemeToggle/ThemeToggle.tsx +72 -0
- package/src/core/changeSerializer.ts +139 -0
- package/src/core/useBobbin.ts +399 -0
- package/src/core/useChangeTracker.ts +186 -0
- package/src/core/useClipboard.ts +21 -0
- package/src/core/useElementSelection.ts +146 -0
- package/src/index.ts +46 -0
- package/src/tokens/borders.ts +19 -0
- package/src/tokens/colors.ts +150 -0
- package/src/tokens/index.ts +37 -0
- package/src/tokens/shadows.ts +10 -0
- package/src/tokens/spacing.ts +37 -0
- package/src/tokens/typography.ts +51 -0
- package/src/types.ts +157 -0
- package/src/utils/animation.ts +40 -0
- package/src/utils/dom.ts +36 -0
- package/src/utils/selectors.ts +76 -0
- package/tsconfig.json +10 -0
- package/tsup.config.ts +10 -0
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
import { useState, useMemo } from 'react';
|
|
2
|
+
|
|
3
|
+
interface QuickSelectDropdownProps {
|
|
4
|
+
value: string;
|
|
5
|
+
tokens: Record<string, string>;
|
|
6
|
+
quickKeys: string[]; // Keys to show as quick buttons
|
|
7
|
+
onChange: (value: string) => void;
|
|
8
|
+
placeholder?: string;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function QuickSelectDropdown({
|
|
12
|
+
value,
|
|
13
|
+
tokens,
|
|
14
|
+
quickKeys,
|
|
15
|
+
onChange,
|
|
16
|
+
placeholder = 'More...',
|
|
17
|
+
}: QuickSelectDropdownProps) {
|
|
18
|
+
const [showDropdown, setShowDropdown] = useState(false);
|
|
19
|
+
|
|
20
|
+
// Split tokens into quick buttons and dropdown items
|
|
21
|
+
const { quickItems, dropdownItems } = useMemo(() => {
|
|
22
|
+
const quick: Array<{ key: string; value: string }> = [];
|
|
23
|
+
const dropdown: Array<{ key: string; value: string }> = [];
|
|
24
|
+
|
|
25
|
+
for (const [key, tokenValue] of Object.entries(tokens)) {
|
|
26
|
+
if (quickKeys.includes(key)) {
|
|
27
|
+
quick.push({ key, value: tokenValue });
|
|
28
|
+
} else {
|
|
29
|
+
dropdown.push({ key, value: tokenValue });
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// Sort quick items by quickKeys order
|
|
34
|
+
quick.sort((a, b) => quickKeys.indexOf(a.key) - quickKeys.indexOf(b.key));
|
|
35
|
+
|
|
36
|
+
return { quickItems: quick, dropdownItems: dropdown };
|
|
37
|
+
}, [tokens, quickKeys]);
|
|
38
|
+
|
|
39
|
+
// Check if current value matches a token
|
|
40
|
+
const isSelected = (tokenValue: string) => {
|
|
41
|
+
// Normalize values for comparison
|
|
42
|
+
const normalizeValue = (v: string) => v.replace(/\s+/g, '').toLowerCase();
|
|
43
|
+
return normalizeValue(value) === normalizeValue(tokenValue);
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
return (
|
|
47
|
+
<div style={{ display: 'flex', alignItems: 'center', gap: '3px', flexWrap: 'wrap' }}>
|
|
48
|
+
{/* Quick select buttons */}
|
|
49
|
+
{quickItems.map(({ key, value: tokenValue }) => (
|
|
50
|
+
<button
|
|
51
|
+
key={key}
|
|
52
|
+
onClick={() => onChange(tokenValue)}
|
|
53
|
+
style={{
|
|
54
|
+
padding: '3px 6px',
|
|
55
|
+
borderRadius: '3px',
|
|
56
|
+
border: '1px solid',
|
|
57
|
+
borderColor: isSelected(tokenValue) ? '#18181b' : '#e4e4e7',
|
|
58
|
+
backgroundColor: isSelected(tokenValue) ? '#18181b' : '#ffffff',
|
|
59
|
+
color: isSelected(tokenValue) ? '#fafafa' : '#18181b',
|
|
60
|
+
fontSize: '10px',
|
|
61
|
+
fontFamily: 'ui-monospace, monospace',
|
|
62
|
+
cursor: 'pointer',
|
|
63
|
+
transition: 'all 0.1s ease',
|
|
64
|
+
minWidth: '28px',
|
|
65
|
+
textAlign: 'center',
|
|
66
|
+
}}
|
|
67
|
+
title={`${key}: ${tokenValue}`}
|
|
68
|
+
>
|
|
69
|
+
{key}
|
|
70
|
+
</button>
|
|
71
|
+
))}
|
|
72
|
+
|
|
73
|
+
{/* Dropdown for remaining options */}
|
|
74
|
+
{dropdownItems.length > 0 && (
|
|
75
|
+
<div style={{ position: 'relative' }}>
|
|
76
|
+
<button
|
|
77
|
+
onClick={() => setShowDropdown(!showDropdown)}
|
|
78
|
+
onBlur={() => setTimeout(() => setShowDropdown(false), 150)}
|
|
79
|
+
style={{
|
|
80
|
+
padding: '3px 6px',
|
|
81
|
+
borderRadius: '3px',
|
|
82
|
+
border: '1px solid #e4e4e7',
|
|
83
|
+
backgroundColor: '#ffffff',
|
|
84
|
+
color: '#71717a',
|
|
85
|
+
fontSize: '10px',
|
|
86
|
+
cursor: 'pointer',
|
|
87
|
+
display: 'flex',
|
|
88
|
+
alignItems: 'center',
|
|
89
|
+
gap: '2px',
|
|
90
|
+
}}
|
|
91
|
+
>
|
|
92
|
+
<span>...</span>
|
|
93
|
+
</button>
|
|
94
|
+
|
|
95
|
+
{showDropdown && (
|
|
96
|
+
<div
|
|
97
|
+
style={{
|
|
98
|
+
position: 'absolute',
|
|
99
|
+
top: '100%',
|
|
100
|
+
left: 0,
|
|
101
|
+
marginTop: '2px',
|
|
102
|
+
backgroundColor: '#ffffff',
|
|
103
|
+
border: '1px solid #e4e4e7',
|
|
104
|
+
borderRadius: '4px',
|
|
105
|
+
boxShadow: '0 4px 6px -1px rgb(0 0 0 / 0.1)',
|
|
106
|
+
zIndex: 10,
|
|
107
|
+
maxHeight: '150px',
|
|
108
|
+
overflow: 'auto',
|
|
109
|
+
minWidth: '100px',
|
|
110
|
+
}}
|
|
111
|
+
>
|
|
112
|
+
{dropdownItems.map(({ key, value: tokenValue }) => (
|
|
113
|
+
<button
|
|
114
|
+
key={key}
|
|
115
|
+
onClick={() => {
|
|
116
|
+
onChange(tokenValue);
|
|
117
|
+
setShowDropdown(false);
|
|
118
|
+
}}
|
|
119
|
+
style={{
|
|
120
|
+
display: 'block',
|
|
121
|
+
width: '100%',
|
|
122
|
+
padding: '4px 8px',
|
|
123
|
+
border: 'none',
|
|
124
|
+
backgroundColor: isSelected(tokenValue) ? '#f4f4f5' : 'transparent',
|
|
125
|
+
color: '#18181b',
|
|
126
|
+
fontSize: '10px',
|
|
127
|
+
fontFamily: 'ui-monospace, monospace',
|
|
128
|
+
cursor: 'pointer',
|
|
129
|
+
textAlign: 'left',
|
|
130
|
+
}}
|
|
131
|
+
>
|
|
132
|
+
<span style={{ fontWeight: 500 }}>{key}</span>
|
|
133
|
+
<span style={{ color: '#71717a', marginLeft: '4px' }}>{tokenValue}</span>
|
|
134
|
+
</button>
|
|
135
|
+
))}
|
|
136
|
+
</div>
|
|
137
|
+
)}
|
|
138
|
+
</div>
|
|
139
|
+
)}
|
|
140
|
+
</div>
|
|
141
|
+
);
|
|
142
|
+
}
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import { useState, useCallback } from 'react';
|
|
2
|
+
|
|
3
|
+
interface SliderInputProps {
|
|
4
|
+
value: number;
|
|
5
|
+
min?: number;
|
|
6
|
+
max?: number;
|
|
7
|
+
step?: number;
|
|
8
|
+
unit?: string;
|
|
9
|
+
onChange: (value: number) => void;
|
|
10
|
+
label?: string;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function SliderInput({
|
|
14
|
+
value,
|
|
15
|
+
min = 0,
|
|
16
|
+
max = 100,
|
|
17
|
+
step = 1,
|
|
18
|
+
unit = 'px',
|
|
19
|
+
onChange,
|
|
20
|
+
label,
|
|
21
|
+
}: SliderInputProps) {
|
|
22
|
+
const [isDragging, setIsDragging] = useState(false);
|
|
23
|
+
const [localValue, setLocalValue] = useState(value);
|
|
24
|
+
|
|
25
|
+
const handleMouseDown = useCallback((e: React.MouseEvent) => {
|
|
26
|
+
e.preventDefault();
|
|
27
|
+
setIsDragging(true);
|
|
28
|
+
const startX = e.clientX;
|
|
29
|
+
const startValue = localValue;
|
|
30
|
+
|
|
31
|
+
const handleMouseMove = (e: MouseEvent) => {
|
|
32
|
+
const delta = e.clientX - startX;
|
|
33
|
+
const sensitivity = e.shiftKey ? 0.1 : 1; // Shift for fine control
|
|
34
|
+
const newValue = Math.min(max, Math.max(min, startValue + delta * sensitivity));
|
|
35
|
+
const steppedValue = Math.round(newValue / step) * step;
|
|
36
|
+
setLocalValue(steppedValue);
|
|
37
|
+
onChange(steppedValue);
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
const handleMouseUp = () => {
|
|
41
|
+
setIsDragging(false);
|
|
42
|
+
document.removeEventListener('mousemove', handleMouseMove);
|
|
43
|
+
document.removeEventListener('mouseup', handleMouseUp);
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
document.addEventListener('mousemove', handleMouseMove);
|
|
47
|
+
document.addEventListener('mouseup', handleMouseUp);
|
|
48
|
+
}, [localValue, min, max, step, onChange]);
|
|
49
|
+
|
|
50
|
+
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
|
51
|
+
const newValue = parseFloat(e.target.value) || 0;
|
|
52
|
+
setLocalValue(newValue);
|
|
53
|
+
onChange(newValue);
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
return (
|
|
57
|
+
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
|
|
58
|
+
{label && (
|
|
59
|
+
<span style={{ fontSize: '10px', color: '#71717a', width: '18px' }}>{label}</span>
|
|
60
|
+
)}
|
|
61
|
+
<div
|
|
62
|
+
style={{
|
|
63
|
+
flex: 1,
|
|
64
|
+
display: 'flex',
|
|
65
|
+
alignItems: 'center',
|
|
66
|
+
backgroundColor: '#ffffff',
|
|
67
|
+
border: '1px solid #e4e4e7',
|
|
68
|
+
borderRadius: '4px',
|
|
69
|
+
padding: '3px 6px',
|
|
70
|
+
cursor: 'ew-resize',
|
|
71
|
+
}}
|
|
72
|
+
onMouseDown={handleMouseDown}
|
|
73
|
+
>
|
|
74
|
+
<input
|
|
75
|
+
type="number"
|
|
76
|
+
value={localValue}
|
|
77
|
+
onChange={handleInputChange}
|
|
78
|
+
style={{
|
|
79
|
+
width: '100%',
|
|
80
|
+
backgroundColor: 'transparent',
|
|
81
|
+
border: 'none',
|
|
82
|
+
color: '#18181b',
|
|
83
|
+
fontSize: '11px',
|
|
84
|
+
fontFamily: 'ui-monospace, monospace',
|
|
85
|
+
outline: 'none',
|
|
86
|
+
cursor: isDragging ? 'ew-resize' : 'text',
|
|
87
|
+
}}
|
|
88
|
+
onClick={(e) => e.stopPropagation()}
|
|
89
|
+
/>
|
|
90
|
+
<span style={{ fontSize: '10px', color: '#a1a1aa', marginLeft: '4px' }}>{unit}</span>
|
|
91
|
+
</div>
|
|
92
|
+
</div>
|
|
93
|
+
);
|
|
94
|
+
}
|
|
@@ -0,0 +1,285 @@
|
|
|
1
|
+
import { useState, useCallback, useRef, useEffect } from 'react';
|
|
2
|
+
|
|
3
|
+
type Side = 'top' | 'right' | 'bottom' | 'left';
|
|
4
|
+
|
|
5
|
+
interface SpacingControlProps {
|
|
6
|
+
values: { top: string; right: string; bottom: string; left: string };
|
|
7
|
+
onChange: (side: Side, value: string) => void;
|
|
8
|
+
label: string;
|
|
9
|
+
hasChanges?: { top?: boolean; right?: boolean; bottom?: boolean; left?: boolean };
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
// Chain link icon (for linked sides)
|
|
13
|
+
const ChainIcon = ({ linked }: { linked: boolean }) => (
|
|
14
|
+
<svg
|
|
15
|
+
width="12"
|
|
16
|
+
height="12"
|
|
17
|
+
viewBox="0 0 24 24"
|
|
18
|
+
fill="none"
|
|
19
|
+
stroke="currentColor"
|
|
20
|
+
strokeWidth="2"
|
|
21
|
+
strokeLinecap="round"
|
|
22
|
+
strokeLinejoin="round"
|
|
23
|
+
style={{ opacity: linked ? 1 : 0.4 }}
|
|
24
|
+
>
|
|
25
|
+
{linked ? (
|
|
26
|
+
// Linked chain
|
|
27
|
+
<>
|
|
28
|
+
<path d="M9 17H7A5 5 0 0 1 7 7h2" />
|
|
29
|
+
<path d="M15 7h2a5 5 0 1 1 0 10h-2" />
|
|
30
|
+
<line x1="8" y1="12" x2="16" y2="12" />
|
|
31
|
+
</>
|
|
32
|
+
) : (
|
|
33
|
+
// Broken chain
|
|
34
|
+
<>
|
|
35
|
+
<path d="M9 17H7A5 5 0 0 1 7 7h2" />
|
|
36
|
+
<path d="M15 7h2a5 5 0 1 1 0 10h-2" />
|
|
37
|
+
<line x1="8" y1="12" x2="10" y2="12" />
|
|
38
|
+
<line x1="14" y1="12" x2="16" y2="12" />
|
|
39
|
+
</>
|
|
40
|
+
)}
|
|
41
|
+
</svg>
|
|
42
|
+
);
|
|
43
|
+
|
|
44
|
+
// Check if a CSS value is valid
|
|
45
|
+
function isValidCSSValue(value: string): boolean {
|
|
46
|
+
if (!value || value.trim() === '') return true; // Empty is valid (will revert to default)
|
|
47
|
+
|
|
48
|
+
// Common valid patterns
|
|
49
|
+
const validPatterns = [
|
|
50
|
+
/^-?\d+(\.\d+)?(px|em|rem|%|vh|vw|pt|cm|mm|in|pc)?$/i,
|
|
51
|
+
/^auto$/i,
|
|
52
|
+
/^inherit$/i,
|
|
53
|
+
/^initial$/i,
|
|
54
|
+
/^unset$/i,
|
|
55
|
+
/^0$/,
|
|
56
|
+
];
|
|
57
|
+
|
|
58
|
+
return validPatterns.some(pattern => pattern.test(value.trim()));
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function SpacingControl({ values, onChange, label, hasChanges = {} }: SpacingControlProps) {
|
|
62
|
+
const [editingSide, setEditingSide] = useState<Side | null>(null);
|
|
63
|
+
const [editValue, setEditValue] = useState('');
|
|
64
|
+
const [linkVertical, setLinkVertical] = useState(false);
|
|
65
|
+
const [linkHorizontal, setLinkHorizontal] = useState(false);
|
|
66
|
+
const [highlightLinked, setHighlightLinked] = useState<'vertical' | 'horizontal' | null>(null);
|
|
67
|
+
const inputRef = useRef<HTMLInputElement>(null);
|
|
68
|
+
|
|
69
|
+
// Focus input when editing starts
|
|
70
|
+
useEffect(() => {
|
|
71
|
+
if (editingSide && inputRef.current) {
|
|
72
|
+
inputRef.current.focus();
|
|
73
|
+
inputRef.current.select();
|
|
74
|
+
}
|
|
75
|
+
}, [editingSide]);
|
|
76
|
+
|
|
77
|
+
const handleChange = useCallback((side: Side, value: string) => {
|
|
78
|
+
onChange(side, value);
|
|
79
|
+
|
|
80
|
+
// Apply linked changes
|
|
81
|
+
if (linkVertical && (side === 'top' || side === 'bottom')) {
|
|
82
|
+
onChange(side === 'top' ? 'bottom' : 'top', value);
|
|
83
|
+
}
|
|
84
|
+
if (linkHorizontal && (side === 'left' || side === 'right')) {
|
|
85
|
+
onChange(side === 'left' ? 'right' : 'left', value);
|
|
86
|
+
}
|
|
87
|
+
}, [onChange, linkVertical, linkHorizontal]);
|
|
88
|
+
|
|
89
|
+
const handleBoxClick = (side: Side, e: React.MouseEvent) => {
|
|
90
|
+
e.stopPropagation();
|
|
91
|
+
setEditingSide(side);
|
|
92
|
+
setEditValue(values[side]);
|
|
93
|
+
|
|
94
|
+
// Highlight linked sides
|
|
95
|
+
if (linkVertical && (side === 'top' || side === 'bottom')) {
|
|
96
|
+
setHighlightLinked('vertical');
|
|
97
|
+
} else if (linkHorizontal && (side === 'left' || side === 'right')) {
|
|
98
|
+
setHighlightLinked('horizontal');
|
|
99
|
+
}
|
|
100
|
+
};
|
|
101
|
+
|
|
102
|
+
const handleInputChange = (value: string) => {
|
|
103
|
+
setEditValue(value);
|
|
104
|
+
// Apply changes even if temporarily invalid (user is typing)
|
|
105
|
+
if (editingSide) {
|
|
106
|
+
handleChange(editingSide, value);
|
|
107
|
+
}
|
|
108
|
+
};
|
|
109
|
+
|
|
110
|
+
const handleInputBlur = () => {
|
|
111
|
+
setEditingSide(null);
|
|
112
|
+
setHighlightLinked(null);
|
|
113
|
+
};
|
|
114
|
+
|
|
115
|
+
const handleKeyDown = (e: React.KeyboardEvent) => {
|
|
116
|
+
if (e.key === 'Enter' || e.key === 'Escape') {
|
|
117
|
+
setEditingSide(null);
|
|
118
|
+
setHighlightLinked(null);
|
|
119
|
+
}
|
|
120
|
+
};
|
|
121
|
+
|
|
122
|
+
const formatValue = (value: string) => {
|
|
123
|
+
// Extract just the numeric value for compact display
|
|
124
|
+
const match = value.match(/^([\d.]+)/);
|
|
125
|
+
return match ? match[1] : value || '0';
|
|
126
|
+
};
|
|
127
|
+
|
|
128
|
+
const isLinkedHighlighted = (side: Side): boolean => {
|
|
129
|
+
if (!highlightLinked) return false;
|
|
130
|
+
if (highlightLinked === 'vertical' && (side === 'top' || side === 'bottom')) return true;
|
|
131
|
+
if (highlightLinked === 'horizontal' && (side === 'left' || side === 'right')) return true;
|
|
132
|
+
return false;
|
|
133
|
+
};
|
|
134
|
+
|
|
135
|
+
const getBoxStyle = (side: Side): React.CSSProperties => {
|
|
136
|
+
const isEditing = editingSide === side;
|
|
137
|
+
const isLinked = isLinkedHighlighted(side);
|
|
138
|
+
const isValid = isEditing ? isValidCSSValue(editValue) : true;
|
|
139
|
+
const hasChange = hasChanges[side];
|
|
140
|
+
|
|
141
|
+
return {
|
|
142
|
+
padding: isEditing ? '0' : '2px 4px',
|
|
143
|
+
borderRadius: '3px',
|
|
144
|
+
border: `1px solid ${
|
|
145
|
+
!isValid ? '#ef4444' :
|
|
146
|
+
hasChange ? '#3b82f6' :
|
|
147
|
+
isLinked ? '#8b5cf6' :
|
|
148
|
+
isEditing ? '#18181b' :
|
|
149
|
+
'#e4e4e7'
|
|
150
|
+
}`,
|
|
151
|
+
backgroundColor:
|
|
152
|
+
!isValid ? '#fef2f2' :
|
|
153
|
+
hasChange ? '#eff6ff' :
|
|
154
|
+
isLinked ? '#f5f3ff' :
|
|
155
|
+
'#ffffff',
|
|
156
|
+
color: '#18181b',
|
|
157
|
+
fontSize: '9px',
|
|
158
|
+
textAlign: 'center',
|
|
159
|
+
cursor: 'text',
|
|
160
|
+
minWidth: '28px',
|
|
161
|
+
transition: 'all 0.1s ease',
|
|
162
|
+
outline: 'none',
|
|
163
|
+
};
|
|
164
|
+
};
|
|
165
|
+
|
|
166
|
+
const linkButtonStyle = (active: boolean): React.CSSProperties => ({
|
|
167
|
+
padding: '2px 4px',
|
|
168
|
+
borderRadius: '3px',
|
|
169
|
+
border: `1px solid ${active ? '#8b5cf6' : '#e4e4e7'}`,
|
|
170
|
+
backgroundColor: active ? '#f5f3ff' : '#ffffff',
|
|
171
|
+
color: active ? '#8b5cf6' : '#71717a',
|
|
172
|
+
cursor: 'pointer',
|
|
173
|
+
display: 'flex',
|
|
174
|
+
alignItems: 'center',
|
|
175
|
+
justifyContent: 'center',
|
|
176
|
+
transition: 'all 0.1s ease',
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
const renderValueBox = (side: Side, position: React.CSSProperties) => {
|
|
180
|
+
const isEditing = editingSide === side;
|
|
181
|
+
|
|
182
|
+
return (
|
|
183
|
+
<div style={{ position: 'absolute', ...position }}>
|
|
184
|
+
{isEditing ? (
|
|
185
|
+
<input
|
|
186
|
+
ref={inputRef}
|
|
187
|
+
type="text"
|
|
188
|
+
value={editValue}
|
|
189
|
+
onChange={(e) => handleInputChange(e.target.value)}
|
|
190
|
+
onBlur={handleInputBlur}
|
|
191
|
+
onKeyDown={handleKeyDown}
|
|
192
|
+
style={{
|
|
193
|
+
...getBoxStyle(side),
|
|
194
|
+
width: '36px',
|
|
195
|
+
fontFamily: 'inherit',
|
|
196
|
+
}}
|
|
197
|
+
/>
|
|
198
|
+
) : (
|
|
199
|
+
<div
|
|
200
|
+
onClick={(e) => handleBoxClick(side, e)}
|
|
201
|
+
style={getBoxStyle(side)}
|
|
202
|
+
title={`${side}: ${values[side]}`}
|
|
203
|
+
>
|
|
204
|
+
{formatValue(values[side])}
|
|
205
|
+
</div>
|
|
206
|
+
)}
|
|
207
|
+
</div>
|
|
208
|
+
);
|
|
209
|
+
};
|
|
210
|
+
|
|
211
|
+
return (
|
|
212
|
+
<div>
|
|
213
|
+
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: '6px' }}>
|
|
214
|
+
<label style={{ fontSize: '10px', color: '#71717a' }}>
|
|
215
|
+
{label}
|
|
216
|
+
</label>
|
|
217
|
+
<div style={{ display: 'flex', gap: '2px' }}>
|
|
218
|
+
<button
|
|
219
|
+
style={linkButtonStyle(linkVertical)}
|
|
220
|
+
onClick={() => setLinkVertical(!linkVertical)}
|
|
221
|
+
title={linkVertical ? 'Unlink top/bottom' : 'Link top/bottom'}
|
|
222
|
+
>
|
|
223
|
+
<span style={{ transform: 'rotate(90deg)', display: 'flex' }}>
|
|
224
|
+
<ChainIcon linked={linkVertical} />
|
|
225
|
+
</span>
|
|
226
|
+
</button>
|
|
227
|
+
<button
|
|
228
|
+
style={linkButtonStyle(linkHorizontal)}
|
|
229
|
+
onClick={() => setLinkHorizontal(!linkHorizontal)}
|
|
230
|
+
title={linkHorizontal ? 'Unlink left/right' : 'Link left/right'}
|
|
231
|
+
>
|
|
232
|
+
<ChainIcon linked={linkHorizontal} />
|
|
233
|
+
</button>
|
|
234
|
+
</div>
|
|
235
|
+
</div>
|
|
236
|
+
|
|
237
|
+
{/* Visual spacing box with outline square in center */}
|
|
238
|
+
<div
|
|
239
|
+
style={{
|
|
240
|
+
position: 'relative',
|
|
241
|
+
width: '100px',
|
|
242
|
+
height: '40px',
|
|
243
|
+
margin: '0 auto',
|
|
244
|
+
}}
|
|
245
|
+
>
|
|
246
|
+
{/* Outline square connecting all sides */}
|
|
247
|
+
<svg
|
|
248
|
+
style={{
|
|
249
|
+
position: 'absolute',
|
|
250
|
+
top: 0,
|
|
251
|
+
left: 0,
|
|
252
|
+
width: '100%',
|
|
253
|
+
height: '100%',
|
|
254
|
+
pointerEvents: 'none',
|
|
255
|
+
}}
|
|
256
|
+
viewBox="0 0 100 70"
|
|
257
|
+
>
|
|
258
|
+
{/* Outer rectangle outline */}
|
|
259
|
+
<rect
|
|
260
|
+
x="10"
|
|
261
|
+
y="10"
|
|
262
|
+
width="80"
|
|
263
|
+
height="50"
|
|
264
|
+
fill="none"
|
|
265
|
+
stroke="lightgray"
|
|
266
|
+
strokeWidth="1"
|
|
267
|
+
strokeDasharray="3,2"
|
|
268
|
+
rx="2"
|
|
269
|
+
/>
|
|
270
|
+
{/* Lines connecting to value boxes */}
|
|
271
|
+
<line x1="50" y1="10" x2="50" y2="2" stroke="lightgray" strokeWidth="1" />
|
|
272
|
+
<line x1="50" y1="60" x2="50" y2="68" stroke="lightgray" strokeWidth="1" />
|
|
273
|
+
<line x1="10" y1="35" x2="2" y2="35" stroke="lightgray" strokeWidth="1" />
|
|
274
|
+
<line x1="90" y1="35" x2="98" y2="35" stroke="lightgray" strokeWidth="1" />
|
|
275
|
+
</svg>
|
|
276
|
+
|
|
277
|
+
{/* Value boxes at each side */}
|
|
278
|
+
{renderValueBox('top', { top: '-8px', left: '50%', transform: 'translateX(-50%)' })}
|
|
279
|
+
{renderValueBox('bottom', { bottom: '-8px', left: '50%', transform: 'translateX(-50%)' })}
|
|
280
|
+
{renderValueBox('left', { left: '-12px', top: '50%', transform: 'translateY(-50%)' })}
|
|
281
|
+
{renderValueBox('right', { right: '-12px', top: '50%', transform: 'translateY(-50%)' })}
|
|
282
|
+
</div>
|
|
283
|
+
</div>
|
|
284
|
+
);
|
|
285
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import type { ReactNode } from 'react';
|
|
2
|
+
|
|
3
|
+
interface ToggleGroupProps {
|
|
4
|
+
value: string;
|
|
5
|
+
options: Array<{ value: string; label: ReactNode }>;
|
|
6
|
+
onChange: (value: string) => void;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export function ToggleGroup({ value, options, onChange }: ToggleGroupProps) {
|
|
10
|
+
return (
|
|
11
|
+
<div style={{ display: 'flex', gap: '2px' }}>
|
|
12
|
+
{options.map(option => (
|
|
13
|
+
<button
|
|
14
|
+
key={option.value}
|
|
15
|
+
onClick={() => onChange(option.value)}
|
|
16
|
+
style={{
|
|
17
|
+
flex: 1,
|
|
18
|
+
padding: '4px 6px',
|
|
19
|
+
borderRadius: '4px',
|
|
20
|
+
border: '1px solid #e4e4e7',
|
|
21
|
+
backgroundColor: value === option.value ? '#18181b' : '#ffffff',
|
|
22
|
+
color: value === option.value ? '#fafafa' : '#71717a',
|
|
23
|
+
cursor: 'pointer',
|
|
24
|
+
fontSize: '10px',
|
|
25
|
+
fontWeight: 500,
|
|
26
|
+
transition: 'all 0.1s ease',
|
|
27
|
+
display: 'flex',
|
|
28
|
+
alignItems: 'center',
|
|
29
|
+
justifyContent: 'center',
|
|
30
|
+
}}
|
|
31
|
+
>
|
|
32
|
+
{option.label}
|
|
33
|
+
</button>
|
|
34
|
+
))}
|
|
35
|
+
</div>
|
|
36
|
+
);
|
|
37
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
interface TokenDropdownProps {
|
|
2
|
+
value: string;
|
|
3
|
+
tokens: Record<string, string>;
|
|
4
|
+
onChange: (value: string) => void;
|
|
5
|
+
placeholder?: string;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export function TokenDropdown({ value, tokens, onChange, placeholder = 'Select...' }: TokenDropdownProps) {
|
|
9
|
+
return (
|
|
10
|
+
<select
|
|
11
|
+
value={value}
|
|
12
|
+
onChange={(e) => onChange(e.target.value)}
|
|
13
|
+
style={{
|
|
14
|
+
width: '100%',
|
|
15
|
+
padding: '4px 8px',
|
|
16
|
+
borderRadius: '4px',
|
|
17
|
+
border: '1px solid #e4e4e7',
|
|
18
|
+
backgroundColor: '#ffffff',
|
|
19
|
+
color: '#18181b',
|
|
20
|
+
fontSize: '11px',
|
|
21
|
+
cursor: 'pointer',
|
|
22
|
+
outline: 'none',
|
|
23
|
+
}}
|
|
24
|
+
>
|
|
25
|
+
<option value="">{placeholder}</option>
|
|
26
|
+
{Object.entries(tokens).map(([key, tokenValue]) => (
|
|
27
|
+
<option key={key} value={tokenValue}>
|
|
28
|
+
{key}: {tokenValue}
|
|
29
|
+
</option>
|
|
30
|
+
))}
|
|
31
|
+
</select>
|
|
32
|
+
);
|
|
33
|
+
}
|