@dfosco/storyboard-react 3.11.0-beta.6 → 3.11.0-beta.7

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/package.json CHANGED
@@ -1,10 +1,10 @@
1
1
  {
2
2
  "name": "@dfosco/storyboard-react",
3
- "version": "3.11.0-beta.6",
3
+ "version": "3.11.0-beta.7",
4
4
  "type": "module",
5
5
  "dependencies": {
6
- "@dfosco/storyboard-core": "3.11.0-beta.6",
7
- "@dfosco/tiny-canvas": "3.11.0-beta.6",
6
+ "@dfosco/storyboard-core": "3.11.0-beta.7",
7
+ "@dfosco/tiny-canvas": "3.11.0-beta.7",
8
8
  "@neodrag/react": "^2.3.1",
9
9
  "glob": "^11.0.0",
10
10
  "jsonc-parser": "^3.3.1"
@@ -45,9 +45,11 @@ export default function Viewfinder({ pageModules = {}, basePath, title = 'Storyb
45
45
  showThumbnails,
46
46
  hideDefaultFlow: shouldHideDefault,
47
47
  })
48
- // Reveal after CSS has been processed to prevent FOUC
49
- requestAnimationFrame(() => {
50
- if (containerRef.current) containerRef.current.style.opacity = '1'
48
+ // Wait for styles to be fully loaded before revealing
49
+ handleRef.current.ready.then(() => {
50
+ requestAnimationFrame(() => {
51
+ if (containerRef.current) containerRef.current.style.opacity = '1'
52
+ })
51
53
  })
52
54
  })
53
55
 
@@ -224,6 +224,7 @@ function WidgetRenderer({ widget, onUpdate, widgetRef }) {
224
224
  function ChromeWrappedWidget({
225
225
  widget,
226
226
  selected,
227
+ multiSelected,
227
228
  onSelect,
228
229
  onDeselect,
229
230
  onUpdate,
@@ -248,6 +249,7 @@ function ChromeWrappedWidget({
248
249
  widgetType={widget.type}
249
250
  features={features}
250
251
  selected={selected}
252
+ multiSelected={multiSelected}
251
253
  widgetProps={widget.props}
252
254
  widgetRef={widgetRef}
253
255
  onSelect={onSelect}
@@ -278,7 +280,7 @@ export default function CanvasPage({ name }) {
278
280
  // Local mutable copy of widgets for instant UI updates
279
281
  const [localWidgets, setLocalWidgets] = useState(canvas?.widgets ?? null)
280
282
  const [trackedCanvas, setTrackedCanvas] = useState(canvas)
281
- const [selectedWidgetId, setSelectedWidgetId] = useState(null)
283
+ const [selectedWidgetIds, setSelectedWidgetIds] = useState(() => new Set())
282
284
  const initialViewport = loadViewportState(name)
283
285
  const [zoom, setZoom] = useState(initialViewport?.zoom ?? 100)
284
286
  const zoomRef = useRef(initialViewport?.zoom ?? 100)
@@ -307,6 +309,92 @@ export default function CanvasPage({ name }) {
307
309
  return writeQueueRef.current
308
310
  }
309
311
 
312
+ // Ref for selectedWidgetIds to avoid stale closures in callbacks
313
+ const selectedIdsRef = useRef(selectedWidgetIds)
314
+ useEffect(() => {
315
+ selectedIdsRef.current = selectedWidgetIds
316
+ }, [selectedWidgetIds])
317
+
318
+ const isMultiSelected = selectedWidgetIds.size > 1
319
+
320
+ /**
321
+ * Selection handler — shift+click toggles in/out of multi-select set,
322
+ * plain click single-selects (clears others).
323
+ */
324
+ const handleWidgetSelect = useCallback((widgetId, shiftKey) => {
325
+ if (shiftKey) {
326
+ setSelectedWidgetIds(prev => {
327
+ const next = new Set(prev)
328
+ if (next.has(widgetId)) {
329
+ next.delete(widgetId)
330
+ } else {
331
+ next.add(widgetId)
332
+ }
333
+ return next
334
+ })
335
+ } else {
336
+ setSelectedWidgetIds(new Set([widgetId]))
337
+ }
338
+ }, [])
339
+
340
+ // --- Multi-select live drag preview via imperative DOM transforms ---
341
+ // On drag start, snapshot ALL articles' translate values (same coord space).
342
+ // On each tick, read dragged article's current translate, compute delta
343
+ // from its snapshot, apply same delta to all peers.
344
+ const draggedArticleRef = useRef(null)
345
+ const draggedStartTranslate = useRef({ x: 0, y: 0 })
346
+ const peerSnapshots = useRef(new Map())
347
+
348
+ function parseTranslate(article) {
349
+ const raw = article?.style.translate || '0px 0px'
350
+ const parts = raw.match(/-?[\d.]+/g) || [0, 0]
351
+ return { x: parseFloat(parts[0]) || 0, y: parseFloat(parts[1]) || 0 }
352
+ }
353
+
354
+ const handleItemDragStart = useCallback((dragId) => {
355
+ const ids = selectedIdsRef.current
356
+ peerSnapshots.current.clear()
357
+ draggedArticleRef.current = null
358
+ if (ids.size <= 1 || !ids.has(dragId)) return
359
+
360
+ // Snapshot dragged widget's article translate
361
+ const draggedEl = document.getElementById(dragId)
362
+ const draggedArticle = draggedEl?.closest('article')
363
+ if (!draggedArticle) return
364
+ draggedArticleRef.current = draggedArticle
365
+ draggedStartTranslate.current = parseTranslate(draggedArticle)
366
+
367
+ // Snapshot each peer's article translate
368
+ for (const id of ids) {
369
+ if (id === dragId) continue
370
+ const widgetEl = document.getElementById(id)
371
+ const article = widgetEl?.closest('article')
372
+ if (!article) continue
373
+ peerSnapshots.current.set(id, {
374
+ article,
375
+ ...parseTranslate(article),
376
+ })
377
+ }
378
+ }, [])
379
+
380
+ const handleItemDrag = useCallback(() => {
381
+ if (!draggedArticleRef.current) return
382
+
383
+ // Read dragged article's CURRENT translate (set by neodrag)
384
+ const current = parseTranslate(draggedArticleRef.current)
385
+ const dx = current.x - draggedStartTranslate.current.x
386
+ const dy = current.y - draggedStartTranslate.current.y
387
+
388
+ for (const [, peer] of peerSnapshots.current) {
389
+ peer.article.style.translate = `${peer.x + dx}px ${peer.y + dy}px`
390
+ }
391
+ }, [])
392
+
393
+ const clearDragPreview = useCallback(() => {
394
+ peerSnapshots.current.clear()
395
+ draggedArticleRef.current = null
396
+ }, [])
397
+
310
398
  if (canvas !== trackedCanvas) {
311
399
  setTrackedCanvas(canvas)
312
400
  setLocalWidgets(canvas?.widgets ?? null)
@@ -447,6 +535,44 @@ export default function CanvasPage({ name }) {
447
535
  return
448
536
  }
449
537
 
538
+ const ids = selectedIdsRef.current
539
+ // Multi-select move: apply same delta to all selected widgets
540
+ if (ids.size > 1 && ids.has(dragId)) {
541
+ clearDragPreview()
542
+ undoRedo.snapshot(stateRef.current, 'multi-move')
543
+ const currentWidgets = stateRef.current.widgets ?? []
544
+ const draggedWidget = currentWidgets.find(w => w.id === dragId)
545
+ if (!draggedWidget) return
546
+ const oldPos = draggedWidget.position || { x: 0, y: 0 }
547
+ const dx = rounded.x - oldPos.x
548
+ const dy = rounded.y - oldPos.y
549
+
550
+ debouncedSave.cancel()
551
+ setLocalWidgets((prev) => {
552
+ if (!prev) return prev
553
+ const next = prev.map((w) => {
554
+ if (w.id === dragId) return { ...w, position: rounded }
555
+ if (ids.has(w.id)) {
556
+ return {
557
+ ...w,
558
+ position: {
559
+ x: Math.max(0, roundPosition((w.position?.x ?? 0) + dx)),
560
+ y: Math.max(0, roundPosition((w.position?.y ?? 0) + dy)),
561
+ },
562
+ }
563
+ }
564
+ return w
565
+ })
566
+ queueWrite(() =>
567
+ updateCanvas(name, { widgets: next }).catch((err) =>
568
+ console.error('[canvas] Failed to save multi-move:', err)
569
+ )
570
+ )
571
+ return next
572
+ })
573
+ return
574
+ }
575
+
450
576
  undoRedo.snapshot(stateRef.current, 'move', dragId)
451
577
  setLocalWidgets((prev) => {
452
578
  if (!prev) return prev
@@ -460,7 +586,7 @@ export default function CanvasPage({ name }) {
460
586
  )
461
587
  return next
462
588
  })
463
- }, [name, undoRedo])
589
+ }, [name, undoRedo, debouncedSave, clearDragPreview])
464
590
 
465
591
  useEffect(() => {
466
592
  zoomRef.current = zoom
@@ -780,22 +906,39 @@ export default function CanvasPage({ name }) {
780
906
 
781
907
  useEffect(() => {
782
908
  function handleKeyDown(e) {
783
- if (!selectedWidgetId) return
909
+ if (selectedWidgetIds.size === 0) return
784
910
  const tag = e.target.tagName
785
911
  if (tag === 'INPUT' || tag === 'TEXTAREA' || e.target.isContentEditable) return
786
912
  if (e.key === 'Escape') {
787
913
  e.preventDefault()
788
- setSelectedWidgetId(null)
914
+ setSelectedWidgetIds(new Set())
789
915
  }
790
916
  if (e.key === 'Delete' || e.key === 'Backspace') {
791
917
  e.preventDefault()
792
- handleWidgetRemove(selectedWidgetId)
793
- setSelectedWidgetId(null)
918
+ if (selectedWidgetIds.size > 1) {
919
+ // Multi-delete — snapshot once, remove all, persist via updateCanvas
920
+ undoRedo.snapshot(stateRef.current, 'multi-remove')
921
+ debouncedSave.cancel()
922
+ setLocalWidgets((prev) => {
923
+ if (!prev) return prev
924
+ const next = prev.filter(w => !selectedWidgetIds.has(w.id))
925
+ queueWrite(() =>
926
+ updateCanvas(name, { widgets: next }).catch(err =>
927
+ console.error('[canvas] Failed to save multi-delete:', err)
928
+ )
929
+ )
930
+ return next
931
+ })
932
+ } else {
933
+ const widgetId = [...selectedWidgetIds][0]
934
+ if (widgetId) handleWidgetRemove(widgetId)
935
+ }
936
+ setSelectedWidgetIds(new Set())
794
937
  }
795
938
  }
796
939
  document.addEventListener('keydown', handleKeyDown)
797
940
  return () => document.removeEventListener('keydown', handleKeyDown)
798
- }, [selectedWidgetId, handleWidgetRemove])
941
+ }, [selectedWidgetIds, handleWidgetRemove, undoRedo, name, debouncedSave])
799
942
 
800
943
  // Paste handler — images become image widgets, same-origin URLs become prototypes,
801
944
  // other URLs become link previews, text becomes markdown
@@ -1120,6 +1263,7 @@ export default function CanvasPage({ name }) {
1120
1263
  colorMode: canvas.colorMode === 'auto'
1121
1264
  ? getToolbarColorMode(canvasTheme)
1122
1265
  : (canvas.colorMode ?? 'auto'),
1266
+ locked: !isLocalDev,
1123
1267
  }
1124
1268
 
1125
1269
  const canvasThemeVars = getCanvasThemeVars(canvasTheme)
@@ -1146,20 +1290,22 @@ export default function CanvasPage({ name }) {
1146
1290
  id={`jsx-${exportName}`}
1147
1291
  data-tc-x={sourcePosition.x}
1148
1292
  data-tc-y={sourcePosition.y}
1149
- {...(isLocalDev ? { 'data-tc-handle': '.tc-drag-handle' } : {})}
1293
+ {...(isLocalDev ? { 'data-tc-handle': '.tc-drag-handle, .tc-drag-surface' } : {})}
1150
1294
  {...canvasPrimerAttrs}
1151
1295
  style={canvasThemeVars}
1152
1296
  onClick={isLocalDev ? (e) => {
1153
1297
  e.stopPropagation()
1154
- setSelectedWidgetId(`jsx-${exportName}`)
1298
+ if (!e.target.closest('.tc-drag-handle')) {
1299
+ handleWidgetSelect(`jsx-${exportName}`, e.shiftKey)
1300
+ }
1155
1301
  } : undefined}
1156
1302
  >
1157
1303
  <WidgetChrome
1158
1304
  widgetId={`jsx-${exportName}`}
1159
1305
  features={componentFeatures}
1160
- selected={selectedWidgetId === `jsx-${exportName}`}
1161
- onSelect={() => setSelectedWidgetId(`jsx-${exportName}`)}
1162
- onDeselect={() => setSelectedWidgetId(null)}
1306
+ selected={selectedWidgetIds.has(`jsx-${exportName}`)}
1307
+ onSelect={(shiftKey) => handleWidgetSelect(`jsx-${exportName}`, shiftKey)}
1308
+ onDeselect={() => setSelectedWidgetIds(new Set())}
1163
1309
  readOnly={!isLocalDev}
1164
1310
  >
1165
1311
  <ComponentWidget
@@ -1182,24 +1328,27 @@ export default function CanvasPage({ name }) {
1182
1328
  id={widget.id}
1183
1329
  data-tc-x={widget?.position?.x ?? 0}
1184
1330
  data-tc-y={widget?.position?.y ?? 0}
1185
- {...(isLocalDev ? { 'data-tc-handle': '.tc-drag-handle' } : {})}
1331
+ {...(isLocalDev ? { 'data-tc-handle': '.tc-drag-handle, .tc-drag-surface' } : {})}
1186
1332
  {...canvasPrimerAttrs}
1187
1333
  style={canvasThemeVars}
1188
1334
  onClick={isLocalDev ? (e) => {
1189
1335
  e.stopPropagation()
1190
- setSelectedWidgetId(widget.id)
1336
+ if (!e.target.closest('.tc-drag-handle')) {
1337
+ handleWidgetSelect(widget.id, e.shiftKey)
1338
+ }
1191
1339
  } : undefined}
1192
1340
  >
1193
1341
  <ChromeWrappedWidget
1194
1342
  widget={widget}
1195
- selected={selectedWidgetId === widget.id}
1196
- onSelect={() => setSelectedWidgetId(widget.id)}
1197
- onDeselect={() => setSelectedWidgetId(null)}
1343
+ selected={selectedWidgetIds.has(widget.id)}
1344
+ multiSelected={isMultiSelected && selectedWidgetIds.has(widget.id)}
1345
+ onSelect={(shiftKey) => handleWidgetSelect(widget.id, shiftKey)}
1346
+ onDeselect={() => setSelectedWidgetIds(new Set())}
1198
1347
  onUpdate={isLocalDev ? handleWidgetUpdate : undefined}
1199
1348
  onCopy={isLocalDev ? handleWidgetCopy : undefined}
1200
1349
  onRemove={isLocalDev ? (id) => {
1201
1350
  handleWidgetRemove(id)
1202
- setSelectedWidgetId(null)
1351
+ setSelectedWidgetIds(new Set())
1203
1352
  } : undefined}
1204
1353
  readOnly={!isLocalDev}
1205
1354
  />
@@ -1240,7 +1389,7 @@ export default function CanvasPage({ name }) {
1240
1389
  ...canvasThemeVars,
1241
1390
  ...(spaceHeld ? { cursor: panningActive ? 'grabbing' : 'grab' } : {}),
1242
1391
  }}
1243
- onClick={() => setSelectedWidgetId(null)}
1392
+ onClick={() => setSelectedWidgetIds(new Set())}
1244
1393
  onMouseDown={handlePanStart}
1245
1394
  >
1246
1395
  <div
@@ -1255,7 +1404,7 @@ export default function CanvasPage({ name }) {
1255
1404
  ...(spaceHeld ? { pointerEvents: 'none' } : {}),
1256
1405
  }}
1257
1406
  >
1258
- <Canvas {...canvasProps} onDragEnd={isLocalDev ? handleItemDragEnd : undefined}>
1407
+ <Canvas {...canvasProps} onDragStart={isLocalDev ? handleItemDragStart : undefined} onDrag={isLocalDev ? handleItemDrag : undefined} onDragEnd={isLocalDev ? handleItemDragEnd : undefined}>
1259
1408
  {allChildren}
1260
1409
  </Canvas>
1261
1410
  </div>
@@ -0,0 +1,259 @@
1
+ import { fireEvent, render, screen, act } from '@testing-library/react'
2
+ import CanvasPage from './CanvasPage.jsx'
3
+ import { updateCanvas, removeWidget } from './canvasApi.js'
4
+
5
+ const MOCK_UNDO_REDO = {
6
+ snapshot: vi.fn(),
7
+ undo: vi.fn(),
8
+ redo: vi.fn(),
9
+ reset: vi.fn(),
10
+ canUndo: false,
11
+ canRedo: false,
12
+ }
13
+
14
+ vi.mock('./useUndoRedo.js', () => ({
15
+ default: () => MOCK_UNDO_REDO,
16
+ }))
17
+
18
+ // Expose onDragEnd so tests can trigger drags with specific IDs
19
+ let capturedOnDragEnd = null
20
+ vi.mock('@dfosco/tiny-canvas', () => ({
21
+ Canvas: ({ children, onDragEnd }) => {
22
+ capturedOnDragEnd = onDragEnd
23
+ return <div data-testid="tiny-canvas">{children}</div>
24
+ },
25
+ }))
26
+
27
+ const mockCanvas = {
28
+ title: 'Multi-Select Test',
29
+ widgets: [
30
+ { id: 'w1', type: 'sticky-note', position: { x: 100, y: 100 }, props: {} },
31
+ { id: 'w2', type: 'sticky-note', position: { x: 300, y: 100 }, props: {} },
32
+ { id: 'w3', type: 'markdown', position: { x: 500, y: 200 }, props: {} },
33
+ ],
34
+ sources: [],
35
+ centered: false,
36
+ dotted: false,
37
+ grid: false,
38
+ gridSize: 18,
39
+ colorMode: 'auto',
40
+ }
41
+
42
+ vi.mock('./useCanvas.js', () => ({
43
+ useCanvas: () => ({
44
+ canvas: mockCanvas,
45
+ jsxExports: null,
46
+ loading: false,
47
+ }),
48
+ }))
49
+
50
+ vi.mock('./widgets/index.js', () => ({
51
+ getWidgetComponent: () => function MockWidget({ id }) {
52
+ return <div data-testid={`widget-content-${id}`}>widget</div>
53
+ },
54
+ }))
55
+
56
+ // WidgetChrome mock that exposes onSelect with shift parameter
57
+ vi.mock('./widgets/WidgetChrome.jsx', () => ({
58
+ default: ({ children, onSelect, selected, multiSelected, widgetId }) => (
59
+ <div
60
+ data-testid={`chrome-${widgetId}`}
61
+ data-selected={selected || undefined}
62
+ data-multi-selected={multiSelected || undefined}
63
+ >
64
+ {children}
65
+ <button
66
+ className="tc-drag-handle"
67
+ data-testid={`select-${widgetId}`}
68
+ onClick={(e) => { e.stopPropagation(); onSelect?.(false) }}
69
+ >
70
+ select
71
+ </button>
72
+ <button
73
+ className="tc-drag-handle"
74
+ data-testid={`shift-select-${widgetId}`}
75
+ onClick={(e) => { e.stopPropagation(); onSelect?.(true) }}
76
+ >
77
+ shift-select
78
+ </button>
79
+ </div>
80
+ ),
81
+ }))
82
+
83
+ vi.mock('./widgets/widgetProps.js', () => ({
84
+ schemas: {},
85
+ getDefaults: () => ({}),
86
+ }))
87
+
88
+ vi.mock('./widgets/widgetConfig.js', () => ({
89
+ getFeatures: () => [],
90
+ schemas: {},
91
+ getMenuWidgetTypes: () => [],
92
+ }))
93
+
94
+ vi.mock('./widgets/figmaUrl.js', () => ({
95
+ isFigmaUrl: () => false,
96
+ sanitizeFigmaUrl: (url) => url,
97
+ }))
98
+
99
+ vi.mock('./canvasApi.js', () => ({
100
+ addWidget: vi.fn(),
101
+ updateCanvas: vi.fn(() => Promise.resolve({ success: true })),
102
+ removeWidget: vi.fn(() => Promise.resolve({ success: true })),
103
+ uploadImage: vi.fn(),
104
+ }))
105
+
106
+ describe('CanvasPage multi-select', () => {
107
+ beforeEach(() => {
108
+ delete window.__storyboardCanvasBridgeState
109
+ window.__SB_LOCAL_DEV__ = true
110
+ vi.clearAllMocks()
111
+ capturedOnDragEnd = null
112
+ })
113
+
114
+ afterEach(() => {
115
+ delete window.__SB_LOCAL_DEV__
116
+ })
117
+
118
+ it('shift+click on select handle adds widget to selection', async () => {
119
+ render(<CanvasPage name="test-canvas" />)
120
+
121
+ // Select first widget
122
+ fireEvent.click(screen.getByTestId('select-w1'))
123
+ await act(async () => { await new Promise((r) => setTimeout(r, 0)) })
124
+ expect(screen.getByTestId('chrome-w1').dataset.selected).toBeDefined()
125
+
126
+ // Shift+select second widget
127
+ fireEvent.click(screen.getByTestId('shift-select-w2'))
128
+ await act(async () => { await new Promise((r) => setTimeout(r, 0)) })
129
+ expect(screen.getByTestId('chrome-w1').dataset.selected).toBeDefined()
130
+ expect(screen.getByTestId('chrome-w2').dataset.selected).toBeDefined()
131
+ })
132
+
133
+ it('shift+click on already selected widget removes it from selection', async () => {
134
+ render(<CanvasPage name="test-canvas" />)
135
+
136
+ // Select both
137
+ fireEvent.click(screen.getByTestId('select-w1'))
138
+ fireEvent.click(screen.getByTestId('shift-select-w2'))
139
+ await act(async () => { await new Promise((r) => setTimeout(r, 0)) })
140
+ expect(screen.getByTestId('chrome-w1').dataset.selected).toBeDefined()
141
+ expect(screen.getByTestId('chrome-w2').dataset.selected).toBeDefined()
142
+
143
+ // Shift+click w1 again to remove it
144
+ fireEvent.click(screen.getByTestId('shift-select-w1'))
145
+ await act(async () => { await new Promise((r) => setTimeout(r, 0)) })
146
+ expect(screen.getByTestId('chrome-w1').dataset.selected).toBeUndefined()
147
+ expect(screen.getByTestId('chrome-w2').dataset.selected).toBeDefined()
148
+ })
149
+
150
+ it('normal click replaces multi-selection with single', async () => {
151
+ render(<CanvasPage name="test-canvas" />)
152
+
153
+ // Multi-select
154
+ fireEvent.click(screen.getByTestId('select-w1'))
155
+ fireEvent.click(screen.getByTestId('shift-select-w2'))
156
+ fireEvent.click(screen.getByTestId('shift-select-w3'))
157
+ await act(async () => { await new Promise((r) => setTimeout(r, 0)) })
158
+
159
+ // Normal click on w1 clears multi-select
160
+ fireEvent.click(screen.getByTestId('select-w1'))
161
+ await act(async () => { await new Promise((r) => setTimeout(r, 0)) })
162
+ expect(screen.getByTestId('chrome-w1').dataset.selected).toBeDefined()
163
+ expect(screen.getByTestId('chrome-w2').dataset.selected).toBeUndefined()
164
+ expect(screen.getByTestId('chrome-w3').dataset.selected).toBeUndefined()
165
+ })
166
+
167
+ it('sets multiSelected on all selected widgets when multiple are selected', async () => {
168
+ render(<CanvasPage name="test-canvas" />)
169
+
170
+ fireEvent.click(screen.getByTestId('select-w1'))
171
+ fireEvent.click(screen.getByTestId('shift-select-w2'))
172
+ await act(async () => { await new Promise((r) => setTimeout(r, 0)) })
173
+
174
+ expect(screen.getByTestId('chrome-w1').dataset.multiSelected).toBeDefined()
175
+ expect(screen.getByTestId('chrome-w2').dataset.multiSelected).toBeDefined()
176
+ // Unselected widget should not have multiSelected
177
+ expect(screen.getByTestId('chrome-w3').dataset.multiSelected).toBeUndefined()
178
+ })
179
+
180
+ it('Escape clears all selection', async () => {
181
+ render(<CanvasPage name="test-canvas" />)
182
+
183
+ fireEvent.click(screen.getByTestId('select-w1'))
184
+ fireEvent.click(screen.getByTestId('shift-select-w2'))
185
+ await act(async () => { await new Promise((r) => setTimeout(r, 0)) })
186
+
187
+ fireEvent.keyDown(document, { key: 'Escape' })
188
+ await act(async () => { await new Promise((r) => setTimeout(r, 0)) })
189
+
190
+ expect(screen.getByTestId('chrome-w1').dataset.selected).toBeUndefined()
191
+ expect(screen.getByTestId('chrome-w2').dataset.selected).toBeUndefined()
192
+ })
193
+
194
+ it('Delete removes all selected widgets and calls updateCanvas', async () => {
195
+ render(<CanvasPage name="test-canvas" />)
196
+
197
+ // Multi-select w1 and w2
198
+ fireEvent.click(screen.getByTestId('select-w1'))
199
+ fireEvent.click(screen.getByTestId('shift-select-w2'))
200
+ await act(async () => { await new Promise((r) => setTimeout(r, 0)) })
201
+
202
+ // Press Delete
203
+ fireEvent.keyDown(document, { key: 'Delete' })
204
+ await act(async () => { await new Promise((r) => setTimeout(r, 0)) })
205
+
206
+ // Should call updateCanvas with only w3 remaining
207
+ expect(updateCanvas).toHaveBeenCalledWith(
208
+ 'test-canvas',
209
+ expect.objectContaining({
210
+ widgets: [expect.objectContaining({ id: 'w3' })],
211
+ })
212
+ )
213
+ // Should NOT use individual removeWidget API for multi-delete
214
+ expect(removeWidget).not.toHaveBeenCalled()
215
+ // Should snapshot for undo
216
+ expect(MOCK_UNDO_REDO.snapshot).toHaveBeenCalled()
217
+ })
218
+
219
+ it('single-select Delete uses removeWidget API', async () => {
220
+ render(<CanvasPage name="test-canvas" />)
221
+
222
+ fireEvent.click(screen.getByTestId('select-w1'))
223
+ fireEvent.keyDown(document, { key: 'Backspace' })
224
+ await act(async () => { await new Promise((r) => setTimeout(r, 0)) })
225
+
226
+ expect(removeWidget).toHaveBeenCalledWith('test-canvas', 'w1')
227
+ })
228
+
229
+ it('multi-select move applies delta to all selected widgets', async () => {
230
+ render(<CanvasPage name="test-canvas" />)
231
+
232
+ // Multi-select w1 (100,100) and w2 (300,100)
233
+ fireEvent.click(screen.getByTestId('select-w1'))
234
+ fireEvent.click(screen.getByTestId('shift-select-w2'))
235
+
236
+ // Wait for selectedIdsRef to sync
237
+ await act(async () => { await new Promise((r) => setTimeout(r, 0)) })
238
+
239
+ // Drag w1 to (150, 200) → delta is (+50, +100)
240
+ expect(capturedOnDragEnd).toBeTruthy()
241
+ act(() => {
242
+ capturedOnDragEnd('w1', { x: 150, y: 200 })
243
+ })
244
+ await act(async () => { await new Promise((r) => setTimeout(r, 0)) })
245
+
246
+ // w1 → (150, 200), w2 → (300+50, 100+100) = (350, 200)
247
+ expect(updateCanvas).toHaveBeenCalledWith(
248
+ 'test-canvas',
249
+ expect.objectContaining({
250
+ widgets: expect.arrayContaining([
251
+ expect.objectContaining({ id: 'w1', position: { x: 150, y: 200 } }),
252
+ expect.objectContaining({ id: 'w2', position: { x: 350, y: 200 } }),
253
+ // w3 unchanged
254
+ expect.objectContaining({ id: 'w3', position: { x: 500, y: 200 } }),
255
+ ]),
256
+ })
257
+ )
258
+ })
259
+ })
@@ -9,7 +9,8 @@ async function fetchCanvasFromServer(name) {
9
9
  try {
10
10
  const base = (import.meta.env?.BASE_URL || '/').replace(/\/$/, '')
11
11
  const res = await fetch(`${base}/_storyboard/canvas/read?name=${encodeURIComponent(name)}`)
12
- if (res.ok) return res.json()
12
+ const contentType = res.headers.get('content-type') || ''
13
+ if (res.ok && contentType.includes('application/json')) return res.json()
13
14
  } catch { /* fall back to build-time data */ }
14
15
  return null
15
16
  }
@@ -1,4 +1,5 @@
1
- import { forwardRef, useImperativeHandle, useMemo, useCallback, useState } from 'react'
1
+ import { forwardRef, useImperativeHandle, useMemo, useCallback, useState, useEffect, useRef } from 'react'
2
+ import { createPortal } from 'react-dom'
2
3
  import WidgetWrapper from './WidgetWrapper.jsx'
3
4
  import { readProp } from './widgetProps.js'
4
5
  import { schemas } from './widgetConfig.js'
@@ -28,6 +29,11 @@ export default forwardRef(function FigmaEmbed({ props, onUpdate }, ref) {
28
29
  const height = readProp(props, 'height', figmaEmbedSchema)
29
30
 
30
31
  const [interactive, setInteractive] = useState(false)
32
+ const [expanded, setExpanded] = useState(false)
33
+
34
+ const iframeRef = useRef(null)
35
+ const inlineContainerRef = useRef(null)
36
+ const modalContainerRef = useRef(null)
31
37
 
32
38
  // Validate URL at render time — only embed known Figma URLs
33
39
  const isValid = useMemo(() => isFigmaUrl(url), [url])
@@ -38,15 +44,65 @@ export default forwardRef(function FigmaEmbed({ props, onUpdate }, ref) {
38
44
 
39
45
  const enterInteractive = useCallback(() => setInteractive(true), [])
40
46
 
47
+ // Close expanded modal on Escape
48
+ useEffect(() => {
49
+ if (!expanded) return
50
+ function handleKeyDown(e) {
51
+ if (e.key === 'Escape') {
52
+ e.stopPropagation()
53
+ setExpanded(false)
54
+ }
55
+ }
56
+ document.addEventListener('keydown', handleKeyDown, true)
57
+ return () => document.removeEventListener('keydown', handleKeyDown, true)
58
+ }, [expanded])
59
+
60
+ // Reparent iframe DOM node between inline container and modal.
61
+ // Uses moveBefore() (Chrome 133+) which preserves the iframe's
62
+ // browsing context — no reload. Falls back to appendChild.
63
+ useEffect(() => {
64
+ const iframe = iframeRef.current
65
+ if (!iframe) return
66
+
67
+ if (expanded && modalContainerRef.current) {
68
+ iframe._savedClassName = iframe.className
69
+ iframe._savedStyle = iframe.getAttribute('style') || ''
70
+ iframe.className = styles.expandIframe
71
+ iframe.removeAttribute('style')
72
+ const target = modalContainerRef.current
73
+ if (target.moveBefore) {
74
+ target.moveBefore(iframe, target.firstChild)
75
+ } else {
76
+ target.prepend(iframe)
77
+ }
78
+ } else if (!expanded && inlineContainerRef.current) {
79
+ if (iframe._savedClassName !== undefined) {
80
+ iframe.className = iframe._savedClassName
81
+ iframe.setAttribute('style', iframe._savedStyle)
82
+ delete iframe._savedClassName
83
+ delete iframe._savedStyle
84
+ }
85
+ const target = inlineContainerRef.current
86
+ if (target.moveBefore) {
87
+ target.moveBefore(iframe, null)
88
+ } else {
89
+ target.appendChild(iframe)
90
+ }
91
+ }
92
+ }, [expanded])
93
+
41
94
  useImperativeHandle(ref, () => ({
42
95
  handleAction(actionId) {
43
96
  if (actionId === 'open-external') {
44
97
  if (url) window.open(url, '_blank', 'noopener')
98
+ } else if (actionId === 'expand') {
99
+ setExpanded(true)
45
100
  }
46
101
  },
47
102
  }), [url])
48
103
 
49
104
  return (
105
+ <>
50
106
  <WidgetWrapper>
51
107
  <div className={styles.embed} style={{ width, height }}>
52
108
  <div className={styles.header}>
@@ -55,15 +111,20 @@ export default forwardRef(function FigmaEmbed({ props, onUpdate }, ref) {
55
111
  </div>
56
112
  {embedUrl ? (
57
113
  <>
58
- <div className={styles.iframeContainer}>
114
+ <div
115
+ ref={inlineContainerRef}
116
+ className={styles.iframeContainer}
117
+ style={expanded ? { visibility: 'hidden' } : undefined}
118
+ >
59
119
  <iframe
120
+ ref={iframeRef}
60
121
  src={embedUrl}
61
122
  className={styles.iframe}
62
123
  title={`Figma ${typeLabel}: ${title}`}
63
124
  allowFullScreen
64
125
  />
65
126
  </div>
66
- {!interactive && (
127
+ {!interactive && !expanded && (
67
128
  <div
68
129
  className={styles.dragOverlay}
69
130
  onDoubleClick={enterInteractive}
@@ -102,5 +163,31 @@ export default forwardRef(function FigmaEmbed({ props, onUpdate }, ref) {
102
163
  onPointerDown={(e) => e.stopPropagation()}
103
164
  />
104
165
  </WidgetWrapper>
166
+ {createPortal(
167
+ <div
168
+ className={styles.expandBackdrop}
169
+ style={expanded && embedUrl ? undefined : { display: 'none' }}
170
+ onClick={() => setExpanded(false)}
171
+ onPointerDown={(e) => e.stopPropagation()}
172
+ onKeyDown={(e) => e.stopPropagation()}
173
+ onWheel={(e) => e.stopPropagation()}
174
+ >
175
+ <div
176
+ ref={modalContainerRef}
177
+ className={styles.expandContainer}
178
+ onClick={(e) => e.stopPropagation()}
179
+ >
180
+ {/* iframe is reparented here via useEffect */}
181
+ <button
182
+ className={styles.expandClose}
183
+ onClick={() => setExpanded(false)}
184
+ aria-label="Close expanded view"
185
+ autoFocus
186
+ >✕</button>
187
+ </div>
188
+ </div>,
189
+ document.body
190
+ )}
191
+ </>
105
192
  )
106
193
  })
@@ -36,7 +36,7 @@
36
36
 
37
37
  .iframeContainer {
38
38
  width: 100%;
39
- height: calc(100% - 31px); /* subtract header height */
39
+ height: calc(100% - 10px); /* subtract header height */
40
40
  overflow: hidden;
41
41
  }
42
42
 
@@ -81,3 +81,67 @@
81
81
  .resizeHandle:hover {
82
82
  opacity: 1;
83
83
  }
84
+
85
+ /* Expand modal — fullscreen overlay for expanded iframe */
86
+ .expandBackdrop {
87
+ position: fixed;
88
+ inset: 0;
89
+ z-index: 100000;
90
+ background: rgba(0, 0, 0, 0.8);
91
+ display: flex;
92
+ align-items: center;
93
+ justify-content: center;
94
+ animation: expandFadeIn 0.15s ease;
95
+ }
96
+
97
+ @keyframes expandFadeIn {
98
+ from { opacity: 0; }
99
+ to { opacity: 1; }
100
+ }
101
+
102
+ .expandContainer {
103
+ width: 90vw;
104
+ height: 90vh;
105
+ position: relative;
106
+ border-radius: 12px;
107
+ overflow: hidden;
108
+ background: var(--bgColor-default, #ffffff);
109
+ box-shadow: 0 24px 64px rgba(0, 0, 0, 0.4);
110
+ animation: expandScaleIn 0.2s ease;
111
+ }
112
+
113
+ @keyframes expandScaleIn {
114
+ from { transform: scale(0.95); opacity: 0; }
115
+ to { transform: scale(1); opacity: 1; }
116
+ }
117
+
118
+ .expandIframe {
119
+ border: none;
120
+ display: block;
121
+ width: 100%;
122
+ height: 100%;
123
+ }
124
+
125
+ .expandClose {
126
+ all: unset;
127
+ cursor: pointer;
128
+ position: absolute;
129
+ top: 12px;
130
+ right: 12px;
131
+ width: 32px;
132
+ height: 32px;
133
+ display: flex;
134
+ align-items: center;
135
+ justify-content: center;
136
+ border-radius: 8px;
137
+ background: rgba(0, 0, 0, 0.5);
138
+ color: #ffffff;
139
+ font-size: 16px;
140
+ z-index: 1;
141
+ transition: background 100ms;
142
+ backdrop-filter: blur(4px);
143
+ }
144
+
145
+ .expandClose:hover {
146
+ background: rgba(0, 0, 0, 0.7);
147
+ }
@@ -1,4 +1,5 @@
1
1
  import { useState, useRef, useEffect, useCallback, useMemo, forwardRef, useImperativeHandle } from 'react'
2
+ import { createPortal } from 'react-dom'
2
3
  import { buildPrototypeIndex } from '@dfosco/storyboard-core'
3
4
  import WidgetWrapper from './WidgetWrapper.jsx'
4
5
  import { readProp, prototypeEmbedSchema } from './widgetProps.js'
@@ -51,12 +52,15 @@ export default forwardRef(function PrototypeEmbed({ props, onUpdate }, ref) {
51
52
 
52
53
  const [editing, setEditing] = useState(false)
53
54
  const [interactive, setInteractive] = useState(false)
55
+ const [expanded, setExpanded] = useState(false)
54
56
  const [filter, setFilter] = useState('')
55
57
  const [canvasTheme, setCanvasTheme] = useState(() => resolveCanvasThemeFromStorage())
56
58
  const inputRef = useRef(null)
57
59
  const filterRef = useRef(null)
58
60
  const embedRef = useRef(null)
59
61
  const iframeRef = useRef(null)
62
+ const inlineContainerRef = useRef(null)
63
+ const modalContainerRef = useRef(null)
60
64
 
61
65
  const iframeSrc = useMemo(() => {
62
66
  if (!rawSrc) return ''
@@ -178,6 +182,54 @@ export default forwardRef(function PrototypeEmbed({ props, onUpdate }, ref) {
178
182
  return () => document.removeEventListener('storyboard:theme:changed', readToolbarTheme)
179
183
  }, [])
180
184
 
185
+ // Close expanded modal on Escape
186
+ useEffect(() => {
187
+ if (!expanded) return
188
+ function handleKeyDown(e) {
189
+ if (e.key === 'Escape') {
190
+ e.stopPropagation()
191
+ setExpanded(false)
192
+ }
193
+ }
194
+ document.addEventListener('keydown', handleKeyDown, true)
195
+ return () => document.removeEventListener('keydown', handleKeyDown, true)
196
+ }, [expanded])
197
+
198
+ // Reparent iframe DOM node between inline container and modal.
199
+ // Uses moveBefore() (Chrome 133+) which preserves the iframe's
200
+ // browsing context — no reload. Falls back to appendChild which
201
+ // will reload but still works functionally.
202
+ useEffect(() => {
203
+ const iframe = iframeRef.current
204
+ if (!iframe) return
205
+
206
+ if (expanded && modalContainerRef.current) {
207
+ iframe._savedClassName = iframe.className
208
+ iframe._savedStyle = iframe.getAttribute('style') || ''
209
+ iframe.className = styles.expandIframe
210
+ iframe.removeAttribute('style')
211
+ const target = modalContainerRef.current
212
+ if (target.moveBefore) {
213
+ target.moveBefore(iframe, target.firstChild)
214
+ } else {
215
+ target.prepend(iframe)
216
+ }
217
+ } else if (!expanded && inlineContainerRef.current) {
218
+ if (iframe._savedClassName !== undefined) {
219
+ iframe.className = iframe._savedClassName
220
+ iframe.setAttribute('style', iframe._savedStyle)
221
+ delete iframe._savedClassName
222
+ delete iframe._savedStyle
223
+ }
224
+ const target = inlineContainerRef.current
225
+ if (target.moveBefore) {
226
+ target.moveBefore(iframe, null)
227
+ } else {
228
+ target.appendChild(iframe)
229
+ }
230
+ }
231
+ }, [expanded])
232
+
181
233
  // Listen for navigation events from the embedded prototype iframe
182
234
  useEffect(() => {
183
235
  function handleMessage(e) {
@@ -202,6 +254,8 @@ export default forwardRef(function PrototypeEmbed({ props, onUpdate }, ref) {
202
254
  handleAction(actionId) {
203
255
  if (actionId === 'edit') {
204
256
  setEditing(true)
257
+ } else if (actionId === 'expand') {
258
+ setExpanded(true)
205
259
  } else if (actionId === 'open-external') {
206
260
  if (rawSrc) window.open(rawSrc, '_blank', 'noopener')
207
261
  } else if (actionId === 'zoom-in') {
@@ -234,6 +288,7 @@ export default forwardRef(function PrototypeEmbed({ props, onUpdate }, ref) {
234
288
  }
235
289
 
236
290
  return (
291
+ <>
237
292
  <WidgetWrapper>
238
293
  <div
239
294
  ref={embedRef}
@@ -323,7 +378,11 @@ export default forwardRef(function PrototypeEmbed({ props, onUpdate }, ref) {
323
378
  </div>
324
379
  ) : iframeSrc ? (
325
380
  <>
326
- <div className={styles.iframeContainer}>
381
+ <div
382
+ ref={inlineContainerRef}
383
+ className={styles.iframeContainer}
384
+ style={expanded ? { visibility: 'hidden' } : undefined}
385
+ >
327
386
  <iframe
328
387
  ref={iframeRef}
329
388
  src={iframeSrc}
@@ -338,7 +397,7 @@ export default forwardRef(function PrototypeEmbed({ props, onUpdate }, ref) {
338
397
  sandbox="allow-same-origin allow-scripts allow-forms allow-popups"
339
398
  />
340
399
  </div>
341
- {!interactive && (
400
+ {!interactive && !expanded && (
342
401
  <div
343
402
  className={styles.dragOverlay}
344
403
  onDoubleClick={enterInteractive}
@@ -381,5 +440,31 @@ export default forwardRef(function PrototypeEmbed({ props, onUpdate }, ref) {
381
440
  onPointerDown={(e) => e.stopPropagation()}
382
441
  />
383
442
  </WidgetWrapper>
443
+ {createPortal(
444
+ <div
445
+ className={styles.expandBackdrop}
446
+ style={expanded ? undefined : { display: 'none' }}
447
+ onClick={() => setExpanded(false)}
448
+ onPointerDown={(e) => e.stopPropagation()}
449
+ onKeyDown={(e) => e.stopPropagation()}
450
+ onWheel={(e) => e.stopPropagation()}
451
+ >
452
+ <div
453
+ ref={modalContainerRef}
454
+ className={styles.expandContainer}
455
+ onClick={(e) => e.stopPropagation()}
456
+ >
457
+ {/* iframe is reparented here via useEffect */}
458
+ <button
459
+ className={styles.expandClose}
460
+ onClick={() => setExpanded(false)}
461
+ aria-label="Close expanded view"
462
+ autoFocus
463
+ >✕</button>
464
+ </div>
465
+ </div>,
466
+ document.body
467
+ )}
468
+ </>
384
469
  )
385
470
  })
@@ -150,7 +150,7 @@
150
150
  }
151
151
 
152
152
  .pickerItem:focus-visible {
153
- outline: 2px solid var(--bgColor-accent-emphasis, #2f81f7);
153
+ outline: 4px solid var(--bgColor-accent-emphasis, #2f81f7);
154
154
  outline-offset: -2px;
155
155
  }
156
156
 
@@ -326,3 +326,67 @@
326
326
  border-right: 1.5px solid var(--trigger-border, var(--borderColor-muted, #d0d7de));
327
327
  user-select: none;
328
328
  }
329
+
330
+ /* Expand modal — fullscreen overlay for expanded iframe */
331
+ .expandBackdrop {
332
+ position: fixed;
333
+ inset: 0;
334
+ z-index: 100000;
335
+ background: rgba(0, 0, 0, 0.8);
336
+ display: flex;
337
+ align-items: center;
338
+ justify-content: center;
339
+ animation: expandFadeIn 0.15s ease;
340
+ }
341
+
342
+ @keyframes expandFadeIn {
343
+ from { opacity: 0; }
344
+ to { opacity: 1; }
345
+ }
346
+
347
+ .expandContainer {
348
+ width: 90vw;
349
+ height: 90vh;
350
+ position: relative;
351
+ border-radius: 12px;
352
+ overflow: hidden;
353
+ background: var(--bgColor-default, #ffffff);
354
+ box-shadow: 0 24px 64px rgba(0, 0, 0, 0.4);
355
+ animation: expandScaleIn 0.2s ease;
356
+ }
357
+
358
+ @keyframes expandScaleIn {
359
+ from { transform: scale(0.95); opacity: 0; }
360
+ to { transform: scale(1); opacity: 1; }
361
+ }
362
+
363
+ .expandIframe {
364
+ border: none;
365
+ display: block;
366
+ width: 100%;
367
+ height: 100%;
368
+ }
369
+
370
+ .expandClose {
371
+ all: unset;
372
+ cursor: pointer;
373
+ position: absolute;
374
+ top: 12px;
375
+ right: 12px;
376
+ width: 32px;
377
+ height: 32px;
378
+ display: flex;
379
+ align-items: center;
380
+ justify-content: center;
381
+ border-radius: 8px;
382
+ background: rgba(0, 0, 0, 0.5);
383
+ color: #ffffff;
384
+ font-size: 16px;
385
+ z-index: 1;
386
+ transition: background 100ms;
387
+ backdrop-filter: blur(4px);
388
+ }
389
+
390
+ .expandClose:hover {
391
+ background: rgba(0, 0, 0, 0.7);
392
+ }
@@ -102,6 +102,14 @@ function DownloadIcon() {
102
102
  )
103
103
  }
104
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
+
105
113
  /** Icon registry — maps icon name strings from config to React components. */
106
114
  const ICON_REGISTRY = {
107
115
  'trash': DeleteIcon,
@@ -116,6 +124,7 @@ const ICON_REGISTRY = {
116
124
  'more': MoreIcon,
117
125
  'chevron-down': ChevronDownIcon,
118
126
  'download': DownloadIcon,
127
+ 'expand': ExpandIcon,
119
128
  }
120
129
 
121
130
  /** Danger-styled actions in the overflow menu. */
@@ -304,6 +313,7 @@ export default function WidgetChrome({
304
313
  widgetId,
305
314
  features = [],
306
315
  selected = false,
316
+ multiSelected = false,
307
317
  widgetProps,
308
318
  widgetRef,
309
319
  onSelect,
@@ -315,7 +325,6 @@ export default function WidgetChrome({
315
325
  }) {
316
326
  const [hovered, setHovered] = useState(false)
317
327
  const leaveTimer = useRef(null)
318
- const pointerStartPos = useRef(null)
319
328
 
320
329
  const handleMouseEnter = useCallback(() => {
321
330
  clearTimeout(leaveTimer.current)
@@ -326,19 +335,12 @@ export default function WidgetChrome({
326
335
  leaveTimer.current = setTimeout(() => setHovered(false), 80)
327
336
  }, [])
328
337
 
329
- // Track pointer position on the handle to distinguish click from drag.
330
- const handleHandlePointerDown = useCallback((e) => {
331
- pointerStartPos.current = { x: e.clientX, y: e.clientY }
332
- }, [])
333
-
334
- const handleHandlePointerUp = useCallback((e) => {
335
- if (!pointerStartPos.current) return
336
- const start = pointerStartPos.current
337
- pointerStartPos.current = null
338
- const dist = Math.hypot(e.clientX - start.x, e.clientY - start.y)
339
- 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) => {
340
342
  e.stopPropagation()
341
- onSelect?.()
343
+ onSelect?.(e.shiftKey)
342
344
  }, [onSelect])
343
345
 
344
346
  const handleActionClick = useCallback((actionId, e) => {
@@ -362,6 +364,7 @@ export default function WidgetChrome({
362
364
  }, [onUpdate])
363
365
 
364
366
  const showToolbar = !readOnly && (hovered || selected)
367
+ const showFeatures = showToolbar && !multiSelected
365
368
 
366
369
  return (
367
370
  <div
@@ -369,7 +372,7 @@ export default function WidgetChrome({
369
372
  onMouseEnter={readOnly ? undefined : handleMouseEnter}
370
373
  onMouseLeave={readOnly ? undefined : handleMouseLeave}
371
374
  >
372
- <div className={`${styles.widgetSlot} ${selected ? styles.widgetSlotSelected : ''}`}>
375
+ <div className={`tc-drag-surface ${styles.widgetSlot} ${selected ? styles.widgetSlotSelected : ''} ${multiSelected ? styles.widgetSlotMultiSelected : ''}`}>
373
376
  {children}
374
377
  </div>
375
378
  <div
@@ -382,6 +385,7 @@ export default function WidgetChrome({
382
385
 
383
386
  {/* Toolbar content — visible on hover */}
384
387
  <div className={`${styles.toolbarContent} ${showToolbar ? styles.toolbarContentVisible : ''}`}>
388
+ {showFeatures && (
385
389
  <div className={styles.featureButtons}>
386
390
  {features.map((feature) => {
387
391
  // Menu features are rendered in WidgetOverflowMenu
@@ -449,13 +453,13 @@ export default function WidgetChrome({
449
453
  onAction={onAction}
450
454
  />
451
455
  </div>
456
+ )}
452
457
 
453
- <Tooltip text="Select" direction="n">
458
+ <Tooltip text={selected ? "Click and drag to move" : "Select"} direction="n">
454
459
  <button
455
460
  className={`tc-drag-handle ${styles.selectHandle} ${selected ? styles.selectHandleActive : ''}`}
456
- onPointerDown={handleHandlePointerDown}
457
- onPointerUp={handleHandlePointerUp}
458
- aria-label="Select widget"
461
+ onClick={handleHandleClick}
462
+ aria-label={selected ? "Drag to move widget" : "Select widget"}
459
463
  aria-pressed={selected}
460
464
  />
461
465
  </Tooltip>
@@ -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;
@@ -122,6 +126,7 @@
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 {
@@ -227,7 +232,7 @@
227
232
 
228
233
  .overflowMenu {
229
234
  position: absolute;
230
- top: calc(100% + 4px);
235
+ top: calc(100% + 10px);
231
236
  right: 0;
232
237
  min-width: 180px;
233
238
  padding: 4px;
@@ -99,11 +99,17 @@ export const widgetTypes = buildWidgetTypes()
99
99
 
100
100
  /**
101
101
  * Get the feature list for a widget type.
102
+ * In production, only features with `prod: true` are returned.
103
+ * In dev, all features are returned.
102
104
  * @param {string} type — widget type string
103
105
  * @returns {Array} features array from config (variables resolved), or empty array
104
106
  */
105
107
  export function getFeatures(type) {
106
- return widgetTypes[type]?.features ?? []
108
+ const features = widgetTypes[type]?.features ?? []
109
+ if (import.meta.env?.PROD) {
110
+ return features.filter(f => f.prod)
111
+ }
112
+ return features
107
113
  }
108
114
 
109
115
  /**