@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.
Files changed (45) hide show
  1. package/.turbo/turbo-build.log +16 -0
  2. package/LICENSE +373 -0
  3. package/dist/index.d.ts +402 -0
  4. package/dist/index.js +3704 -0
  5. package/package.json +30 -0
  6. package/src/Bobbin.tsx +89 -0
  7. package/src/components/EditPanel/EditPanel.tsx +376 -0
  8. package/src/components/EditPanel/controls/ColorPicker.tsx +138 -0
  9. package/src/components/EditPanel/controls/QuickSelectDropdown.tsx +142 -0
  10. package/src/components/EditPanel/controls/SliderInput.tsx +94 -0
  11. package/src/components/EditPanel/controls/SpacingControl.tsx +285 -0
  12. package/src/components/EditPanel/controls/ToggleGroup.tsx +37 -0
  13. package/src/components/EditPanel/controls/TokenDropdown.tsx +33 -0
  14. package/src/components/EditPanel/sections/AnnotationSection.tsx +136 -0
  15. package/src/components/EditPanel/sections/BackgroundSection.tsx +79 -0
  16. package/src/components/EditPanel/sections/EffectsSection.tsx +85 -0
  17. package/src/components/EditPanel/sections/LayoutSection.tsx +224 -0
  18. package/src/components/EditPanel/sections/SectionWrapper.tsx +57 -0
  19. package/src/components/EditPanel/sections/SizeSection.tsx +166 -0
  20. package/src/components/EditPanel/sections/SpacingSection.tsx +69 -0
  21. package/src/components/EditPanel/sections/TypographySection.tsx +148 -0
  22. package/src/components/Inspector/Inspector.tsx +221 -0
  23. package/src/components/Overlay/ControlHandles.tsx +572 -0
  24. package/src/components/Overlay/MarginPaddingOverlay.tsx +229 -0
  25. package/src/components/Overlay/SelectionOverlay.tsx +73 -0
  26. package/src/components/Pill/Pill.tsx +155 -0
  27. package/src/components/ThemeToggle/ThemeToggle.tsx +72 -0
  28. package/src/core/changeSerializer.ts +139 -0
  29. package/src/core/useBobbin.ts +399 -0
  30. package/src/core/useChangeTracker.ts +186 -0
  31. package/src/core/useClipboard.ts +21 -0
  32. package/src/core/useElementSelection.ts +146 -0
  33. package/src/index.ts +46 -0
  34. package/src/tokens/borders.ts +19 -0
  35. package/src/tokens/colors.ts +150 -0
  36. package/src/tokens/index.ts +37 -0
  37. package/src/tokens/shadows.ts +10 -0
  38. package/src/tokens/spacing.ts +37 -0
  39. package/src/tokens/typography.ts +51 -0
  40. package/src/types.ts +157 -0
  41. package/src/utils/animation.ts +40 -0
  42. package/src/utils/dom.ts +36 -0
  43. package/src/utils/selectors.ts +76 -0
  44. package/tsconfig.json +10 -0
  45. 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
+ }