@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 +3 -3
- package/src/Viewfinder.jsx +5 -3
- package/src/canvas/CanvasPage.jsx +169 -20
- package/src/canvas/CanvasPage.multiselect.test.jsx +259 -0
- package/src/canvas/useCanvas.js +2 -1
- package/src/canvas/widgets/FigmaEmbed.jsx +90 -3
- package/src/canvas/widgets/FigmaEmbed.module.css +65 -1
- package/src/canvas/widgets/PrototypeEmbed.jsx +87 -2
- package/src/canvas/widgets/PrototypeEmbed.module.css +65 -1
- package/src/canvas/widgets/WidgetChrome.jsx +22 -18
- package/src/canvas/widgets/WidgetChrome.module.css +9 -4
- package/src/canvas/widgets/widgetConfig.js +7 -1
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.7",
|
|
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.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"
|
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
|
|
|
@@ -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 [
|
|
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 (
|
|
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
|
-
|
|
914
|
+
setSelectedWidgetIds(new Set())
|
|
789
915
|
}
|
|
790
916
|
if (e.key === 'Delete' || e.key === 'Backspace') {
|
|
791
917
|
e.preventDefault()
|
|
792
|
-
|
|
793
|
-
|
|
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
|
-
}, [
|
|
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
|
-
|
|
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={
|
|
1161
|
-
onSelect={() =>
|
|
1162
|
-
onDeselect={() =>
|
|
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
|
-
|
|
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={
|
|
1196
|
-
|
|
1197
|
-
|
|
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
|
-
|
|
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={() =>
|
|
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
|
+
})
|
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
|
}
|
|
@@ -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
|
|
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% -
|
|
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
|
|
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:
|
|
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
|
-
//
|
|
330
|
-
|
|
331
|
-
|
|
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={
|
|
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
|
-
|
|
457
|
-
|
|
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:
|
|
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% +
|
|
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:
|
|
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% +
|
|
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
|
-
|
|
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
|
/**
|