@aprovan/bobbin 0.1.0-dev.6bd527d
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,572 @@
|
|
|
1
|
+
import { useState, useMemo, useCallback, useEffect, useRef } from 'react';
|
|
2
|
+
import type { SelectedElement, BobbinActions } from '../../types';
|
|
3
|
+
|
|
4
|
+
interface ControlHandlesProps {
|
|
5
|
+
selectedElement: SelectedElement;
|
|
6
|
+
actions: BobbinActions;
|
|
7
|
+
clipboard: SelectedElement | null;
|
|
8
|
+
zIndex?: number;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
type HandlePosition = 'top' | 'bottom' | 'left' | 'right';
|
|
12
|
+
|
|
13
|
+
// Determine layout direction based on parent's flex/grid direction
|
|
14
|
+
function getLayoutDirection(element: HTMLElement): 'horizontal' | 'vertical' | 'unknown' {
|
|
15
|
+
const parent = element.parentElement;
|
|
16
|
+
if (!parent) return 'unknown';
|
|
17
|
+
|
|
18
|
+
const style = getComputedStyle(parent);
|
|
19
|
+
const display = style.display;
|
|
20
|
+
const flexDirection = style.flexDirection;
|
|
21
|
+
|
|
22
|
+
if (display.includes('flex')) {
|
|
23
|
+
if (flexDirection === 'column' || flexDirection === 'column-reverse') {
|
|
24
|
+
return 'vertical';
|
|
25
|
+
}
|
|
26
|
+
return 'horizontal';
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
if (display.includes('grid')) {
|
|
30
|
+
return 'horizontal';
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
return 'vertical';
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// Thresholds
|
|
37
|
+
const MIN_WIDTH_FOR_CORNER_TOOLBAR = 60; // Show corner toolbar if wider than this
|
|
38
|
+
const MIN_SIZE_FOR_EDGE_ICONS = 70; // Minimum size (height for left/right, width for top/bottom) to show all edge icons
|
|
39
|
+
const CORNER_HANDLE_SIZE = 18;
|
|
40
|
+
const HOVER_ZONE_SIZE = 28;
|
|
41
|
+
|
|
42
|
+
export function ControlHandles({
|
|
43
|
+
selectedElement,
|
|
44
|
+
actions,
|
|
45
|
+
clipboard,
|
|
46
|
+
zIndex = 9999,
|
|
47
|
+
}: ControlHandlesProps) {
|
|
48
|
+
const [hoveredEdge, setHoveredEdge] = useState<HandlePosition | null>(null);
|
|
49
|
+
const [expandedEdge, setExpandedEdge] = useState<HandlePosition | null>(null);
|
|
50
|
+
const [isDragging, setIsDragging] = useState(false);
|
|
51
|
+
const [dropTarget, setDropTarget] = useState<HTMLElement | null>(null);
|
|
52
|
+
const [cornerToolbarExpanded, setCornerToolbarExpanded] = useState(false);
|
|
53
|
+
const toolbarRef = useRef<HTMLDivElement>(null);
|
|
54
|
+
const { rect } = selectedElement;
|
|
55
|
+
|
|
56
|
+
const layoutDirection = useMemo(
|
|
57
|
+
() => getLayoutDirection(selectedElement.element),
|
|
58
|
+
[selectedElement.element]
|
|
59
|
+
);
|
|
60
|
+
|
|
61
|
+
// Check if corner toolbar should collapse
|
|
62
|
+
const isNarrowElement = rect.width < MIN_WIDTH_FOR_CORNER_TOOLBAR;
|
|
63
|
+
|
|
64
|
+
// Check if edge zones need collapsing (based on element dimension perpendicular to edge)
|
|
65
|
+
const isShortForVerticalEdge = rect.height < MIN_SIZE_FOR_EDGE_ICONS; // for left/right edges
|
|
66
|
+
const isShortForHorizontalEdge = rect.width < MIN_SIZE_FOR_EDGE_ICONS; // for top/bottom edges
|
|
67
|
+
|
|
68
|
+
// Reset states when element changes
|
|
69
|
+
useEffect(() => {
|
|
70
|
+
setCornerToolbarExpanded(false);
|
|
71
|
+
setExpandedEdge(null);
|
|
72
|
+
setHoveredEdge(null);
|
|
73
|
+
}, [selectedElement.path]);
|
|
74
|
+
|
|
75
|
+
// Icons (simplified SVG) - all monochrome, smaller size
|
|
76
|
+
const TrashIcon = () => (
|
|
77
|
+
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
|
78
|
+
<path d="M3 6h18M19 6v14a2 2 0 01-2 2H7a2 2 0 01-2-2V6m3 0V4a2 2 0 012-2h4a2 2 0 012 2v2" />
|
|
79
|
+
</svg>
|
|
80
|
+
);
|
|
81
|
+
|
|
82
|
+
const CopyIcon = () => (
|
|
83
|
+
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
|
84
|
+
<rect x="9" y="9" width="13" height="13" rx="2" />
|
|
85
|
+
<path d="M5 15H4a2 2 0 01-2-2V4a2 2 0 012-2h9a2 2 0 012 2v1" />
|
|
86
|
+
</svg>
|
|
87
|
+
);
|
|
88
|
+
|
|
89
|
+
const MoveIcon = () => (
|
|
90
|
+
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
|
91
|
+
<polyline points="5 9 2 12 5 15" />
|
|
92
|
+
<polyline points="9 5 12 2 15 5" />
|
|
93
|
+
<polyline points="15 19 12 22 9 19" />
|
|
94
|
+
<polyline points="19 9 22 12 19 15" />
|
|
95
|
+
<line x1="2" y1="12" x2="22" y2="12" />
|
|
96
|
+
<line x1="12" y1="2" x2="12" y2="22" />
|
|
97
|
+
</svg>
|
|
98
|
+
);
|
|
99
|
+
|
|
100
|
+
const PlusIcon = () => (
|
|
101
|
+
<svg width="9" height="9" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
|
102
|
+
<path d="M12 5v14M5 12h14" />
|
|
103
|
+
</svg>
|
|
104
|
+
);
|
|
105
|
+
|
|
106
|
+
const DuplicateIcon = () => (
|
|
107
|
+
<svg width="9" height="9" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
|
108
|
+
<rect x="8" y="8" width="12" height="12" rx="2" />
|
|
109
|
+
<rect x="4" y="4" width="12" height="12" rx="2" />
|
|
110
|
+
</svg>
|
|
111
|
+
);
|
|
112
|
+
|
|
113
|
+
const PasteIcon = () => (
|
|
114
|
+
<svg width="9" height="9" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
|
115
|
+
<path d="M16 4h2a2 2 0 012 2v14a2 2 0 01-2 2H6a2 2 0 01-2-2V6a2 2 0 012-2h2" />
|
|
116
|
+
<rect x="8" y="2" width="8" height="4" rx="1" />
|
|
117
|
+
</svg>
|
|
118
|
+
);
|
|
119
|
+
|
|
120
|
+
const MoreIcon = () => (
|
|
121
|
+
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
|
122
|
+
<circle cx="12" cy="12" r="1" fill="currentColor" />
|
|
123
|
+
<circle cx="19" cy="12" r="1" fill="currentColor" />
|
|
124
|
+
<circle cx="5" cy="12" r="1" fill="currentColor" />
|
|
125
|
+
</svg>
|
|
126
|
+
);
|
|
127
|
+
|
|
128
|
+
const MoreIconVertical = () => (
|
|
129
|
+
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
|
130
|
+
<circle cx="12" cy="5" r="1" fill="currentColor" />
|
|
131
|
+
<circle cx="12" cy="12" r="1" fill="currentColor" />
|
|
132
|
+
<circle cx="12" cy="19" r="1" fill="currentColor" />
|
|
133
|
+
</svg>
|
|
134
|
+
);
|
|
135
|
+
|
|
136
|
+
// Close expanded menus when clicking outside
|
|
137
|
+
useEffect(() => {
|
|
138
|
+
if (!cornerToolbarExpanded && !expandedEdge) return;
|
|
139
|
+
|
|
140
|
+
const handleClickOutside = (e: MouseEvent) => {
|
|
141
|
+
const target = e.target as HTMLElement;
|
|
142
|
+
if (target.closest('[data-bobbin="control-handles"]')) return;
|
|
143
|
+
|
|
144
|
+
setCornerToolbarExpanded(false);
|
|
145
|
+
setExpandedEdge(null);
|
|
146
|
+
};
|
|
147
|
+
|
|
148
|
+
const timer = setTimeout(() => {
|
|
149
|
+
document.addEventListener('mousedown', handleClickOutside);
|
|
150
|
+
}, 0);
|
|
151
|
+
|
|
152
|
+
return () => {
|
|
153
|
+
clearTimeout(timer);
|
|
154
|
+
document.removeEventListener('mousedown', handleClickOutside);
|
|
155
|
+
};
|
|
156
|
+
}, [cornerToolbarExpanded, expandedEdge]);
|
|
157
|
+
|
|
158
|
+
// Handle mouse move during drag to find drop target
|
|
159
|
+
const handleMouseMove = useCallback((e: MouseEvent) => {
|
|
160
|
+
if (!isDragging) return;
|
|
161
|
+
|
|
162
|
+
// Get element at point, excluding bobbin elements
|
|
163
|
+
const elementsAtPoint = document.elementsFromPoint(e.clientX, e.clientY);
|
|
164
|
+
const target = elementsAtPoint.find(el =>
|
|
165
|
+
!el.hasAttribute('data-bobbin') &&
|
|
166
|
+
el !== selectedElement.element &&
|
|
167
|
+
!selectedElement.element.contains(el) &&
|
|
168
|
+
el instanceof HTMLElement &&
|
|
169
|
+
el.tagName !== 'HTML' &&
|
|
170
|
+
el.tagName !== 'BODY'
|
|
171
|
+
) as HTMLElement | undefined;
|
|
172
|
+
|
|
173
|
+
if (target !== dropTarget) {
|
|
174
|
+
// Remove highlight from previous target
|
|
175
|
+
if (dropTarget) {
|
|
176
|
+
dropTarget.style.outline = '';
|
|
177
|
+
dropTarget.style.outlineOffset = '';
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
// Highlight new target
|
|
181
|
+
if (target) {
|
|
182
|
+
target.style.outline = '2px dashed #3b82f6';
|
|
183
|
+
target.style.outlineOffset = '2px';
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
setDropTarget(target || null);
|
|
187
|
+
}
|
|
188
|
+
}, [isDragging, dropTarget, selectedElement.element]);
|
|
189
|
+
|
|
190
|
+
// Handle mouse up to complete drag
|
|
191
|
+
const handleMouseUp = useCallback(() => {
|
|
192
|
+
if (isDragging && dropTarget) {
|
|
193
|
+
// Move element after the drop target
|
|
194
|
+
const parent = dropTarget.parentElement;
|
|
195
|
+
if (parent) {
|
|
196
|
+
const index = Array.from(parent.children).indexOf(dropTarget) + 1;
|
|
197
|
+
actions.moveElement(parent, index);
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
// Clean up highlight
|
|
201
|
+
dropTarget.style.outline = '';
|
|
202
|
+
dropTarget.style.outlineOffset = '';
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
setIsDragging(false);
|
|
206
|
+
setDropTarget(null);
|
|
207
|
+
}, [isDragging, dropTarget, actions]);
|
|
208
|
+
|
|
209
|
+
// Set up global event listeners for drag
|
|
210
|
+
useEffect(() => {
|
|
211
|
+
if (isDragging) {
|
|
212
|
+
document.addEventListener('mousemove', handleMouseMove);
|
|
213
|
+
document.addEventListener('mouseup', handleMouseUp);
|
|
214
|
+
document.body.style.cursor = 'grabbing';
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
return () => {
|
|
218
|
+
document.removeEventListener('mousemove', handleMouseMove);
|
|
219
|
+
document.removeEventListener('mouseup', handleMouseUp);
|
|
220
|
+
document.body.style.cursor = '';
|
|
221
|
+
|
|
222
|
+
// Clean up any lingering highlight
|
|
223
|
+
if (dropTarget) {
|
|
224
|
+
dropTarget.style.outline = '';
|
|
225
|
+
dropTarget.style.outlineOffset = '';
|
|
226
|
+
}
|
|
227
|
+
};
|
|
228
|
+
}, [isDragging, handleMouseMove, handleMouseUp, dropTarget]);
|
|
229
|
+
|
|
230
|
+
const handleMoveStart = (e: React.MouseEvent) => {
|
|
231
|
+
e.stopPropagation();
|
|
232
|
+
e.preventDefault();
|
|
233
|
+
setIsDragging(true);
|
|
234
|
+
};
|
|
235
|
+
|
|
236
|
+
// Edge hover zone styles - invisible by default, shows actions on hover
|
|
237
|
+
const getEdgeZoneStyle = (position: HandlePosition): React.CSSProperties => {
|
|
238
|
+
const isHorizontal = position === 'top' || position === 'bottom';
|
|
239
|
+
|
|
240
|
+
const base: React.CSSProperties = {
|
|
241
|
+
position: 'fixed',
|
|
242
|
+
display: 'flex',
|
|
243
|
+
alignItems: 'center',
|
|
244
|
+
justifyContent: 'center',
|
|
245
|
+
gap: '3px',
|
|
246
|
+
zIndex,
|
|
247
|
+
transition: 'opacity 0.1s ease',
|
|
248
|
+
pointerEvents: 'auto',
|
|
249
|
+
};
|
|
250
|
+
|
|
251
|
+
if (isHorizontal) {
|
|
252
|
+
return {
|
|
253
|
+
...base,
|
|
254
|
+
left: rect.left,
|
|
255
|
+
width: rect.width,
|
|
256
|
+
height: HOVER_ZONE_SIZE,
|
|
257
|
+
top: position === 'top' ? rect.top - HOVER_ZONE_SIZE : rect.bottom,
|
|
258
|
+
flexDirection: 'row',
|
|
259
|
+
};
|
|
260
|
+
} else {
|
|
261
|
+
return {
|
|
262
|
+
...base,
|
|
263
|
+
top: rect.top,
|
|
264
|
+
height: rect.height,
|
|
265
|
+
width: HOVER_ZONE_SIZE,
|
|
266
|
+
left: position === 'left' ? rect.left - HOVER_ZONE_SIZE : rect.right,
|
|
267
|
+
flexDirection: 'column',
|
|
268
|
+
};
|
|
269
|
+
}
|
|
270
|
+
};
|
|
271
|
+
|
|
272
|
+
// Check if an edge needs collapse based on position
|
|
273
|
+
const edgeNeedsCollapse = (position: HandlePosition): boolean => {
|
|
274
|
+
const isHorizontal = position === 'top' || position === 'bottom';
|
|
275
|
+
return isHorizontal ? isShortForHorizontalEdge : isShortForVerticalEdge;
|
|
276
|
+
};
|
|
277
|
+
|
|
278
|
+
// Small action button in edge hover zone - dark background like corner buttons
|
|
279
|
+
const EdgeActionButton = ({
|
|
280
|
+
icon,
|
|
281
|
+
onClick,
|
|
282
|
+
title,
|
|
283
|
+
visible,
|
|
284
|
+
}: {
|
|
285
|
+
icon: React.ReactNode;
|
|
286
|
+
onClick: () => void;
|
|
287
|
+
title: string;
|
|
288
|
+
visible: boolean;
|
|
289
|
+
}) => {
|
|
290
|
+
const [isHovered, setIsHovered] = useState(false);
|
|
291
|
+
|
|
292
|
+
if (!visible) return null;
|
|
293
|
+
|
|
294
|
+
return (
|
|
295
|
+
<button
|
|
296
|
+
style={{
|
|
297
|
+
width: CORNER_HANDLE_SIZE,
|
|
298
|
+
height: CORNER_HANDLE_SIZE,
|
|
299
|
+
borderRadius: '3px',
|
|
300
|
+
backgroundColor: isHovered ? '#27272a' : '#18181b',
|
|
301
|
+
color: '#fafafa',
|
|
302
|
+
border: 'none',
|
|
303
|
+
cursor: 'pointer',
|
|
304
|
+
display: 'flex',
|
|
305
|
+
alignItems: 'center',
|
|
306
|
+
justifyContent: 'center',
|
|
307
|
+
transition: 'all 0.1s ease',
|
|
308
|
+
boxShadow: '0 1px 3px 0 rgb(0 0 0 / 0.2)',
|
|
309
|
+
pointerEvents: 'auto',
|
|
310
|
+
flexShrink: 0,
|
|
311
|
+
}}
|
|
312
|
+
onClick={(e) => {
|
|
313
|
+
e.stopPropagation();
|
|
314
|
+
e.preventDefault();
|
|
315
|
+
onClick();
|
|
316
|
+
}}
|
|
317
|
+
onMouseEnter={() => setIsHovered(true)}
|
|
318
|
+
onMouseLeave={() => setIsHovered(false)}
|
|
319
|
+
title={title}
|
|
320
|
+
>
|
|
321
|
+
{icon}
|
|
322
|
+
</button>
|
|
323
|
+
);
|
|
324
|
+
};
|
|
325
|
+
|
|
326
|
+
// Drag-enabled action button for move
|
|
327
|
+
const MoveActionButton = () => {
|
|
328
|
+
const [isHovered, setIsHovered] = useState(false);
|
|
329
|
+
|
|
330
|
+
return (
|
|
331
|
+
<button
|
|
332
|
+
style={{
|
|
333
|
+
width: CORNER_HANDLE_SIZE,
|
|
334
|
+
height: CORNER_HANDLE_SIZE,
|
|
335
|
+
borderRadius: '3px',
|
|
336
|
+
backgroundColor: isHovered ? '#27272a' : '#18181b',
|
|
337
|
+
color: '#fafafa',
|
|
338
|
+
border: 'none',
|
|
339
|
+
cursor: isDragging ? 'grabbing' : 'grab',
|
|
340
|
+
display: 'flex',
|
|
341
|
+
alignItems: 'center',
|
|
342
|
+
justifyContent: 'center',
|
|
343
|
+
transition: 'all 0.1s ease',
|
|
344
|
+
boxShadow: '0 1px 3px 0 rgb(0 0 0 / 0.2)',
|
|
345
|
+
pointerEvents: 'auto',
|
|
346
|
+
flexShrink: 0,
|
|
347
|
+
}}
|
|
348
|
+
onMouseDown={(e) => {
|
|
349
|
+
e.stopPropagation();
|
|
350
|
+
e.preventDefault();
|
|
351
|
+
setIsDragging(true);
|
|
352
|
+
}}
|
|
353
|
+
onMouseEnter={() => setIsHovered(true)}
|
|
354
|
+
onMouseLeave={() => setIsHovered(false)}
|
|
355
|
+
title="Move element (drag to new location)"
|
|
356
|
+
>
|
|
357
|
+
<MoveIcon />
|
|
358
|
+
</button>
|
|
359
|
+
);
|
|
360
|
+
};
|
|
361
|
+
|
|
362
|
+
// Edge action buttons component (used both inline and in expanded popup)
|
|
363
|
+
const EdgeButtons = ({ position }: { position: HandlePosition }) => {
|
|
364
|
+
const insertDir = getInsertDirection(position);
|
|
365
|
+
const isHorizontal = position === 'top' || position === 'bottom';
|
|
366
|
+
const separator = isHorizontal
|
|
367
|
+
? <div style={{ width: '1px', height: CORNER_HANDLE_SIZE, backgroundColor: '#3f3f46', margin: '0 2px' }} />
|
|
368
|
+
: <div style={{ height: '1px', width: CORNER_HANDLE_SIZE, backgroundColor: '#3f3f46', margin: '2px 0' }} />;
|
|
369
|
+
|
|
370
|
+
return (
|
|
371
|
+
<>
|
|
372
|
+
{/* Top edge gets the delete/copy buttons on left, then insert buttons, then move on right */}
|
|
373
|
+
{position === 'top' && (
|
|
374
|
+
<>
|
|
375
|
+
<EdgeActionButton
|
|
376
|
+
icon={<TrashIcon />}
|
|
377
|
+
onClick={() => actions.deleteElement()}
|
|
378
|
+
title="Delete element"
|
|
379
|
+
visible={true}
|
|
380
|
+
/>
|
|
381
|
+
<EdgeActionButton
|
|
382
|
+
icon={<CopyIcon />}
|
|
383
|
+
onClick={() => actions.copyElement()}
|
|
384
|
+
title="Copy element"
|
|
385
|
+
visible={true}
|
|
386
|
+
/>
|
|
387
|
+
{separator}
|
|
388
|
+
</>
|
|
389
|
+
)}
|
|
390
|
+
<EdgeActionButton
|
|
391
|
+
icon={<PlusIcon />}
|
|
392
|
+
onClick={() => actions.insertElement(insertDir)}
|
|
393
|
+
title={`Add text ${insertDir}`}
|
|
394
|
+
visible={true}
|
|
395
|
+
/>
|
|
396
|
+
<EdgeActionButton
|
|
397
|
+
icon={<PasteIcon />}
|
|
398
|
+
onClick={() => actions.pasteElement(insertDir)}
|
|
399
|
+
title={`Paste ${insertDir}`}
|
|
400
|
+
visible={!!clipboard}
|
|
401
|
+
/>
|
|
402
|
+
<EdgeActionButton
|
|
403
|
+
icon={<DuplicateIcon />}
|
|
404
|
+
onClick={actions.duplicateElement}
|
|
405
|
+
title="Duplicate element"
|
|
406
|
+
visible={true}
|
|
407
|
+
/>
|
|
408
|
+
{/* Move button on the right side of top edge bar */}
|
|
409
|
+
{position === 'top' && (
|
|
410
|
+
<>
|
|
411
|
+
{separator}
|
|
412
|
+
<MoveActionButton />
|
|
413
|
+
</>
|
|
414
|
+
)}
|
|
415
|
+
</>
|
|
416
|
+
);
|
|
417
|
+
};
|
|
418
|
+
|
|
419
|
+
// Expanded popup for collapsed edge zones
|
|
420
|
+
const EdgeExpandedPopup = ({ position }: { position: HandlePosition }) => {
|
|
421
|
+
const isHorizontal = position === 'top' || position === 'bottom';
|
|
422
|
+
|
|
423
|
+
// Position the popup to extend in the direction it needs space
|
|
424
|
+
const getPopupStyle = (): React.CSSProperties => {
|
|
425
|
+
const base: React.CSSProperties = {
|
|
426
|
+
position: 'absolute',
|
|
427
|
+
display: 'flex',
|
|
428
|
+
gap: '3px',
|
|
429
|
+
padding: '4px',
|
|
430
|
+
backgroundColor: '#18181b',
|
|
431
|
+
borderRadius: '4px',
|
|
432
|
+
boxShadow: '0 4px 6px -1px rgb(0 0 0 / 0.2)',
|
|
433
|
+
zIndex: zIndex + 2,
|
|
434
|
+
};
|
|
435
|
+
|
|
436
|
+
if (isHorizontal) {
|
|
437
|
+
// Horizontal edge: popup extends horizontally
|
|
438
|
+
return {
|
|
439
|
+
...base,
|
|
440
|
+
flexDirection: 'row',
|
|
441
|
+
top: '50%',
|
|
442
|
+
transform: 'translateY(-50%)',
|
|
443
|
+
left: position === 'top' || position === 'bottom' ? '50%' : undefined,
|
|
444
|
+
...(position === 'top' || position === 'bottom' ? { transform: 'translate(-50%, -50%)' } : {}),
|
|
445
|
+
};
|
|
446
|
+
} else {
|
|
447
|
+
// Vertical edge: popup extends vertically
|
|
448
|
+
return {
|
|
449
|
+
...base,
|
|
450
|
+
flexDirection: 'column',
|
|
451
|
+
left: '50%',
|
|
452
|
+
transform: 'translateX(-50%)',
|
|
453
|
+
top: '50%',
|
|
454
|
+
...(true ? { transform: 'translate(-50%, -50%)' } : {}),
|
|
455
|
+
};
|
|
456
|
+
}
|
|
457
|
+
};
|
|
458
|
+
|
|
459
|
+
return (
|
|
460
|
+
<div style={getPopupStyle()}>
|
|
461
|
+
<EdgeButtons position={position} />
|
|
462
|
+
</div>
|
|
463
|
+
);
|
|
464
|
+
};
|
|
465
|
+
|
|
466
|
+
// Determine insert direction based on edge and layout
|
|
467
|
+
const getInsertDirection = (position: HandlePosition): 'before' | 'after' => {
|
|
468
|
+
if (layoutDirection === 'horizontal') {
|
|
469
|
+
return position === 'left' ? 'before' : 'after';
|
|
470
|
+
} else {
|
|
471
|
+
return position === 'top' ? 'before' : 'after';
|
|
472
|
+
}
|
|
473
|
+
};
|
|
474
|
+
|
|
475
|
+
const [cornerHover, setCornerHover] = useState<'delete' | 'copy' | 'move' | 'more' | null>(null);
|
|
476
|
+
|
|
477
|
+
// Action button component for consistent styling and behavior
|
|
478
|
+
const ActionButton = ({
|
|
479
|
+
icon,
|
|
480
|
+
onClick,
|
|
481
|
+
onMouseDown,
|
|
482
|
+
title,
|
|
483
|
+
hoverKey,
|
|
484
|
+
cursor,
|
|
485
|
+
}: {
|
|
486
|
+
icon: React.ReactNode;
|
|
487
|
+
onClick?: (e: React.MouseEvent) => void;
|
|
488
|
+
onMouseDown?: (e: React.MouseEvent) => void;
|
|
489
|
+
title: string;
|
|
490
|
+
hoverKey: 'delete' | 'copy' | 'move' | 'more';
|
|
491
|
+
cursor?: string;
|
|
492
|
+
}) => (
|
|
493
|
+
<button
|
|
494
|
+
style={{
|
|
495
|
+
width: CORNER_HANDLE_SIZE,
|
|
496
|
+
height: CORNER_HANDLE_SIZE,
|
|
497
|
+
borderRadius: '3px',
|
|
498
|
+
backgroundColor: cornerHover === hoverKey ? '#27272a' : '#18181b',
|
|
499
|
+
color: '#fafafa',
|
|
500
|
+
border: 'none',
|
|
501
|
+
cursor: cursor || 'pointer',
|
|
502
|
+
display: 'flex',
|
|
503
|
+
alignItems: 'center',
|
|
504
|
+
justifyContent: 'center',
|
|
505
|
+
transition: 'background-color 0.1s ease',
|
|
506
|
+
boxShadow: '0 1px 3px 0 rgb(0 0 0 / 0.2)',
|
|
507
|
+
pointerEvents: 'auto',
|
|
508
|
+
flexShrink: 0,
|
|
509
|
+
}}
|
|
510
|
+
onClick={onClick}
|
|
511
|
+
onMouseDown={onMouseDown}
|
|
512
|
+
onMouseEnter={() => setCornerHover(hoverKey)}
|
|
513
|
+
onMouseLeave={() => setCornerHover(null)}
|
|
514
|
+
title={title}
|
|
515
|
+
>
|
|
516
|
+
{icon}
|
|
517
|
+
</button>
|
|
518
|
+
);
|
|
519
|
+
|
|
520
|
+
// Toolbar buttons (always the same, rendered conditionally based on narrow state)
|
|
521
|
+
const ToolbarButtons = () => (
|
|
522
|
+
<>
|
|
523
|
+
<ActionButton
|
|
524
|
+
icon={<MoveIcon />}
|
|
525
|
+
onMouseDown={handleMoveStart}
|
|
526
|
+
title="Move element (drag to new location)"
|
|
527
|
+
hoverKey="move"
|
|
528
|
+
cursor={isDragging ? 'grabbing' : 'grab'}
|
|
529
|
+
/>
|
|
530
|
+
<ActionButton
|
|
531
|
+
icon={<TrashIcon />}
|
|
532
|
+
onClick={(e) => {
|
|
533
|
+
e.stopPropagation();
|
|
534
|
+
e.preventDefault();
|
|
535
|
+
actions.deleteElement();
|
|
536
|
+
}}
|
|
537
|
+
title="Delete element"
|
|
538
|
+
hoverKey="delete"
|
|
539
|
+
/>
|
|
540
|
+
<ActionButton
|
|
541
|
+
icon={<CopyIcon />}
|
|
542
|
+
onClick={(e) => {
|
|
543
|
+
e.stopPropagation();
|
|
544
|
+
e.preventDefault();
|
|
545
|
+
actions.copyElement();
|
|
546
|
+
}}
|
|
547
|
+
title="Copy element"
|
|
548
|
+
hoverKey="copy"
|
|
549
|
+
/>
|
|
550
|
+
</>
|
|
551
|
+
);
|
|
552
|
+
|
|
553
|
+
return (
|
|
554
|
+
<div data-bobbin="control-handles" style={{ pointerEvents: 'none' }}>
|
|
555
|
+
{/* Edge hover zones with action buttons */}
|
|
556
|
+
{(['top', 'bottom', 'left', 'right'] as HandlePosition[]).map((position) => {
|
|
557
|
+
const isHovered = hoveredEdge === position;
|
|
558
|
+
|
|
559
|
+
return (
|
|
560
|
+
<div
|
|
561
|
+
key={position}
|
|
562
|
+
style={getEdgeZoneStyle(position)}
|
|
563
|
+
onMouseEnter={() => setHoveredEdge(position)}
|
|
564
|
+
onMouseLeave={() => setHoveredEdge(null)}
|
|
565
|
+
>
|
|
566
|
+
{isHovered && <EdgeButtons position={position} />}
|
|
567
|
+
</div>
|
|
568
|
+
);
|
|
569
|
+
})}
|
|
570
|
+
</div>
|
|
571
|
+
);
|
|
572
|
+
}
|