@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 +3 -3
- package/src/Viewfinder.jsx +5 -3
- package/src/canvas/CanvasPage.bridge.test.jsx +1 -0
- package/src/canvas/CanvasPage.jsx +173 -22
- package/src/canvas/CanvasPage.multiselect.test.jsx +260 -0
- package/src/canvas/useCanvas.js +2 -1
- package/src/canvas/widgets/ComponentWidget.jsx +9 -7
- package/src/canvas/widgets/FigmaEmbed.jsx +115 -26
- package/src/canvas/widgets/FigmaEmbed.module.css +65 -1
- package/src/canvas/widgets/ImageWidget.jsx +9 -7
- package/src/canvas/widgets/PrototypeEmbed.jsx +112 -25
- package/src/canvas/widgets/PrototypeEmbed.module.css +65 -1
- package/src/canvas/widgets/StickyNote.jsx +9 -7
- package/src/canvas/widgets/StickyNote.test.jsx +10 -4
- package/src/canvas/widgets/WidgetChrome.jsx +22 -18
- package/src/canvas/widgets/WidgetChrome.module.css +13 -6
- package/src/canvas/widgets/widgetConfig.js +20 -1
- package/src/canvas/widgets/widgetConfig.test.js +46 -0
package/package.json
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@dfosco/storyboard-react",
|
|
3
|
-
"version": "3.11.0-beta.
|
|
3
|
+
"version": "3.11.0-beta.8",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"dependencies": {
|
|
6
|
-
"@dfosco/storyboard-core": "3.11.0-beta.
|
|
7
|
-
"@dfosco/tiny-canvas": "3.11.0-beta.
|
|
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"
|
package/src/Viewfinder.jsx
CHANGED
|
@@ -45,9 +45,11 @@ export default function Viewfinder({ pageModules = {}, basePath, title = 'Storyb
|
|
|
45
45
|
showThumbnails,
|
|
46
46
|
hideDefaultFlow: shouldHideDefault,
|
|
47
47
|
})
|
|
48
|
-
//
|
|
49
|
-
|
|
50
|
-
|
|
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
|
|
|
@@ -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 [
|
|
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 (
|
|
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
|
-
|
|
915
|
+
setSelectedWidgetIds(new Set())
|
|
789
916
|
}
|
|
790
917
|
if (e.key === 'Delete' || e.key === 'Backspace') {
|
|
791
918
|
e.preventDefault()
|
|
792
|
-
|
|
793
|
-
|
|
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
|
-
}, [
|
|
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
|
-
|
|
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={
|
|
1161
|
-
onSelect={() =>
|
|
1162
|
-
onDeselect={() =>
|
|
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
|
-
|
|
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={
|
|
1196
|
-
|
|
1197
|
-
|
|
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
|
-
|
|
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={() =>
|
|
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
|
+
})
|
package/src/canvas/useCanvas.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
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
|
)
|