@dfosco/storyboard-react 3.11.1-beta.0 → 3.11.2

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 (35) hide show
  1. package/package.json +3 -3
  2. package/src/Viewfinder.jsx +5 -3
  3. package/src/__mocks__/virtual-storyboard-data-index.js +1 -0
  4. package/src/canvas/CanvasControls.jsx +2 -59
  5. package/src/canvas/CanvasControls.module.css +0 -29
  6. package/src/canvas/CanvasPage.bridge.test.jsx +68 -42
  7. package/src/canvas/CanvasPage.jsx +801 -68
  8. package/src/canvas/CanvasPage.module.css +47 -2
  9. package/src/canvas/CanvasPage.multiselect.test.jsx +345 -0
  10. package/src/canvas/canvasApi.js +8 -0
  11. package/src/canvas/computeCanvasBounds.test.js +121 -0
  12. package/src/canvas/useCanvas.js +2 -1
  13. package/src/canvas/useUndoRedo.js +86 -0
  14. package/src/canvas/useUndoRedo.test.js +231 -0
  15. package/src/canvas/widgets/ComponentWidget.jsx +9 -7
  16. package/src/canvas/widgets/FigmaEmbed.jsx +195 -0
  17. package/src/canvas/widgets/FigmaEmbed.module.css +147 -0
  18. package/src/canvas/widgets/ImageWidget.jsx +115 -0
  19. package/src/canvas/widgets/ImageWidget.module.css +39 -0
  20. package/src/canvas/widgets/MarkdownBlock.jsx +25 -8
  21. package/src/canvas/widgets/MarkdownBlock.test.jsx +53 -0
  22. package/src/canvas/widgets/PrototypeEmbed.jsx +132 -26
  23. package/src/canvas/widgets/PrototypeEmbed.module.css +66 -2
  24. package/src/canvas/widgets/StickyNote.jsx +21 -16
  25. package/src/canvas/widgets/StickyNote.test.jsx +24 -4
  26. package/src/canvas/widgets/WidgetChrome.jsx +276 -50
  27. package/src/canvas/widgets/WidgetChrome.module.css +91 -10
  28. package/src/canvas/widgets/figmaUrl.js +118 -0
  29. package/src/canvas/widgets/figmaUrl.test.js +139 -0
  30. package/src/canvas/widgets/index.js +4 -0
  31. package/src/canvas/widgets/widgetConfig.js +74 -6
  32. package/src/canvas/widgets/widgetConfig.test.js +46 -0
  33. package/src/canvas/widgets/widgetProps.js +2 -0
  34. package/src/context.jsx +34 -4
  35. package/src/context.test.jsx +13 -0
@@ -49,16 +49,22 @@ describe('StickyNote', () => {
49
49
  expect(sticky.style.height).toBe('200px')
50
50
  })
51
51
 
