@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,229 @@
|
|
|
1
|
+
import type { SelectedElement } from '../../types';
|
|
2
|
+
|
|
3
|
+
interface MarginPaddingOverlayProps {
|
|
4
|
+
selectedElement: SelectedElement;
|
|
5
|
+
zIndex?: number;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export function MarginPaddingOverlay({
|
|
9
|
+
selectedElement,
|
|
10
|
+
zIndex = 9997,
|
|
11
|
+
}: MarginPaddingOverlayProps) {
|
|
12
|
+
const { element, rect } = selectedElement;
|
|
13
|
+
const computed = window.getComputedStyle(element);
|
|
14
|
+
|
|
15
|
+
const margin = {
|
|
16
|
+
top: parseFloat(computed.marginTop) || 0,
|
|
17
|
+
right: parseFloat(computed.marginRight) || 0,
|
|
18
|
+
bottom: parseFloat(computed.marginBottom) || 0,
|
|
19
|
+
left: parseFloat(computed.marginLeft) || 0,
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
const padding = {
|
|
23
|
+
top: parseFloat(computed.paddingTop) || 0,
|
|
24
|
+
right: parseFloat(computed.paddingRight) || 0,
|
|
25
|
+
bottom: parseFloat(computed.paddingBottom) || 0,
|
|
26
|
+
left: parseFloat(computed.paddingLeft) || 0,
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
// Get gap information
|
|
30
|
+
const gap = parseFloat(computed.gap) || 0;
|
|
31
|
+
const rowGap = parseFloat(computed.rowGap) || gap;
|
|
32
|
+
const columnGap = parseFloat(computed.columnGap) || gap;
|
|
33
|
+
const display = computed.display;
|
|
34
|
+
const flexDirection = computed.flexDirection;
|
|
35
|
+
const isFlexOrGrid = display.includes('flex') || display.includes('grid');
|
|
36
|
+
const isColumn = flexDirection === 'column' || flexDirection === 'column-reverse';
|
|
37
|
+
|
|
38
|
+
// Margin color: orange tint
|
|
39
|
+
const marginColor = 'rgba(251, 146, 60, 0.3)';
|
|
40
|
+
// Padding color: green tint
|
|
41
|
+
const paddingColor = 'rgba(74, 222, 128, 0.3)';
|
|
42
|
+
// Gap color: purple tint
|
|
43
|
+
const gapColor = 'rgba(168, 85, 247, 0.35)';
|
|
44
|
+
|
|
45
|
+
// Get visible children and their rects
|
|
46
|
+
const getGapOverlays = () => {
|
|
47
|
+
if (!isFlexOrGrid || (rowGap === 0 && columnGap === 0)) return [];
|
|
48
|
+
|
|
49
|
+
const children = Array.from(element.children).filter(
|
|
50
|
+
child => child instanceof HTMLElement &&
|
|
51
|
+
getComputedStyle(child).display !== 'none' &&
|
|
52
|
+
getComputedStyle(child).visibility !== 'hidden'
|
|
53
|
+
) as HTMLElement[];
|
|
54
|
+
|
|
55
|
+
if (children.length < 2) return [];
|
|
56
|
+
|
|
57
|
+
const overlays: Array<{ top: number; left: number; width: number; height: number }> = [];
|
|
58
|
+
|
|
59
|
+
for (let i = 0; i < children.length - 1; i++) {
|
|
60
|
+
const currentChild = children[i];
|
|
61
|
+
const nextChild = children[i + 1];
|
|
62
|
+
if (!currentChild || !nextChild) continue;
|
|
63
|
+
|
|
64
|
+
const currentRect = currentChild.getBoundingClientRect();
|
|
65
|
+
const nextRect = nextChild.getBoundingClientRect();
|
|
66
|
+
|
|
67
|
+
if (isColumn || display.includes('grid')) {
|
|
68
|
+
// For column flex or grid, show row gaps (vertical gaps between rows)
|
|
69
|
+
if (rowGap > 0 && nextRect.top > currentRect.bottom) {
|
|
70
|
+
const gapTop = currentRect.bottom;
|
|
71
|
+
const gapHeight = Math.min(rowGap, nextRect.top - currentRect.bottom);
|
|
72
|
+
if (gapHeight > 0) {
|
|
73
|
+
overlays.push({
|
|
74
|
+
top: gapTop,
|
|
75
|
+
left: rect.left + padding.left,
|
|
76
|
+
width: rect.width - padding.left - padding.right,
|
|
77
|
+
height: gapHeight,
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
if (!isColumn || display.includes('grid')) {
|
|
84
|
+
// For row flex or grid, show column gaps (horizontal gaps between columns)
|
|
85
|
+
if (columnGap > 0 && nextRect.left > currentRect.right) {
|
|
86
|
+
const gapLeft = currentRect.right;
|
|
87
|
+
const gapWidth = Math.min(columnGap, nextRect.left - currentRect.right);
|
|
88
|
+
if (gapWidth > 0) {
|
|
89
|
+
overlays.push({
|
|
90
|
+
top: rect.top + padding.top,
|
|
91
|
+
left: gapLeft,
|
|
92
|
+
width: gapWidth,
|
|
93
|
+
height: rect.height - padding.top - padding.bottom,
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
return overlays;
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
const gapOverlays = getGapOverlays();
|
|
104
|
+
|
|
105
|
+
return (
|
|
106
|
+
<div data-bobbin="margin-padding-overlay" style={{ position: 'fixed', top: 0, left: 0, zIndex, pointerEvents: 'none' }}>
|
|
107
|
+
{/* Top margin */}
|
|
108
|
+
{margin.top > 0 && (
|
|
109
|
+
<div
|
|
110
|
+
style={{
|
|
111
|
+
position: 'fixed',
|
|
112
|
+
top: rect.top - margin.top,
|
|
113
|
+
left: rect.left,
|
|
114
|
+
width: rect.width,
|
|
115
|
+
height: margin.top,
|
|
116
|
+
backgroundColor: marginColor,
|
|
117
|
+
}}
|
|
118
|
+
/>
|
|
119
|
+
)}
|
|
120
|
+
{/* Bottom margin */}
|
|
121
|
+
{margin.bottom > 0 && (
|
|
122
|
+
<div
|
|
123
|
+
style={{
|
|
124
|
+
position: 'fixed',
|
|
125
|
+
top: rect.bottom,
|
|
126
|
+
left: rect.left,
|
|
127
|
+
width: rect.width,
|
|
128
|
+
height: margin.bottom,
|
|
129
|
+
backgroundColor: marginColor,
|
|
130
|
+
}}
|
|
131
|
+
/>
|
|
132
|
+
)}
|
|
133
|
+
{/* Left margin */}
|
|
134
|
+
{margin.left > 0 && (
|
|
135
|
+
<div
|
|
136
|
+
style={{
|
|
137
|
+
position: 'fixed',
|
|
138
|
+
top: rect.top,
|
|
139
|
+
left: rect.left - margin.left,
|
|
140
|
+
width: margin.left,
|
|
141
|
+
height: rect.height,
|
|
142
|
+
backgroundColor: marginColor,
|
|
143
|
+
}}
|
|
144
|
+
/>
|
|
145
|
+
)}
|
|
146
|
+
{/* Right margin */}
|
|
147
|
+
{margin.right > 0 && (
|
|
148
|
+
<div
|
|
149
|
+
style={{
|
|
150
|
+
position: 'fixed',
|
|
151
|
+
top: rect.top,
|
|
152
|
+
left: rect.right,
|
|
153
|
+
width: margin.right,
|
|
154
|
+
height: rect.height,
|
|
155
|
+
backgroundColor: marginColor,
|
|
156
|
+
}}
|
|
157
|
+
/>
|
|
158
|
+
)}
|
|
159
|
+
|
|
160
|
+
{/* Top padding */}
|
|
161
|
+
{padding.top > 0 && (
|
|
162
|
+
<div
|
|
163
|
+
style={{
|
|
164
|
+
position: 'fixed',
|
|
165
|
+
top: rect.top,
|
|
166
|
+
left: rect.left,
|
|
167
|
+
width: rect.width,
|
|
168
|
+
height: padding.top,
|
|
169
|
+
backgroundColor: paddingColor,
|
|
170
|
+
}}
|
|
171
|
+
/>
|
|
172
|
+
)}
|
|
173
|
+
{/* Bottom padding */}
|
|
174
|
+
{padding.bottom > 0 && (
|
|
175
|
+
<div
|
|
176
|
+
style={{
|
|
177
|
+
position: 'fixed',
|
|
178
|
+
top: rect.bottom - padding.bottom,
|
|
179
|
+
left: rect.left,
|
|
180
|
+
width: rect.width,
|
|
181
|
+
height: padding.bottom,
|
|
182
|
+
backgroundColor: paddingColor,
|
|
183
|
+
}}
|
|
184
|
+
/>
|
|
185
|
+
)}
|
|
186
|
+
{/* Left padding */}
|
|
187
|
+
{padding.left > 0 && (
|
|
188
|
+
<div
|
|
189
|
+
style={{
|
|
190
|
+
position: 'fixed',
|
|
191
|
+
top: rect.top + padding.top,
|
|
192
|
+
left: rect.left,
|
|
193
|
+
width: padding.left,
|
|
194
|
+
height: rect.height - padding.top - padding.bottom,
|
|
195
|
+
backgroundColor: paddingColor,
|
|
196
|
+
}}
|
|
197
|
+
/>
|
|
198
|
+
)}
|
|
199
|
+
{/* Right padding */}
|
|
200
|
+
{padding.right > 0 && (
|
|
201
|
+
<div
|
|
202
|
+
style={{
|
|
203
|
+
position: 'fixed',
|
|
204
|
+
top: rect.top + padding.top,
|
|
205
|
+
left: rect.right - padding.right,
|
|
206
|
+
width: padding.right,
|
|
207
|
+
height: rect.height - padding.top - padding.bottom,
|
|
208
|
+
backgroundColor: paddingColor,
|
|
209
|
+
}}
|
|
210
|
+
/>
|
|
211
|
+
)}
|
|
212
|
+
|
|
213
|
+
{/* Gap overlays */}
|
|
214
|
+
{gapOverlays.map((overlay, index) => (
|
|
215
|
+
<div
|
|
216
|
+
key={`gap-${index}`}
|
|
217
|
+
style={{
|
|
218
|
+
position: 'fixed',
|
|
219
|
+
top: overlay.top,
|
|
220
|
+
left: overlay.left,
|
|
221
|
+
width: overlay.width,
|
|
222
|
+
height: overlay.height,
|
|
223
|
+
backgroundColor: gapColor,
|
|
224
|
+
}}
|
|
225
|
+
/>
|
|
226
|
+
))}
|
|
227
|
+
</div>
|
|
228
|
+
);
|
|
229
|
+
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import { useLayoutEffect, useState } from 'react';
|
|
2
|
+
import type { SelectedElement } from '../../types';
|
|
3
|
+
|
|
4
|
+
interface SelectionOverlayProps {
|
|
5
|
+
hoveredElement: SelectedElement | null;
|
|
6
|
+
selectedElement: SelectedElement | null;
|
|
7
|
+
offset?: number;
|
|
8
|
+
zIndex?: number;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function SelectionOverlay({
|
|
12
|
+
hoveredElement,
|
|
13
|
+
selectedElement,
|
|
14
|
+
offset = 4,
|
|
15
|
+
zIndex = 9998,
|
|
16
|
+
}: SelectionOverlayProps) {
|
|
17
|
+
const [hoverRect, setHoverRect] = useState<DOMRect | null>(null);
|
|
18
|
+
const [selectRect, setSelectRect] = useState<DOMRect | null>(null);
|
|
19
|
+
|
|
20
|
+
// Animate hover box
|
|
21
|
+
useLayoutEffect(() => {
|
|
22
|
+
if (!hoveredElement || hoveredElement === selectedElement) {
|
|
23
|
+
setHoverRect(null);
|
|
24
|
+
return;
|
|
25
|
+
}
|
|
26
|
+
setHoverRect(hoveredElement.rect);
|
|
27
|
+
}, [hoveredElement, selectedElement]);
|
|
28
|
+
|
|
29
|
+
// Animate selection box
|
|
30
|
+
useLayoutEffect(() => {
|
|
31
|
+
if (!selectedElement) {
|
|
32
|
+
setSelectRect(null);
|
|
33
|
+
return;
|
|
34
|
+
}
|
|
35
|
+
setSelectRect(selectedElement.rect);
|
|
36
|
+
}, [selectedElement]);
|
|
37
|
+
|
|
38
|
+
// ShadCN-style: subtle dark gray for hover, black for selection
|
|
39
|
+
const createBoxStyle = (rect: DOMRect | null, isSelected: boolean): React.CSSProperties => {
|
|
40
|
+
if (!rect) return { opacity: 0, pointerEvents: 'none' };
|
|
41
|
+
|
|
42
|
+
return {
|
|
43
|
+
position: 'fixed',
|
|
44
|
+
top: rect.top - offset,
|
|
45
|
+
left: rect.left - offset,
|
|
46
|
+
width: rect.width + offset * 2,
|
|
47
|
+
height: rect.height + offset * 2,
|
|
48
|
+
border: isSelected ? '1.5px solid #18181b' : '1.5px dashed #71717a',
|
|
49
|
+
borderRadius: '3px',
|
|
50
|
+
pointerEvents: 'none',
|
|
51
|
+
zIndex,
|
|
52
|
+
transition: 'all 0.12s cubic-bezier(0.4, 0, 0.2, 1)',
|
|
53
|
+
opacity: 1,
|
|
54
|
+
boxShadow: isSelected ? '0 0 0 1px rgba(24, 24, 27, 0.1)' : 'none',
|
|
55
|
+
};
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
return (
|
|
59
|
+
<>
|
|
60
|
+
{/* Hover overlay */}
|
|
61
|
+
<div
|
|
62
|
+
data-bobbin="hover-overlay"
|
|
63
|
+
style={createBoxStyle(hoverRect, false)}
|
|
64
|
+
/>
|
|
65
|
+
|
|
66
|
+
{/* Selection overlay */}
|
|
67
|
+
<div
|
|
68
|
+
data-bobbin="select-overlay"
|
|
69
|
+
style={createBoxStyle(selectRect, true)}
|
|
70
|
+
/>
|
|
71
|
+
</>
|
|
72
|
+
);
|
|
73
|
+
}
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
import { useState, useLayoutEffect } from 'react';
|
|
2
|
+
import type { BobbinState, BobbinActions } from '../../types';
|
|
3
|
+
|
|
4
|
+
interface PillProps {
|
|
5
|
+
state: BobbinState;
|
|
6
|
+
actions: BobbinActions;
|
|
7
|
+
position?: { bottom: number; right: number };
|
|
8
|
+
container?: HTMLElement | null;
|
|
9
|
+
zIndex?: number;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
// Copy icon
|
|
13
|
+
const CopyIcon = () => (
|
|
14
|
+
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
|
15
|
+
<rect x="9" y="9" width="13" height="13" rx="2" />
|
|
16
|
+
<path d="M5 15H4a2 2 0 01-2-2V4a2 2 0 012-2h9a2 2 0 012 2v1" />
|
|
17
|
+
</svg>
|
|
18
|
+
);
|
|
19
|
+
|
|
20
|
+
// Edit icon (pencil)
|
|
21
|
+
const EditIcon = () => (
|
|
22
|
+
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
|
23
|
+
<path d="M12 20h9" />
|
|
24
|
+
<path d="M16.5 3.5a2.121 2.121 0 0 1 3 3L7 19l-4 1 1-4L16.5 3.5z" />
|
|
25
|
+
</svg>
|
|
26
|
+
);
|
|
27
|
+
|
|
28
|
+
export function Pill({ state, actions, position, container, zIndex = 9999 }: PillProps) {
|
|
29
|
+
const [copyHovered, setCopyHovered] = useState(false);
|
|
30
|
+
const [copied, setCopied] = useState(false);
|
|
31
|
+
const [computedPosition, setComputedPosition] = useState<{ bottom: number; right: number } | null>(null);
|
|
32
|
+
|
|
33
|
+
const offset = position ?? { bottom: 16, right: 16 };
|
|
34
|
+
|
|
35
|
+
// Calculate position relative to container if provided
|
|
36
|
+
useLayoutEffect(() => {
|
|
37
|
+
if (!container) {
|
|
38
|
+
setComputedPosition(null);
|
|
39
|
+
return;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const updatePosition = () => {
|
|
43
|
+
const rect = container.getBoundingClientRect();
|
|
44
|
+
// Position from viewport edges based on container position
|
|
45
|
+
setComputedPosition({
|
|
46
|
+
bottom: window.innerHeight - rect.bottom + offset.bottom,
|
|
47
|
+
right: window.innerWidth - rect.right + offset.right,
|
|
48
|
+
});
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
updatePosition();
|
|
52
|
+
|
|
53
|
+
// Update on resize/scroll
|
|
54
|
+
const observer = new ResizeObserver(updatePosition);
|
|
55
|
+
observer.observe(container);
|
|
56
|
+
window.addEventListener('scroll', updatePosition, true);
|
|
57
|
+
|
|
58
|
+
return () => {
|
|
59
|
+
observer.disconnect();
|
|
60
|
+
window.removeEventListener('scroll', updatePosition, true);
|
|
61
|
+
};
|
|
62
|
+
}, [container, offset.bottom, offset.right]);
|
|
63
|
+
|
|
64
|
+
const pos = computedPosition ?? offset;
|
|
65
|
+
|
|
66
|
+
const handleClick = () => {
|
|
67
|
+
if (state.isActive) {
|
|
68
|
+
actions.deactivate();
|
|
69
|
+
} else {
|
|
70
|
+
actions.activate();
|
|
71
|
+
}
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
const handleCopyChanges = (e: React.MouseEvent) => {
|
|
75
|
+
e.stopPropagation();
|
|
76
|
+
const yaml = actions.exportChanges();
|
|
77
|
+
navigator.clipboard.writeText(yaml);
|
|
78
|
+
setCopied(true);
|
|
79
|
+
setTimeout(() => setCopied(false), 1500);
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
// ShadCN-style: minimal black/white with subtle borders
|
|
83
|
+
return (
|
|
84
|
+
<div
|
|
85
|
+
data-bobbin="pill"
|
|
86
|
+
className="bobbin-pill"
|
|
87
|
+
style={{
|
|
88
|
+
position: 'fixed',
|
|
89
|
+
bottom: pos.bottom,
|
|
90
|
+
right: pos.right,
|
|
91
|
+
zIndex,
|
|
92
|
+
display: 'flex',
|
|
93
|
+
alignItems: 'center',
|
|
94
|
+
gap: '6px',
|
|
95
|
+
padding: '6px 10px',
|
|
96
|
+
borderRadius: '9999px',
|
|
97
|
+
backgroundColor: state.isActive ? '#18181b' : '#fafafa',
|
|
98
|
+
color: state.isActive ? '#fafafa' : '#18181b',
|
|
99
|
+
border: '1px solid #e4e4e7',
|
|
100
|
+
cursor: 'pointer',
|
|
101
|
+
boxShadow: '0 1px 2px 0 rgb(0 0 0 / 0.05)',
|
|
102
|
+
transition: 'all 0.15s ease',
|
|
103
|
+
userSelect: 'none',
|
|
104
|
+
fontSize: '13px',
|
|
105
|
+
fontFamily: 'system-ui, -apple-system, sans-serif',
|
|
106
|
+
}}
|
|
107
|
+
onClick={handleClick}
|
|
108
|
+
>
|
|
109
|
+
{/* Edit Icon */}
|
|
110
|
+
<EditIcon />
|
|
111
|
+
|
|
112
|
+
{/* Change count badge with copy button - shows for changes OR annotations */}
|
|
113
|
+
{(state.changes.length > 0 || state.annotations.length > 0) && (
|
|
114
|
+
<button
|
|
115
|
+
onClick={handleCopyChanges}
|
|
116
|
+
onMouseEnter={() => setCopyHovered(true)}
|
|
117
|
+
onMouseLeave={() => setCopyHovered(false)}
|
|
118
|
+
style={{
|
|
119
|
+
display: 'flex',
|
|
120
|
+
alignItems: 'center',
|
|
121
|
+
gap: '4px',
|
|
122
|
+
padding: '2px 6px',
|
|
123
|
+
borderRadius: '9999px',
|
|
124
|
+
backgroundColor: state.isActive ? '#fafafa' : '#18181b',
|
|
125
|
+
color: state.isActive ? '#18181b' : '#fafafa',
|
|
126
|
+
fontSize: '11px',
|
|
127
|
+
fontWeight: 500,
|
|
128
|
+
border: 'none',
|
|
129
|
+
cursor: 'pointer',
|
|
130
|
+
transition: 'all 0.1s ease',
|
|
131
|
+
}}
|
|
132
|
+
title="Copy changes as YAML"
|
|
133
|
+
>
|
|
134
|
+
<span>{state.changes.length + state.annotations.length}</span>
|
|
135
|
+
{copyHovered && <CopyIcon />}
|
|
136
|
+
{copied && <span style={{ fontSize: '10px' }}>✓</span>}
|
|
137
|
+
</button>
|
|
138
|
+
)}
|
|
139
|
+
|
|
140
|
+
{/* Clipboard indicator */}
|
|
141
|
+
{state.clipboard && (
|
|
142
|
+
<div
|
|
143
|
+
style={{
|
|
144
|
+
width: '6px',
|
|
145
|
+
height: '6px',
|
|
146
|
+
borderRadius: '50%',
|
|
147
|
+
backgroundColor: state.isActive ? '#fafafa' : '#18181b',
|
|
148
|
+
border: '1px solid #a1a1aa',
|
|
149
|
+
}}
|
|
150
|
+
title="Element copied"
|
|
151
|
+
/>
|
|
152
|
+
)}
|
|
153
|
+
</div>
|
|
154
|
+
);
|
|
155
|
+
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
interface ThemeToggleProps {
|
|
2
|
+
theme: 'light' | 'dark' | 'system';
|
|
3
|
+
onToggle: () => void;
|
|
4
|
+
zIndex?: number;
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
// Sun icon for light theme
|
|
8
|
+
const SunIcon = () => (
|
|
9
|
+
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
|
10
|
+
<circle cx="12" cy="12" r="5" />
|
|
11
|
+
<line x1="12" y1="1" x2="12" y2="3" />
|
|
12
|
+
<line x1="12" y1="21" x2="12" y2="23" />
|
|
13
|
+
<line x1="4.22" y1="4.22" x2="5.64" y2="5.64" />
|
|
14
|
+
<line x1="18.36" y1="18.36" x2="19.78" y2="19.78" />
|
|
15
|
+
<line x1="1" y1="12" x2="3" y2="12" />
|
|
16
|
+
<line x1="21" y1="12" x2="23" y2="12" />
|
|
17
|
+
<line x1="4.22" y1="19.78" x2="5.64" y2="18.36" />
|
|
18
|
+
<line x1="18.36" y1="5.64" x2="19.78" y2="4.22" />
|
|
19
|
+
</svg>
|
|
20
|
+
);
|
|
21
|
+
|
|
22
|
+
// Moon icon for dark theme
|
|
23
|
+
const MoonIcon = () => (
|
|
24
|
+
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
|
25
|
+
<path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z" />
|
|
26
|
+
</svg>
|
|
27
|
+
);
|
|
28
|
+
|
|
29
|
+
// Monitor icon for system theme
|
|
30
|
+
const MonitorIcon = () => (
|
|
31
|
+
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
|
32
|
+
<rect x="2" y="3" width="20" height="14" rx="2" ry="2" />
|
|
33
|
+
<line x1="8" y1="21" x2="16" y2="21" />
|
|
34
|
+
<line x1="12" y1="17" x2="12" y2="21" />
|
|
35
|
+
</svg>
|
|
36
|
+
);
|
|
37
|
+
|
|
38
|
+
export function ThemeToggle({ theme, onToggle, zIndex = 9999 }: ThemeToggleProps) {
|
|
39
|
+
const icons = {
|
|
40
|
+
light: <SunIcon />,
|
|
41
|
+
dark: <MoonIcon />,
|
|
42
|
+
system: <MonitorIcon />,
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
return (
|
|
46
|
+
<button
|
|
47
|
+
data-bobbin="theme-toggle"
|
|
48
|
+
onClick={onToggle}
|
|
49
|
+
style={{
|
|
50
|
+
position: 'fixed',
|
|
51
|
+
bottom: '24px',
|
|
52
|
+
left: '24px',
|
|
53
|
+
width: '32px',
|
|
54
|
+
height: '32px',
|
|
55
|
+
borderRadius: '8px',
|
|
56
|
+
border: '1px solid #e4e4e7',
|
|
57
|
+
backgroundColor: '#fafafa',
|
|
58
|
+
color: '#18181b',
|
|
59
|
+
cursor: 'pointer',
|
|
60
|
+
display: 'flex',
|
|
61
|
+
alignItems: 'center',
|
|
62
|
+
justifyContent: 'center',
|
|
63
|
+
fontSize: '14px',
|
|
64
|
+
boxShadow: '0 1px 2px 0 rgb(0 0 0 / 0.05)',
|
|
65
|
+
zIndex,
|
|
66
|
+
}}
|
|
67
|
+
title={`Theme: ${theme}`}
|
|
68
|
+
>
|
|
69
|
+
{icons[theme]}
|
|
70
|
+
</button>
|
|
71
|
+
);
|
|
72
|
+
}
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
import type { Change, Annotation, BobbinChangeset } from '../types';
|
|
2
|
+
|
|
3
|
+
export function serializeChangesToYAML(
|
|
4
|
+
changes: Change[],
|
|
5
|
+
annotations: Annotation[] = [],
|
|
6
|
+
): string {
|
|
7
|
+
const changeset: BobbinChangeset = {
|
|
8
|
+
version: '1.0',
|
|
9
|
+
timestamp: new Date().toISOString(),
|
|
10
|
+
changeCount: changes.length + annotations.length,
|
|
11
|
+
changes: changes.map((change) => {
|
|
12
|
+
const base = {
|
|
13
|
+
type: change.type,
|
|
14
|
+
target: change.target.path,
|
|
15
|
+
xpath: change.target.xpath,
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
switch (change.type) {
|
|
19
|
+
case 'style':
|
|
20
|
+
return {
|
|
21
|
+
...base,
|
|
22
|
+
property: (change.before as { property: string }).property,
|
|
23
|
+
before: (change.before as { value: string }).value,
|
|
24
|
+
after: (change.after as { value: string }).value,
|
|
25
|
+
};
|
|
26
|
+
case 'text':
|
|
27
|
+
return {
|
|
28
|
+
...base,
|
|
29
|
+
before: change.before as string,
|
|
30
|
+
after: change.after as string,
|
|
31
|
+
};
|
|
32
|
+
case 'move':
|
|
33
|
+
return {
|
|
34
|
+
...base,
|
|
35
|
+
before: `${(change.before as { parent: string }).parent}[${
|
|
36
|
+
(change.before as { index: number }).index
|
|
37
|
+
}]`,
|
|
38
|
+
after: `${(change.after as { parent: string }).parent}[${
|
|
39
|
+
(change.after as { index: number }).index
|
|
40
|
+
}]`,
|
|
41
|
+
};
|
|
42
|
+
default:
|
|
43
|
+
return {
|
|
44
|
+
...base,
|
|
45
|
+
before: JSON.stringify(change.before),
|
|
46
|
+
after: JSON.stringify(change.after),
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
}),
|
|
50
|
+
annotations: annotations.map((a) => ({
|
|
51
|
+
type: 'annotation',
|
|
52
|
+
target: a.elementPath,
|
|
53
|
+
xpath: a.elementXpath,
|
|
54
|
+
note: a.content,
|
|
55
|
+
})),
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
return toYAML(changeset);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function toYAML(obj: unknown, indent = 0): string {
|
|
62
|
+
const spaces = ' '.repeat(indent);
|
|
63
|
+
|
|
64
|
+
if (obj === null || obj === undefined) return 'null';
|
|
65
|
+
if (typeof obj === 'boolean') return obj ? 'true' : 'false';
|
|
66
|
+
if (typeof obj === 'number') return String(obj);
|
|
67
|
+
if (typeof obj === 'string') {
|
|
68
|
+
// Escape strings that need quoting
|
|
69
|
+
if (/[\n:{}[\],&*#?|\-<>=!%@`]/.test(obj) || obj.trim() !== obj) {
|
|
70
|
+
return `"${obj.replace(/"/g, '\\"').replace(/\n/g, '\\n')}"`;
|
|
71
|
+
}
|
|
72
|
+
return obj || '""';
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
if (Array.isArray(obj)) {
|
|
76
|
+
if (obj.length === 0) return '[]';
|
|
77
|
+
return obj
|
|
78
|
+
.map((item) => {
|
|
79
|
+
const itemYaml = toYAML(item, indent + 1);
|
|
80
|
+
if (typeof item === 'object' && item !== null) {
|
|
81
|
+
return `${spaces}- ${itemYaml.trimStart()}`;
|
|
82
|
+
}
|
|
83
|
+
return `${spaces}- ${itemYaml}`;
|
|
84
|
+
})
|
|
85
|
+
.join('\n');
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
if (typeof obj === 'object') {
|
|
89
|
+
const entries = Object.entries(obj);
|
|
90
|
+
if (entries.length === 0) return '{}';
|
|
91
|
+
return entries
|
|
92
|
+
.map(([key, value]) => {
|
|
93
|
+
const valueYaml = toYAML(value, indent + 1);
|
|
94
|
+
if (
|
|
95
|
+
typeof value === 'object' &&
|
|
96
|
+
value !== null &&
|
|
97
|
+
!Array.isArray(value)
|
|
98
|
+
) {
|
|
99
|
+
return `${spaces}${key}:\n${valueYaml}`;
|
|
100
|
+
}
|
|
101
|
+
if (Array.isArray(value) && value.length > 0) {
|
|
102
|
+
return `${spaces}${key}:\n${valueYaml}`;
|
|
103
|
+
}
|
|
104
|
+
return `${spaces}${key}: ${valueYaml}`;
|
|
105
|
+
})
|
|
106
|
+
.join('\n');
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
return String(obj);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export function parseYAMLChangeset(yaml: string): BobbinChangeset {
|
|
113
|
+
// Basic YAML parser for the changeset format
|
|
114
|
+
// For production, consider using js-yaml
|
|
115
|
+
const lines = yaml.split('\n');
|
|
116
|
+
const result: BobbinChangeset = {
|
|
117
|
+
version: '1.0',
|
|
118
|
+
timestamp: '',
|
|
119
|
+
changeCount: 0,
|
|
120
|
+
changes: [],
|
|
121
|
+
annotations: [],
|
|
122
|
+
};
|
|
123
|
+
|
|
124
|
+
// Simplified parser - in production use js-yaml
|
|
125
|
+
for (const line of lines) {
|
|
126
|
+
const trimmed = line.trim();
|
|
127
|
+
if (trimmed.startsWith('version:')) {
|
|
128
|
+
const parts = trimmed.split(':');
|
|
129
|
+
result.version = (parts[1]?.trim() ?? '1.0') as '1.0';
|
|
130
|
+
} else if (trimmed.startsWith('timestamp:')) {
|
|
131
|
+
result.timestamp = trimmed.split(':').slice(1).join(':').trim();
|
|
132
|
+
} else if (trimmed.startsWith('changeCount:')) {
|
|
133
|
+
const parts = trimmed.split(':');
|
|
134
|
+
result.changeCount = parseInt(parts[1]?.trim() ?? '0', 10);
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
return result;
|
|
139
|
+
}
|