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

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.8",
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.8",
7
+ "@dfosco/tiny-canvas": "3.11.0-beta.8",
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
 
@@ -65,6 +65,7 @@ vi.mock('./widgets/widgetProps.js', () => ({
65
65
 
66
66
  vi.mock('./widgets/widgetConfig.js', () => ({
67
67
  getFeatures: () => [],
68
+ isResizable: () => false,
68
69
  schemas: {},
69
70
  getMenuWidgetTypes: () => [],
70
71
  }))
@@ -7,7 +7,7 @@ import { shouldPreventCanvasTextSelection } from './textSelection.js'
7
7
  import { getCanvasThemeVars, getCanvasPrimerAttrs } from './canvasTheme.js'
8
8
  import { getWidgetComponent } from './widgets/index.js'
9
9
  import { schemas, getDefaults } from './widgets/widgetProps.js'
10
- import { getFeatures } from './widgets/widgetConfig.js'
10
+ import { getFeatures, isResizable } from './widgets/widgetConfig.js'
11
11
  import { isFigmaUrl, sanitizeFigmaUrl } from './widgets/figmaUrl.js'
12
12
  import WidgetChrome from './widgets/WidgetChrome.jsx'
13
13
  import ComponentWidget from './widgets/ComponentWidget.jsx'
@@ -209,8 +209,9 @@ function WidgetRenderer({ widget, onUpdate, widgetRef }) {
209
209
  console.warn(`[canvas] Unknown widget type: ${widget.type}`)
210
210
  return null
211
211
  }
212
+ const resizable = isResizable(widget.type) && !!onUpdate
212
213
  // Only pass ref to forwardRef-wrapped components (e.g. PrototypeEmbed)
213
- const elementProps = { id: widget.id, props: widget.props, onUpdate }
214
+ const elementProps = { id: widget.id, props: widget.props, onUpdate, resizable }
214
215
  if (Component.$$typeof === Symbol.for('react.forward_ref')) {
215
216
  elementProps.ref = widgetRef
216
217
  }
@@ -224,6 +225,7 @@ function WidgetRenderer({ widget, onUpdate, widgetRef }) {
224
225
  function ChromeWrappedWidget({
225
226
  widget,
226
227
  selected,
228
+ multiSelected,
227
229
  onSelect,
228
230
  onDeselect,
229
231
  onUpdate,
@@ -248,6 +250,7 @@ function ChromeWrappedWidget({
248
250
  widgetType={widget.type}
249
251
  features={features}
250
252
  selected={selected}
253
+ multiSelected={multiSelected}
251
254
  widgetProps={widget.props}
252
255
  widgetRef={widgetRef}
253
256
  onSelect={onSelect}
@@ -278,7 +281,7 @@ export default function CanvasPage({ name }) {
278
281
  // Local mutable copy of widgets for instant UI updates
279
282
  const [localWidgets, setLocalWidgets] = useState(canvas?.widgets ?? null)
280
283
  const [trackedCanvas, setTrackedCanvas] = useState(canvas)
281
- const [selectedWidgetId, setSelectedWidgetId] = useState(null)
284
+ const [selectedWidgetIds, setSelectedWidgetIds] = useState(() => new Set())
282
285
  const initialViewport = loadViewportState(name)
283
286
  const [zoom, setZoom] = useState(initialViewport?.zoom ?? 100)
284
287
  const zoomRef = useRef(initialViewport?.zoom ?? 100)
@@ -307,6 +310,92 @@ export default function CanvasPage({ name }) {
307
310
  return writeQueueRef.current
308
311
  }
309
312
 
313
+ // Ref for selectedWidgetIds to avoid stale closures in callbacks
314
+ const selectedIdsRef = useRef(selectedWidgetIds)
315
+ useEffect(() => {
316
+ selectedIdsRef.current = selectedWidgetIds
317
+ }, [selectedWidgetIds])
318
+
319
+ const isMultiSelected = selectedWidgetIds.size > 1
320
+
321
+ /**
322
+ * Selection handler — shift+click toggles in/out of multi-select set,
323
+ * plain click single-selects (clears others).
324
+ */
325
+ const handleWidgetSelect = useCallback((widgetId, shiftKey) => {
326
+ if (shiftKey) {
327
+ setSelectedWidgetIds(prev => {
328
+ const next = new Set(prev)
329
+ if (next.has(widgetId)) {
330
+ next.delete(widgetId)
331
+ } else {
332
+ next.add(widgetId)
333
+ }
334
+ return next
335
+ })
336
+ } else {
337
+ setSelectedWidgetIds(new Set([widgetId]))
338
+ }
339
+ }, [])
340
+
341
+ // --- Multi-select live drag preview via imperative DOM transforms ---
342
+ // On drag start, snapshot ALL articles' translate values (same coord space).
343
+ // On each tick, read dragged article's current translate, compute delta
344
+ // from its snapshot, apply same delta to all peers.
345
+ const draggedArticleRef = useRef(null)
346
+ const draggedStartTranslate = useRef({ x: 0, y: 0 })
347
+ const peerSnapshots = useRef(new Map())
348
+
349
+ function parseTranslate(article) {
350
+ const raw = article?.style.translate || '0px 0px'
351
+ const parts = raw.match(/-?[\d.]+/g) || [0, 0]
352
+ return { x: parseFloat(parts[0]) || 0, y: parseFloat(parts[1]) || 0 }
353
+ }
354
+
355
+ const handleItemDragStart = useCallback((dragId) => {
356
+ const ids = selectedIdsRef.current
357
+ peerSnapshots.current.clear()
358
+ draggedArticleRef.current = null
359
+ if (ids.size <= 1 || !ids.has(dragId)) return
360
+
361
+ // Snapshot dragged widget's article translate
362
+ const draggedEl = document.getElementById(dragId)
363
+ const draggedArticle = draggedEl?.closest('article')
364
+ if (!draggedArticle) return
365
+ draggedArticleRef.current = draggedArticle
366
+ draggedStartTranslate.current = parseTranslate(draggedArticle)
367
+
368
+ // Snapshot each peer's article translate
369
+ for (const id of ids) {
370
+ if (id === dragId) continue
371
+ const widgetEl = document.getElementById(id)
372
+ const article = widgetEl?.closest('article')
373
+ if (!article) continue
374
+ peerSnapshots.current.set(id, {
375
+ article,
376
+ ...parseTranslate(article),
377
+ })
378
+ }
379
+ }, [])
380
+
381
+ const handleItemDrag = useCallback(() => {
382
+ if (!draggedArticleRef.current) return
383
+
384
+ // Read dragged article's CURRENT translate (set by neodrag)
385
+ const current = parseTranslate(draggedArticleRef.current)
386
+ const dx = current.x - draggedStartTranslate.current.x
387
+ const dy = current.y - draggedStartTranslate.current.y
388
+
389
+ for (const [, peer] of peerSnapshots.current) {
390
+ peer.article.style.translate = `${peer.x + dx}px ${peer.y + dy}px`
391
+ }
392
+ }, [])
393
+
394
+ const clearDragPreview = useCallback(() => {
395
+ peerSnapshots.current.clear()
396
+ draggedArticleRef.current = null
397
+ }, [])
398
+
310
399
  if (canvas !== trackedCanvas) {
311
400
  setTrackedCanvas(canvas)
312
401
  setLocalWidgets(canvas?.widgets ?? null)
@@ -447,6 +536,44 @@ export default function CanvasPage({ name }) {
447
536
  return
448
537
  }
449
538
 
539
+ const ids = selectedIdsRef.current
540
+ // Multi-select move: apply same delta to all selected widgets
541
+ if (ids.size > 1 && ids.has(dragId)) {
542
+ clearDragPreview()
543
+ undoRedo.snapshot(stateRef.current, 'multi-move')
544
+ const currentWidgets = stateRef.current.widgets ?? []
545
+ const draggedWidget = currentWidgets.find(w => w.id === dragId)
546
+ if (!draggedWidget) return
547
+ const oldPos = draggedWidget.position || { x: 0, y: 0 }
548
+ const dx = rounded.x - oldPos.x
549
+ const dy = rounded.y - oldPos.y
550
+
551
+ debouncedSave.cancel()
552
+ setLocalWidgets((prev) => {
553
+ if (!prev) return prev
554
+ const next = prev.map((w) => {
555
+ if (w.id === dragId) return { ...w, position: rounded }
556
+ if (ids.has(w.id)) {
557
+ return {
558
+ ...w,
559
+ position: {
560
+ x: Math.max(0, roundPosition((w.position?.x ?? 0) + dx)),
561
+ y: Math.max(0, roundPosition((w.position?.y ?? 0) + dy)),
562
+ },
563
+ }
564
+ }
565
+ return w
566
+ })
567
+ queueWrite(() =>
568
+ updateCanvas(name, { widgets: next }).catch((err) =>
569
+ console.error('[canvas] Failed to save multi-move:', err)
570
+ )
571
+ )
572
+ return next
573
+ })
574
+ return
575
+ }
576
+
450
577
  undoRedo.snapshot(stateRef.current, 'move', dragId)
451
578
  setLocalWidgets((prev) => {
452
579
  if (!prev) return prev
@@ -460,7 +587,7 @@ export default function CanvasPage({ name }) {
460
587
  )
461
588
  return next
462
589
  })
463
- }, [name, undoRedo])
590
+ }, [name, undoRedo, debouncedSave, clearDragPreview])
464
591
 
465
592
  useEffect(() => {
466
593
  zoomRef.current = zoom
@@ -780,22 +907,39 @@ export default function CanvasPage({ name }) {
780
907
 
781
908
  useEffect(() => {
782
909
  function handleKeyDown(e) {
783
- if (!selectedWidgetId) return
910
+ if (selectedWidgetIds.size === 0) return
784
911
  const tag = e.target.tagName
785
912
  if (tag === 'INPUT' || tag === 'TEXTAREA' || e.target.isContentEditable) return
786
913
  if (e.key === 'Escape') {
787
914
  e.preventDefault()
788
- setSelectedWidgetId(null)
915
+ setSelectedWidgetIds(new Set())
789
916
  }
790
917
  if (e.key === 'Delete' || e.key === 'Backspace') {
791
918
  e.preventDefault()
792
- handleWidgetRemove(selectedWidgetId)
793
- setSelectedWidgetId(null)
919
+ if (selectedWidgetIds.size > 1) {
920
+ // Multi-delete — snapshot once, remove all, persist via updateCanvas
921
+ undoRedo.snapshot(stateRef.current, 'multi-remove')
922
+ debouncedSave.cancel()
923
+ setLocalWidgets((prev) => {
924
+ if (!prev) return prev
925
+ const next = prev.filter(w => !selectedWidgetIds.has(w.id))
926
+ queueWrite(() =>
927
+ updateCanvas(name, { widgets: next }).catch(err =>
928
+ console.error('[canvas] Failed to save multi-delete:', err)
929
+ )
930
+ )
931
+ return next
932
+ })
933
+ } else {
934
+ const widgetId = [...selectedWidgetIds][0]
935
+ if (widgetId) handleWidgetRemove(widgetId)
936
+ }
937
+ setSelectedWidgetIds(new Set())
794
938
  }
795
939
  }
796
940
  document.addEventListener('keydown', handleKeyDown)
797
941
  return () => document.removeEventListener('keydown', handleKeyDown)
798
- }, [selectedWidgetId, handleWidgetRemove])
942
+ }, [selectedWidgetIds, handleWidgetRemove, undoRedo, name, debouncedSave])
799
943
 
800
944
  // Paste handler — images become image widgets, same-origin URLs become prototypes,
801
945
  // other URLs become link previews, text becomes markdown
@@ -1120,6 +1264,7 @@ export default function CanvasPage({ name }) {
1120
1264
  colorMode: canvas.colorMode === 'auto'
1121
1265
  ? getToolbarColorMode(canvasTheme)
1122
1266
  : (canvas.colorMode ?? 'auto'),
1267
+ locked: !isLocalDev,
1123
1268
  }
1124
1269
 
1125
1270
  const canvasThemeVars = getCanvasThemeVars(canvasTheme)
@@ -1146,20 +1291,22 @@ export default function CanvasPage({ name }) {
1146
1291
  id={`jsx-${exportName}`}
1147
1292
  data-tc-x={sourcePosition.x}
1148
1293
  data-tc-y={sourcePosition.y}
1149
- {...(isLocalDev ? { 'data-tc-handle': '.tc-drag-handle' } : {})}
1294
+ {...(isLocalDev ? { 'data-tc-handle': '.tc-drag-handle, .tc-drag-surface' } : {})}
1150
1295
  {...canvasPrimerAttrs}
1151
1296
  style={canvasThemeVars}
1152
1297
  onClick={isLocalDev ? (e) => {
1153
1298
  e.stopPropagation()
1154
- setSelectedWidgetId(`jsx-${exportName}`)
1299
+ if (!e.target.closest('.tc-drag-handle')) {
1300
+ handleWidgetSelect(`jsx-${exportName}`, e.shiftKey)
1301
+ }
1155
1302
  } : undefined}
1156
1303
  >
1157
1304
  <WidgetChrome
1158
1305
  widgetId={`jsx-${exportName}`}
1159
1306
  features={componentFeatures}
1160
- selected={selectedWidgetId === `jsx-${exportName}`}
1161
- onSelect={() => setSelectedWidgetId(`jsx-${exportName}`)}
1162
- onDeselect={() => setSelectedWidgetId(null)}
1307
+ selected={selectedWidgetIds.has(`jsx-${exportName}`)}
1308
+ onSelect={(shiftKey) => handleWidgetSelect(`jsx-${exportName}`, shiftKey)}
1309
+ onDeselect={() => setSelectedWidgetIds(new Set())}
1163
1310
  readOnly={!isLocalDev}
1164
1311
  >
1165
1312
  <ComponentWidget
@@ -1167,6 +1314,7 @@ export default function CanvasPage({ name }) {
1167
1314
  width={sourceData.width}
1168
1315
  height={sourceData.height}
1169
1316
  onUpdate={isLocalDev ? (updates) => handleSourceUpdate(exportName, updates) : undefined}
1317
+ resizable={isResizable('component') && isLocalDev}
1170
1318
  />
1171
1319
  </WidgetChrome>
1172
1320
  </div>
@@ -1182,24 +1330,27 @@ export default function CanvasPage({ name }) {
1182
1330
  id={widget.id}
1183
1331
  data-tc-x={widget?.position?.x ?? 0}
1184
1332
  data-tc-y={widget?.position?.y ?? 0}
1185
- {...(isLocalDev ? { 'data-tc-handle': '.tc-drag-handle' } : {})}
1333
+ {...(isLocalDev ? { 'data-tc-handle': '.tc-drag-handle, .tc-drag-surface' } : {})}
1186
1334
  {...canvasPrimerAttrs}
1187
1335
  style={canvasThemeVars}
1188
1336
  onClick={isLocalDev ? (e) => {
1189
1337
  e.stopPropagation()
1190
- setSelectedWidgetId(widget.id)
1338
+ if (!e.target.closest('.tc-drag-handle')) {
1339
+ handleWidgetSelect(widget.id, e.shiftKey)
1340
+ }
1191
1341
  } : undefined}
1192
1342
  >
1193
1343
  <ChromeWrappedWidget
1194
1344
  widget={widget}
1195
- selected={selectedWidgetId === widget.id}
1196
- onSelect={() => setSelectedWidgetId(widget.id)}
1197
- onDeselect={() => setSelectedWidgetId(null)}
1345
+ selected={selectedWidgetIds.has(widget.id)}
1346
+ multiSelected={isMultiSelected && selectedWidgetIds.has(widget.id)}
1347
+ onSelect={(shiftKey) => handleWidgetSelect(widget.id, shiftKey)}
1348
+ onDeselect={() => setSelectedWidgetIds(new Set())}
1198
1349
  onUpdate={isLocalDev ? handleWidgetUpdate : undefined}
1199
1350
  onCopy={isLocalDev ? handleWidgetCopy : undefined}
1200
1351
  onRemove={isLocalDev ? (id) => {
1201
1352
  handleWidgetRemove(id)
1202
- setSelectedWidgetId(null)
1353
+ setSelectedWidgetIds(new Set())
1203
1354
  } : undefined}
1204
1355
  readOnly={!isLocalDev}
1205
1356
  />
@@ -1240,7 +1391,7 @@ export default function CanvasPage({ name }) {
1240
1391
  ...canvasThemeVars,
1241
1392
  ...(spaceHeld ? { cursor: panningActive ? 'grabbing' : 'grab' } : {}),
1242
1393
  }}
1243
- onClick={() => setSelectedWidgetId(null)}
1394
+ onClick={() => setSelectedWidgetIds(new Set())}
1244
1395
  onMouseDown={handlePanStart}
1245
1396
  >
1246
1397
  <div
@@ -1255,7 +1406,7 @@ export default function CanvasPage({ name }) {
1255
1406
  ...(spaceHeld ? { pointerEvents: 'none' } : {}),
1256
1407
  }}
1257
1408
  >
1258
- <Canvas {...canvasProps} onDragEnd={isLocalDev ? handleItemDragEnd : undefined}>
1409
+ <Canvas {...canvasProps} onDragStart={isLocalDev ? handleItemDragStart : undefined} onDrag={isLocalDev ? handleItemDrag : undefined} onDragEnd={isLocalDev ? handleItemDragEnd : undefined}>
1259
1410
  {allChildren}
1260
1411
  </Canvas>
1261
1412
  </div>
@@ -0,0 +1,260 @@
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
+ isResizable: () => false,
91
+ schemas: {},
92
+ getMenuWidgetTypes: () => [],
93
+ }))
94
+
95
+ vi.mock('./widgets/figmaUrl.js', () => ({
96
+ isFigmaUrl: () => false,
97
+ sanitizeFigmaUrl: (url) => url,
98
+ }))
99
+
100
+ vi.mock('./canvasApi.js', () => ({
101
+ addWidget: vi.fn(),
102
+ updateCanvas: vi.fn(() => Promise.resolve({ success: true })),
103
+ removeWidget: vi.fn(() => Promise.resolve({ success: true })),
104
+ uploadImage: vi.fn(),
105
+ }))
106
+
107
+ describe('CanvasPage multi-select', () => {
108
+ beforeEach(() => {
109
+ delete window.__storyboardCanvasBridgeState
110
+ window.__SB_LOCAL_DEV__ = true
111
+ vi.clearAllMocks()
112
+ capturedOnDragEnd = null
113
+ })
114
+
115
+ afterEach(() => {
116
+ delete window.__SB_LOCAL_DEV__
117
+ })
118
+
119
+ it('shift+click on select handle adds widget to selection', async () => {
120
+ render(<CanvasPage name="test-canvas" />)
121
+
122
+ // Select first widget
123
+ fireEvent.click(screen.getByTestId('select-w1'))
124
+ await act(async () => { await new Promise((r) => setTimeout(r, 0)) })
125
+ expect(screen.getByTestId('chrome-w1').dataset.selected).toBeDefined()
126
+
127
+ // Shift+select second widget
128
+ fireEvent.click(screen.getByTestId('shift-select-w2'))
129
+ await act(async () => { await new Promise((r) => setTimeout(r, 0)) })
130
+ expect(screen.getByTestId('chrome-w1').dataset.selected).toBeDefined()
131
+ expect(screen.getByTestId('chrome-w2').dataset.selected).toBeDefined()
132
+ })
133
+
134
+ it('shift+click on already selected widget removes it from selection', async () => {
135
+ render(<CanvasPage name="test-canvas" />)
136
+
137
+ // Select both
138
+ fireEvent.click(screen.getByTestId('select-w1'))
139
+ fireEvent.click(screen.getByTestId('shift-select-w2'))
140
+ await act(async () => { await new Promise((r) => setTimeout(r, 0)) })
141
+ expect(screen.getByTestId('chrome-w1').dataset.selected).toBeDefined()
142
+ expect(screen.getByTestId('chrome-w2').dataset.selected).toBeDefined()
143
+
144
+ // Shift+click w1 again to remove it
145
+ fireEvent.click(screen.getByTestId('shift-select-w1'))
146
+ await act(async () => { await new Promise((r) => setTimeout(r, 0)) })
147
+ expect(screen.getByTestId('chrome-w1').dataset.selected).toBeUndefined()
148
+ expect(screen.getByTestId('chrome-w2').dataset.selected).toBeDefined()
149
+ })
150
+
151
+ it('normal click replaces multi-selection with single', async () => {
152
+ render(<CanvasPage name="test-canvas" />)
153
+
154
+ // Multi-select
155
+ fireEvent.click(screen.getByTestId('select-w1'))
156
+ fireEvent.click(screen.getByTestId('shift-select-w2'))
157
+ fireEvent.click(screen.getByTestId('shift-select-w3'))
158
+ await act(async () => { await new Promise((r) => setTimeout(r, 0)) })
159
+
160
+ // Normal click on w1 clears multi-select
161
+ fireEvent.click(screen.getByTestId('select-w1'))
162
+ await act(async () => { await new Promise((r) => setTimeout(r, 0)) })
163
+ expect(screen.getByTestId('chrome-w1').dataset.selected).toBeDefined()
164
+ expect(screen.getByTestId('chrome-w2').dataset.selected).toBeUndefined()
165
+ expect(screen.getByTestId('chrome-w3').dataset.selected).toBeUndefined()
166
+ })
167
+
168
+ it('sets multiSelected on all selected widgets when multiple are selected', async () => {
169
+ render(<CanvasPage name="test-canvas" />)
170
+
171
+ fireEvent.click(screen.getByTestId('select-w1'))
172
+ fireEvent.click(screen.getByTestId('shift-select-w2'))
173
+ await act(async () => { await new Promise((r) => setTimeout(r, 0)) })
174
+
175
+ expect(screen.getByTestId('chrome-w1').dataset.multiSelected).toBeDefined()
176
+ expect(screen.getByTestId('chrome-w2').dataset.multiSelected).toBeDefined()
177
+ // Unselected widget should not have multiSelected
178
+ expect(screen.getByTestId('chrome-w3').dataset.multiSelected).toBeUndefined()
179
+ })
180
+
181
+ it('Escape clears all selection', async () => {
182
+ render(<CanvasPage name="test-canvas" />)
183
+
184
+ fireEvent.click(screen.getByTestId('select-w1'))
185
+ fireEvent.click(screen.getByTestId('shift-select-w2'))
186
+ await act(async () => { await new Promise((r) => setTimeout(r, 0)) })
187
+
188
+ fireEvent.keyDown(document, { key: 'Escape' })
189
+ await act(async () => { await new Promise((r) => setTimeout(r, 0)) })
190
+
191
+ expect(screen.getByTestId('chrome-w1').dataset.selected).toBeUndefined()
192
+ expect(screen.getByTestId('chrome-w2').dataset.selected).toBeUndefined()
193
+ })
194
+
195
+ it('Delete removes all selected widgets and calls updateCanvas', async () => {
196
+ render(<CanvasPage name="test-canvas" />)
197
+
198
+ // Multi-select w1 and w2
199
+ fireEvent.click(screen.getByTestId('select-w1'))
200
+ fireEvent.click(screen.getByTestId('shift-select-w2'))
201
+ await act(async () => { await new Promise((r) => setTimeout(r, 0)) })
202
+
203
+ // Press Delete
204
+ fireEvent.keyDown(document, { key: 'Delete' })
205
+ await act(async () => { await new Promise((r) => setTimeout(r, 0)) })
206
+
207
+ // Should call updateCanvas with only w3 remaining
208
+ expect(updateCanvas).toHaveBeenCalledWith(
209
+ 'test-canvas',
210
+ expect.objectContaining({
211
+ widgets: [expect.objectContaining({ id: 'w3' })],
212
+ })
213
+ )
214
+ // Should NOT use individual removeWidget API for multi-delete
215
+ expect(removeWidget).not.toHaveBeenCalled()
216
+ // Should snapshot for undo
217
+ expect(MOCK_UNDO_REDO.snapshot).toHaveBeenCalled()
218
+ })
219
+
220
+ it('single-select Delete uses removeWidget API', async () => {
221
+ render(<CanvasPage name="test-canvas" />)
222
+
223
+ fireEvent.click(screen.getByTestId('select-w1'))
224
+ fireEvent.keyDown(document, { key: 'Backspace' })
225
+ await act(async () => { await new Promise((r) => setTimeout(r, 0)) })
226
+
227
+ expect(removeWidget).toHaveBeenCalledWith('test-canvas', 'w1')
228
+ })
229
+
230
+ it('multi-select move applies delta to all selected widgets', async () => {
231
+ render(<CanvasPage name="test-canvas" />)
232
+
233
+ // Multi-select w1 (100,100) and w2 (300,100)
234
+ fireEvent.click(screen.getByTestId('select-w1'))
235
+ fireEvent.click(screen.getByTestId('shift-select-w2'))
236
+
237
+ // Wait for selectedIdsRef to sync
238
+ await act(async () => { await new Promise((r) => setTimeout(r, 0)) })
239
+
240
+ // Drag w1 to (150, 200) → delta is (+50, +100)
241
+ expect(capturedOnDragEnd).toBeTruthy()
242
+ act(() => {
243
+ capturedOnDragEnd('w1', { x: 150, y: 200 })
244
+ })
245
+ await act(async () => { await new Promise((r) => setTimeout(r, 0)) })
246
+
247
+ // w1 → (150, 200), w2 → (300+50, 100+100) = (350, 200)
248
+ expect(updateCanvas).toHaveBeenCalledWith(
249
+ 'test-canvas',
250
+ expect.objectContaining({
251
+ widgets: expect.arrayContaining([
252
+ expect.objectContaining({ id: 'w1', position: { x: 150, y: 200 } }),
253
+ expect.objectContaining({ id: 'w2', position: { x: 350, y: 200 } }),
254
+ // w3 unchanged
255
+ expect.objectContaining({ id: 'w3', position: { x: 500, y: 200 } }),
256
+ ]),
257
+ })
258
+ )
259
+ })
260
+ })
@@ -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
  }
@@ -11,7 +11,7 @@ import styles from './ComponentWidget.module.css'
11
11
  * Double-click the overlay to enter interactive mode (dropdowns, buttons work).
12
12
  * Click outside to exit interactive mode.
13
13
  */
14
- export default function ComponentWidget({ component: Component, width, height, onUpdate }) {
14
+ export default function ComponentWidget({ component: Component, width, height, onUpdate, resizable }) {
15
15
  const containerRef = useRef(null)
16
16
  const [interactive, setInteractive] = useState(false)
17
17
 
@@ -51,12 +51,14 @@ export default function ComponentWidget({ component: Component, width, height, o
51
51
  onDoubleClick={enterInteractive}
52
52
  />
53
53
  )}
54
- <ResizeHandle
55
- targetRef={containerRef}
56
- minWidth={100}
57
- minHeight={60}
58
- onResize={handleResize}
59
- />
54
+ {resizable && (
55
+ <ResizeHandle
56
+ targetRef={containerRef}
57
+ minWidth={100}
58
+ minHeight={60}
59
+ onResize={handleResize}
60
+ />
61
+ )}
60
62
  </div>
61
63
  </WidgetWrapper>
62
64
  )