52
- it('renders a resize handle', () => {
53
- const { container } = render(<StickyNote props={{ text: 'Hi' }} onUpdate={vi.fn()} />)
52
+ it('renders a resize handle when resizable', () => {
53
+ const { container } = render(<StickyNote props={{ text: 'Hi' }} onUpdate={vi.fn()} resizable />)
54
54
  const handle = container.querySelector('[role="separator"]')
55
55
  expect(handle).not.toBeNull()
56
56
  })
57
57
 
58
+ it('does not render a resize handle when not resizable', () => {
59
+ const { container } = render(<StickyNote props={{ text: 'Hi' }} onUpdate={vi.fn()} resizable={false} />)
60
+ const handle = container.querySelector('[role="separator"]')
61
+ expect(handle).toBeNull()
62
+ })
63
+
58
64
  it('calls onUpdate with new dimensions on resize drag', () => {
59
65
  const onUpdate = vi.fn()
60
66
  const { container } = render(
61
- <StickyNote props={{ text: 'Hi', width: 200, height: 150 }} onUpdate={onUpdate} />
67
+ <StickyNote props={{ text: 'Hi', width: 200, height: 150 }} onUpdate={onUpdate} resizable />
62
68
  )
63
69
  const handle = container.querySelector('[role="separator"]')
64
70
  const sticky = container.querySelector('article')
@@ -78,7 +84,7 @@ describe('StickyNote', () => {
78
84
  it('enforces minimum dimensions during resize', () => {
79
85
  const onUpdate = vi.fn()
80
86
  const { container } = render(
81
- <StickyNote props={{ text: 'Hi', width: 200, height: 150 }} onUpdate={onUpdate} />
87
+ <StickyNote props={{ text: 'Hi', width: 200, height: 150 }} onUpdate={onUpdate} resizable />
82
88
  )
83
89
  const handle = container.querySelector('[role="separator"]')
84
90
  const sticky = container.querySelector('article')
@@ -93,4 +99,18 @@ describe('StickyNote', () => {
93
99
 
94
100
  expect(onUpdate).toHaveBeenCalledWith({ width: 180, height: 60 })
95
101
  })
102
+
103
+ it('does not enter edit mode without onUpdate (read-only/prod)', () => {
104
+ const { container } = render(<StickyNote props={{ text: 'Read me' }} />)
105
+ const text = container.querySelector('p')
106
+ fireEvent.doubleClick(text)
107
+ expect(container.querySelector('textarea')).toBeNull()
108
+ expect(container.querySelector('[data-canvas-allow-text-selection]')).not.toBeNull()
109
+ })
110
+
111
+ it('shows non-editable empty-state text in read-only mode', () => {
112
+ const { container } = render(<StickyNote props={{ text: '' }} />)
113
+ expect(container.textContent).toContain('No content')
114
+ expect(container.textContent).not.toContain('Double-click to edit…')
115
+ })
96
116
  })
@@ -1,4 +1,6 @@
1
- import { useState, useCallback, useRef } from 'react'
1
+ import { useState, useCallback, useRef, useEffect } from 'react'
2
+ import { Tooltip } from '@primer/react'
3
+ import { EyeIcon as OcticonEye, EyeClosedIcon as OcticonEyeClosed } from '@primer/octicons-react'
2
4
  import styles from './WidgetChrome.module.css'
3
5
 
4
6
  const STICKY_NOTE_COLORS = {
@@ -42,18 +44,213 @@ function EditIcon() {
42
44
  )
43
45
  }
44
46
 
45
- const ACTION_ICONS = {
46
- 'delete': DeleteIcon,
47
+ function OpenExternalIcon() {
48
+ return (
49
+ <svg width="12" height="12" viewBox="0 0 16 16" fill="currentColor" aria-hidden="true">
50
+ <path d="M3.75 2h3.5a.75.75 0 0 1 0 1.5h-3.5a.25.25 0 0 0-.25.25v8.5c0 .138.112.25.25.25h8.5a.25.25 0 0 0 .25-.25v-3.5a.75.75 0 0 1 1.5 0v3.5A1.75 1.75 0 0 1 12.25 14h-8.5A1.75 1.75 0 0 1 2 12.25v-8.5C2 2.784 2.784 2 3.75 2Zm6.854-1h4.146a.25.25 0 0 1 .25.25v4.146a.25.25 0 0 1-.427.177L13.03 4.03 9.28 7.78a.751.751 0 0 1-1.042-.018.751.751 0 0 1-.018-1.042l3.75-3.75-1.543-1.543A.25.25 0 0 1 10.604 1Z" />
51
+ </svg>
52
+ )
53
+ }
54
+
55
+ function EyeIcon() {
56
+ return <OcticonEye size={12} />
57
+ }
58
+
59
+ function EyeClosedIcon() {
60
+ return <OcticonEyeClosed size={12} />
61
+ }
62
+
63
+ function CopyIcon() {
64
+ return (
65
+ <svg width="12" height="12" viewBox="0 0 16 16" fill="currentColor" aria-hidden="true">
66
+ <path d="M0 6.75C0 5.784.784 5 1.75 5h1.5a.75.75 0 0 1 0 1.5h-1.5a.25.25 0 0 0-.25.25v7.5c0 .138.112.25.25.25h7.5a.25.25 0 0 0 .25-.25v-1.5a.75.75 0 0 1 1.5 0v1.5A1.75 1.75 0 0 1 9.25 16h-7.5A1.75 1.75 0 0 1 0 14.25Z" />
67
+ <path d="M5 1.75C5 .784 5.784 0 6.75 0h7.5C15.216 0 16 .784 16 1.75v7.5A1.75 1.75 0 0 1 14.25 11h-7.5A1.75 1.75 0 0 1 5 9.25Zm1.75-.25a.25.25 0 0 0-.25.25v7.5c0 .138.112.25.25.25h7.5a.25.25 0 0 0 .25-.25v-7.5a.25.25 0 0 0-.25-.25Z" />
68
+ </svg>
69
+ )
70
+ }
71
+
72
+ function MoreIcon() {
73
+ return (
74
+ <svg width="12" height="12" viewBox="0 0 16 16" fill="currentColor" aria-hidden="true">
75
+ <path d="M8 9a1.5 1.5 0 1 0 0-3 1.5 1.5 0 0 0 0 3ZM1.5 9a1.5 1.5 0 1 0 0-3 1.5 1.5 0 0 0 0 3Zm13 0a1.5 1.5 0 1 0 0-3 1.5 1.5 0 0 0 0 3Z" />
76
+ </svg>
77
+ )
78
+ }
79
+
80
+ function LinkIcon() {
81
+ return (
82
+ <svg width="12" height="12" viewBox="0 0 16 16" fill="currentColor" aria-hidden="true">
83
+ <path d="m7.775 3.275 1.25-1.25a3.5 3.5 0 1 1 4.95 4.95l-2.5 2.5a3.5 3.5 0 0 1-4.95 0 .751.751 0 0 1 .018-1.042.751.751 0 0 1 1.042-.018 1.998 1.998 0 0 0 2.83 0l2.5-2.5a2.002 2.002 0 0 0-2.83-2.83l-1.25 1.25a.751.751 0 0 1-1.042-.018.751.751 0 0 1-.018-1.042Zm-4.69 9.64a1.998 1.998 0 0 0 2.83 0l1.25-1.25a.751.751 0 0 1 1.042.018.751.751 0 0 1 .018 1.042l-1.25 1.25a3.5 3.5 0 1 1-4.95-4.95l2.5-2.5a3.5 3.5 0 0 1 4.95 0 .751.751 0 0 1-.018 1.042.751.751 0 0 1-1.042.018 1.998 1.998 0 0 0-2.83 0l-2.5 2.5a1.998 1.998 0 0 0 0 2.83Z" />
84
+ </svg>
85
+ )
86
+ }
87
+
88
+ function ChevronDownIcon() {
89
+ return (
90
+ <svg width="12" height="12" viewBox="0 0 16 16" fill="currentColor" aria-hidden="true">
91
+ <path d="M12.78 5.22a.749.749 0 0 1 0 1.06l-4.25 4.25a.749.749 0 0 1-1.06 0L3.22 6.28a.749.749 0 1 1 1.06-1.06L8 8.939l3.72-3.719a.749.749 0 0 1 1.06 0Z" />
92
+ </svg>
93
+ )
94
+ }
95
+
96
+ function DownloadIcon() {
97
+ return (
98
+ <svg width="12" height="12" viewBox="0 0 16 16" fill="currentColor" aria-hidden="true">
99
+ <path d="M2.75 14A1.75 1.75 0 0 1 1 12.25v-2.5a.75.75 0 0 1 1.5 0v2.5c0 .138.112.25.25.25h10.5a.25.25 0 0 0 .25-.25v-2.5a.75.75 0 0 1 1.5 0v2.5A1.75 1.75 0 0 1 13.25 14Z" />
100
+ <path d="M7.25 7.689V2a.75.75 0 0 1 1.5 0v5.689l1.97-1.969a.749.749 0 1 1 1.06 1.06l-3.25 3.25a.749.749 0 0 1-1.06 0L4.22 6.78a.749.749 0 1 1 1.06-1.06Z" />
101
+ </svg>
102
+ )
103
+ }
104
+
105
+ function ExpandIcon() {
106
+ return (
107
+ <svg width="12" height="12" viewBox="0 0 16 16" fill="currentColor" aria-hidden="true">
108
+ <path d="M1.75 10a.75.75 0 0 1 .75.75v2.5c0 .138.112.25.25.25h2.5a.75.75 0 0 1 0 1.5h-2.5A1.75 1.75 0 0 1 1 13.25v-2.5a.75.75 0 0 1 .75-.75Zm12.5 0a.75.75 0 0 1 .75.75v2.5A1.75 1.75 0 0 1 13.25 15h-2.5a.75.75 0 0 1 0-1.5h2.5a.25.25 0 0 0 .25-.25v-2.5a.75.75 0 0 1 .75-.75ZM2.75 1h2.5a.75.75 0 0 1 0 1.5h-2.5a.25.25 0 0 0-.25.25v2.5a.75.75 0 0 1-1.5 0v-2.5C1 1.784 1.784 1 2.75 1Zm10.5 0C14.216 1 15 1.784 15 2.75v2.5a.75.75 0 0 1-1.5 0v-2.5a.25.25 0 0 0-.25-.25h-2.5a.75.75 0 0 1 0-1.5Z" />
109
+ </svg>
110
+ )
111
+ }
112
+
113
+ /** Icon registry — maps icon name strings from config to React components. */
114
+ const ICON_REGISTRY = {
115
+ 'trash': DeleteIcon,
47
116
  'zoom-in': ZoomInIcon,
48
117
  'zoom-out': ZoomOutIcon,
49
118
  'edit': EditIcon,
119
+ 'open-external': OpenExternalIcon,
120
+ 'eye': EyeIcon,
121
+ 'eye-closed': EyeClosedIcon,
122
+ 'copy': CopyIcon,
123
+ 'link': LinkIcon,
124
+ 'more': MoreIcon,
125
+ 'chevron-down': ChevronDownIcon,
126
+ 'download': DownloadIcon,
127
+ 'expand': ExpandIcon,
50
128
  }
51
129
 
52
- const ACTION_LABELS = {
53
- 'delete': 'Delete widget',
54
- 'zoom-in': 'Zoom in',
55
- 'zoom-out': 'Zoom out',
56
- 'edit': 'Edit',
130
+ /** Danger-styled actions in the overflow menu. */
131
+ const DANGER_ACTIONS = new Set(['delete'])
132
+
133
+ /**
134
+ * Overflow menu — `...` button that opens a dropdown with menu-only actions.
135
+ */
136
+ function WidgetOverflowMenu({ widgetId, menuFeatures, onAction }) {
137
+ const [open, setOpen] = useState(false)
138
+ const menuRef = useRef(null)
139
+
140
+ useEffect(() => {
141
+ if (!open) return
142
+ function handlePointerDown(e) {
143
+ if (menuRef.current && !menuRef.current.contains(e.target)) {
144
+ setOpen(false)
145
+ }
146
+ }
147
+ document.addEventListener('pointerdown', handlePointerDown)
148
+ return () => document.removeEventListener('pointerdown', handlePointerDown)
149
+ }, [open])
150
+
151
+ const handleItemClick = useCallback((action, e) => {
152
+ e.stopPropagation()
153
+ if (action === 'copy-link') {
154
+ const url = new URL(window.location.href)
155
+ url.searchParams.set('widget', widgetId)
156
+ navigator.clipboard.writeText(url.toString()).catch(() => {})
157
+ } else {
158
+ onAction?.(action)
159
+ }
160
+ setOpen(false)
161
+ }, [widgetId, onAction])
162
+
163
+ return (
164
+ <div ref={menuRef} className={styles.overflowWrapper}>
165
+ <Tooltip text="More actions" direction="n">
166
+ <button
167
+ className={styles.featureBtn}
168
+ onClick={(e) => { e.stopPropagation(); setOpen((v) => !v) }}
169
+ aria-label="More actions"
170
+ aria-expanded={open}
171
+ >
172
+ <MoreIcon />
173
+ </button>
174
+ </Tooltip>
175
+ {open && (
176
+ <div className={styles.overflowMenu}>
177
+ {menuFeatures.map((feature) => {
178
+ const Icon = ICON_REGISTRY[feature.icon]
179
+ const label = feature.label || feature.action
180
+ const isDanger = DANGER_ACTIONS.has(feature.action)
181
+ return (
182
+ <button
183
+ key={feature.id}
184
+ className={`${styles.overflowItem} ${isDanger ? styles.overflowItemDanger : ''}`}
185
+ onClick={(e) => handleItemClick(feature.action, e)}
186
+ >
187
+ {Icon && <Icon />}
188
+ <span>{label}</span>
189
+ </button>
190
+ )
191
+ })}
192
+ </div>
193
+ )}
194
+ </div>
195
+ )
196
+ }
197
+
198
+ /**
199
+ * Dropdown feature — a chevron button that opens a menu of actions.
200
+ * Items and their icons/labels come from config.
201
+ */
202
+ function DropdownFeature({ feature, onAction }) {
203
+ const [open, setOpen] = useState(false)
204
+ const menuRef = useRef(null)
205
+
206
+ useEffect(() => {
207
+ if (!open) return
208
+ function handlePointerDown(e) {
209
+ if (menuRef.current && !menuRef.current.contains(e.target)) {
210
+ setOpen(false)
211
+ }
212
+ }
213
+ document.addEventListener('pointerdown', handlePointerDown)
214
+ return () => document.removeEventListener('pointerdown', handlePointerDown)
215
+ }, [open])
216
+
217
+ const TriggerIcon = ICON_REGISTRY[feature.icon] || ChevronDownIcon
218
+
219
+ return (
220
+ <div ref={menuRef} className={styles.overflowWrapper}>
221
+ <Tooltip text={feature.label || 'Actions'} direction="n">
222
+ <button
223
+ className={styles.featureBtn}
224
+ onClick={(e) => { e.stopPropagation(); setOpen((v) => !v) }}
225
+ aria-label={feature.label || 'Actions'}
226
+ aria-expanded={open}
227
+ >
228
+ <TriggerIcon />
229
+ </button>
230
+ </Tooltip>
231
+ {open && (
232
+ <div className={styles.overflowMenu}>
233
+ {(feature.items || []).map((item) => {
234
+ const Icon = ICON_REGISTRY[item.icon]
235
+ return (
236
+ <button
237
+ key={item.action}
238
+ className={styles.overflowItem}
239
+ onClick={(e) => {
240
+ e.stopPropagation()
241
+ onAction?.(item.action)
242
+ setOpen(false)
243
+ }}
244
+ >
245
+ {Icon && <Icon />}
246
+ <span>{item.label || item.action}</span>
247
+ </button>
248
+ )
249
+ })}
250
+ </div>
251
+ )}
252
+ </div>
253
+ )
57
254
  }
58
255
 
59
256
  /**
@@ -113,19 +310,21 @@ function ColorPickerFeature({ currentColor, options, onColorChange }) {
113
310
  * non-standard actions (anything other than 'delete').
114
311
  */
115
312
  export default function WidgetChrome({
313
+ widgetId,
116
314
  features = [],
117
315
  selected = false,
316
+ multiSelected = false,
118
317
  widgetProps,
119
318
  widgetRef,
120
319
  onSelect,
121
- onDeselect,
320
+ onDeselect, // eslint-disable-line no-unused-vars
122
321
  onAction,
123
322
  onUpdate,
124
323
  children,
324
+ readOnly = false,
125
325
  }) {
126
326
  const [hovered, setHovered] = useState(false)
127
327
  const leaveTimer = useRef(null)
128
- const pointerStartPos = useRef(null)
129
328
 
130
329
  const handleMouseEnter = useCallback(() => {
131
330
  clearTimeout(leaveTimer.current)
@@ -136,30 +335,18 @@ export default function WidgetChrome({
136
335
  leaveTimer.current = setTimeout(() => setHovered(false), 80)
137
336
  }, [])
138
337
 
139
- // Track pointer position on the handle to distinguish click from drag.
140
- const handleHandlePointerDown = useCallback((e) => {
141
- pointerStartPos.current = { x: e.clientX, y: e.clientY }
142
- }, [])
143
-
144
- const handleHandlePointerUp = useCallback((e) => {
145
- if (!pointerStartPos.current) return
146
- const start = pointerStartPos.current
147
- pointerStartPos.current = null
148
- // Only toggle selection if the pointer stayed close (click, not drag)
149
- const dist = Math.hypot(e.clientX - start.x, e.clientY - start.y)
150
- if (dist > 10) return
338
+ // Handle select via click pointer events are intercepted by the drag
339
+ // gate in Draggable, so onPointerDown never reaches React on the handle.
340
+ // onClick fires reliably after pointer up.
341
+ const handleHandleClick = useCallback((e) => {
151
342
  e.stopPropagation()
152
- if (selected) {
153
- onDeselect?.()
154
- } else {
155
- onSelect?.()
156
- }
157
- }, [selected, onSelect, onDeselect])
343
+ onSelect?.(e.shiftKey)
344
+ }, [onSelect])
158
345
 
159
346
  const handleActionClick = useCallback((actionId, e) => {
160
347
  e.stopPropagation()
161
348
  // Standard actions go through onAction (handled by CanvasPage)
162
- if (actionId === 'delete') {
349
+ if (actionId === 'delete' || actionId === 'copy') {
163
350
  onAction?.(actionId)
164
351
  return
165
352
  }
@@ -176,15 +363,16 @@ export default function WidgetChrome({
176
363
  onUpdate?.({ color })
177
364
  }, [onUpdate])
178
365
 
179
- const showToolbar = hovered || selected
366
+ const showToolbar = !readOnly && (hovered || selected)
367
+ const showFeatures = showToolbar && !multiSelected
180
368
 
181
369
  return (
182
370
  <div
183
371
  className={styles.chromeContainer}
184
- onMouseEnter={handleMouseEnter}
185
- onMouseLeave={handleMouseLeave}
372
+ onMouseEnter={readOnly ? undefined : handleMouseEnter}
373
+ onMouseLeave={readOnly ? undefined : handleMouseLeave}
186
374
  >
187
- <div className={`${styles.widgetSlot} ${selected ? styles.widgetSlotSelected : ''}`}>
375
+ <div className={`tc-drag-surface ${styles.widgetSlot} ${selected ? styles.widgetSlotSelected : ''} ${multiSelected ? styles.widgetSlotMultiSelected : ''}`}>
188
376
  {children}
189
377
  </div>
190
378
  <div
@@ -197,8 +385,12 @@ export default function WidgetChrome({
197
385
 
198
386
  {/* Toolbar content — visible on hover */}
199
387
  <div className={`${styles.toolbarContent} ${showToolbar ? styles.toolbarContentVisible : ''}`}>
388
+ {showFeatures && (
200
389
  <div className={styles.featureButtons}>
201
390
  {features.map((feature) => {
391
+ // Menu features are rendered in WidgetOverflowMenu
392
+ if (feature.menu) return null
393
+
202
394
  if (feature.type === 'color-picker') {
203
395
  return (
204
396
  <ColorPickerFeature
@@ -211,32 +403,66 @@ export default function WidgetChrome({
211
403
  }
212
404
 
213
405
  if (feature.type === 'action') {
214
- const Icon = ACTION_ICONS[feature.action]
406
+ let Icon = ICON_REGISTRY[feature.icon]
407
+ let label = feature.label || feature.action
408
+
409
+ // Toggle-private: swap icon/label based on current state
410
+ if (feature.action === 'toggle-private') {
411
+ if (widgetProps?.private) {
412
+ Icon = ICON_REGISTRY['eye-closed']
413
+ label = 'Private image — only visible locally'
414
+ } else {
415
+ label = 'Published image — deployed with canvas'
416
+ }
417
+ }
418
+
419
+ return (
420
+ <Tooltip key={feature.id} text={label} direction="n">
421
+ <button
422
+ className={styles.featureBtn}
423
+ onClick={(e) => handleActionClick(feature.action, e)}
424
+ aria-label={label}
425
+ >
426
+ {Icon ? <Icon /> : feature.action}
427
+ </button>
428
+ </Tooltip>
429
+ )
430
+ }
431
+
432
+ if (feature.type === 'dropdown') {
215
433
  return (
216
- <button
434
+ <DropdownFeature
217
435
  key={feature.id}
218
- className={styles.featureBtn}
219
- onClick={(e) => handleActionClick(feature.action, e)}
220
- title={ACTION_LABELS[feature.action] || feature.action}
221
- aria-label={ACTION_LABELS[feature.action] || feature.action}
222
- >
223
- {Icon ? <Icon /> : feature.action}
224
- </button>
436
+ feature={feature}
437
+ onAction={(actionId) => {
438
+ if (widgetRef?.current?.handleAction) {
439
+ widgetRef.current.handleAction(actionId)
440
+ } else {
441
+ onAction?.(actionId)
442
+ }
443
+ }}
444
+ />
225
445
  )
226
446
  }
227
447
 
228
448
  return null
229
449
  })}
450
+ <WidgetOverflowMenu
451
+ widgetId={widgetId}
452
+ menuFeatures={features.filter((f) => f.menu)}
453
+ onAction={onAction}
454
+ />
230
455
  </div>
456
+ )}
231
457
 
232
- <button
233
- className={`tc-drag-handle ${styles.selectHandle} ${selected ? styles.selectHandleActive : ''}`}
234
- onPointerDown={handleHandlePointerDown}
235
- onPointerUp={handleHandlePointerUp}
236
- title={selected ? 'Deselect' : 'Select'}
237
- aria-label={selected ? 'Deselect widget' : 'Select widget'}
238
- aria-pressed={selected}
239
- />
458
+ <Tooltip text={selected ? "Click and drag to move" : "Select"} direction="n">
459
+ <button
460
+ className={`tc-drag-handle ${styles.selectHandle} ${selected ? styles.selectHandleActive : ''}`}
461
+ onClick={handleHandleClick}
462
+ aria-label={selected ? "Drag to move widget" : "Select widget"}
463
+ aria-pressed={selected}
464
+ />
465
+ </Tooltip>
240
466
  </div>
241
467
  </div>
242
468
  </div>
@@ -11,11 +11,15 @@
11
11
  }
12
12
 
13
13
  .widgetSlotSelected {
14
- outline: 2px solid var(--bgColor-accent-emphasis, #2f81f7);
14
+ outline: 4px solid var(--bgColor-accent-emphasis, #2f81f7);
15
15
  outline-offset: 2px;
16
16
  border-radius: 4px;
17
17
  }
18
18
 
19
+ .widgetSlotMultiSelected {
20
+ outline-style: solid;
21
+ }
22
+
19
23
  /* Toolbar — absolutely positioned below the widget so it doesn't affect
20
24
  the draggable box dimensions (tiny-canvas measures children for drag). */
21
25
  .toolbar {
@@ -26,7 +30,7 @@
26
30
  position: absolute;
27
31
  left: 0;
28
32
  right: 0;
29
- top: calc(100% + 4px);
33
+ top: calc(100% + 10px);
30
34
  }
31
35
 
32
36
  /* Trigger dot — centered, visible at rest */
@@ -57,7 +61,7 @@
57
61
  .toolbarContent {
58
62
  display: flex;
59
63
  align-items: center;
60
- justify-content: space-between;
64
+ justify-content: flex-start;
61
65
  width: 100%;
62
66
  opacity: 0;
63
67
  pointer-events: none;
@@ -115,13 +119,14 @@
115
119
  .selectHandle {
116
120
  all: unset;
117
121
  cursor: grab;
118
- width: 18px;
119
- height: 12px;
122
+ width: 16px;
123
+ height: 16px;
120
124
  border-radius: 4px;
121
125
  border: 1.6px solid var(--borderColor-muted, #d0d7de);
122
126
  background: var(--bgColor-default, #ffffff);
123
127
  transition: background 100ms, border-color 100ms;
124
128
  flex-shrink: 0;
129
+ margin-left: auto;
125
130
  }
126
131
 
127
132
  :global([data-sb-canvas-theme^='dark']) .selectHandle {
@@ -133,12 +138,14 @@
133
138
  border-color: var(--bgColor-accent-emphasis, #2f81f7);
134
139
  }
135
140
 
136
- .selectHandleActive {
141
+ .selectHandleActive,
142
+ :global([data-sb-canvas-theme^='dark']) .selectHandleActive {
137
143
  background: var(--bgColor-accent-emphasis, #2f81f7);
138
144
  border-color: var(--bgColor-accent-emphasis, #2f81f7);
139
145
  }
140
146
 
141
- .selectHandleActive:hover {
147
+ .selectHandleActive:hover,
148
+ :global([data-sb-canvas-theme^='dark']) .selectHandleActive:hover {
142
149
  background: var(--bgColor-accent-emphasis, #388bfd);
143
150
  border-color: var(--bgColor-accent-emphasis, #388bfd);
144
151
  }
@@ -159,9 +166,8 @@
159
166
 
160
167
  .colorPopup {
161
168
  position: absolute;
162
- bottom: calc(100% + 6px);
163
- left: 50%;
164
- transform: translateX(-50%);
169
+ top: calc(100% + 2px);
170
+ left: -4px;
165
171
  display: flex;
166
172
  gap: 5px;
167
173
  padding: 6px 10px;
@@ -177,6 +183,17 @@
177
183
  white-space: nowrap;
178
184
  }
179
185
 
186
+ /* Invisible bridge from the trigger button to the popup so mouse
187
+ travel doesn't create a gap that closes the picker. */
188
+ .colorPopup::before {
189
+ content: '';
190
+ position: absolute;
191
+ bottom: 100%;
192
+ left: 0;
193
+ right: 0;
194
+ height: 8px;
195
+ }
196
+
180
197
  :global([data-sb-canvas-theme^='dark']) .colorPopup {
181
198
  background: var(--bgColor-muted, #161b22);
182
199
  box-shadow:
@@ -207,3 +224,67 @@
207
224
  border-color: currentColor;
208
225
  box-shadow: 0 0 0 1px currentColor;
209
226
  }
227
+
228
+ /* Overflow menu */
229
+ .overflowWrapper {
230
+ position: relative;
231
+ display: flex;
232
+ align-items: center;
233
+ }
234
+
235
+ .overflowMenu {
236
+ position: absolute;
237
+ top: calc(100% + 10px);
238
+ right: 0;
239
+ min-width: 180px;
240
+ padding: 4px;
241
+ background: var(--bgColor-default, #ffffff);
242
+ border-radius: 10px;
243
+ box-shadow:
244
+ 0 0 0 1px rgba(0, 0, 0, 0.08),
245
+ 0 4px 12px rgba(0, 0, 0, 0.12);
246
+ z-index: 10;
247
+ }
248
+
249
+ :global([data-sb-canvas-theme^='dark']) .overflowMenu {
250
+ background: var(--bgColor-muted, #161b22);
251
+ box-shadow:
252
+ 0 0 0 1px rgba(255, 255, 255, 0.08),
253
+ 0 4px 12px rgba(0, 0, 0, 0.45);
254
+ }
255
+
256
+ .overflowItem {
257
+ all: unset;
258
+ cursor: pointer;
259
+ display: flex;
260
+ align-items: center;
261
+ gap: 8px;
262
+ width: 100%;
263
+ padding: 6px 10px;
264
+ font-size: 12px;
265
+ color: var(--fgColor-default, #1f2328);
266
+ border-radius: 6px;
267
+ box-sizing: border-box;
268
+ }
269
+
270
+ :global([data-sb-canvas-theme^='dark']) .overflowItem {
271
+ color: var(--fgColor-default, #e6edf3);
272
+ }
273
+
274
+ .overflowItem:hover {
275
+ background: var(--bgColor-neutral-muted, #eaeef2);
276
+ }
277
+
278
+ :global([data-sb-canvas-theme^='dark']) .overflowItem:hover {
279
+ background: var(--bgColor-neutral-muted, #272c33);
280
+ }
281
+
282
+ .overflowItemDanger:hover {
283
+ background: var(--bgColor-danger-muted, #ffebe9);
284
+ color: var(--fgColor-danger, #d1242f);
285
+ }
286
+
287
+ :global([data-sb-canvas-theme^='dark']) .overflowItemDanger:hover {
288
+ background: var(--bgColor-danger-muted, #490202);
289
+ color: var(--fgColor-danger, #f85149);
290
+ }