@dfosco/storyboard-react 3.11.0-beta.5 → 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.bridge.test.jsx +1 -1
- package/src/canvas/CanvasPage.jsx +170 -20
- package/src/canvas/CanvasPage.multiselect.test.jsx +259 -0
- package/src/canvas/useCanvas.js +2 -1
- package/src/canvas/useUndoRedo.test.js +1 -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 +23 -19
- 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
|
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { fireEvent, render, screen,
|
|
1
|
+
import { fireEvent, render, screen, act } from '@testing-library/react'
|
|
2
2
|
import CanvasPage from './CanvasPage.jsx'
|
|
3
3
|
import { getCanvasPrimerAttrs, getCanvasThemeVars } from './canvasTheme.js'
|
|
4
4
|
import { updateCanvas } from './canvasApi.js'
|
|
@@ -136,6 +136,7 @@ function snapValue(value, gridSize) {
|
|
|
136
136
|
}
|
|
137
137
|
|
|
138
138
|
/** Snap a position to the grid if snapping is enabled. */
|
|
139
|
+
// eslint-disable-next-line no-unused-vars
|
|
139
140
|
function snapPosition(pos, gridSize, enabled) {
|
|
140
141
|
if (!enabled || !gridSize) return pos
|
|
141
142
|
return {
|
|
@@ -223,6 +224,7 @@ function WidgetRenderer({ widget, onUpdate, widgetRef }) {
|
|
|
223
224
|
function ChromeWrappedWidget({
|
|
224
225
|
widget,
|
|
225
226
|
selected,
|
|
227
|
+
multiSelected,
|
|
226
228
|
onSelect,
|
|
227
229
|
onDeselect,
|
|
228
230
|
onUpdate,
|
|
@@ -247,6 +249,7 @@ function ChromeWrappedWidget({
|
|
|
247
249
|
widgetType={widget.type}
|
|
248
250
|
features={features}
|
|
249
251
|
selected={selected}
|
|
252
|
+
multiSelected={multiSelected}
|
|
250
253
|
widgetProps={widget.props}
|
|
251
254
|
widgetRef={widgetRef}
|
|
252
255
|
onSelect={onSelect}
|
|
@@ -277,7 +280,7 @@ export default function CanvasPage({ name }) {
|
|
|
277
280
|
// Local mutable copy of widgets for instant UI updates
|
|
278
281
|
const [localWidgets, setLocalWidgets] = useState(canvas?.widgets ?? null)
|
|
279
282
|
const [trackedCanvas, setTrackedCanvas] = useState(canvas)
|
|
280
|
-
const [
|
|
283
|
+
const [selectedWidgetIds, setSelectedWidgetIds] = useState(() => new Set())
|
|
281
284
|
const initialViewport = loadViewportState(name)
|
|
282
285
|
const [zoom, setZoom] = useState(initialViewport?.zoom ?? 100)
|
|
283
286
|
const zoomRef = useRef(initialViewport?.zoom ?? 100)
|
|
@@ -306,6 +309,92 @@ export default function CanvasPage({ name }) {
|
|
|
306
309
|
return writeQueueRef.current
|
|
307
310
|
}
|
|
308
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
|
+
|
|
309
398
|
if (canvas !== trackedCanvas) {
|
|
310
399
|
setTrackedCanvas(canvas)
|
|
311
400
|
setLocalWidgets(canvas?.widgets ?? null)
|
|
@@ -446,6 +535,44 @@ export default function CanvasPage({ name }) {
|
|
|
446
535
|
return
|
|
447
536
|
}
|
|
448
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
|
+
|
|
449
576
|
undoRedo.snapshot(stateRef.current, 'move', dragId)
|
|
450
577
|
setLocalWidgets((prev) => {
|
|
451
578
|
if (!prev) return prev
|
|
@@ -459,7 +586,7 @@ export default function CanvasPage({ name }) {
|
|
|
459
586
|
)
|
|
460
587
|
return next
|
|
461
588
|
})
|
|
462
|
-
}, [name, undoRedo])
|
|
589
|
+
}, [name, undoRedo, debouncedSave, clearDragPreview])
|
|
463
590
|
|
|
464
591
|
useEffect(() => {
|
|
465
592
|
zoomRef.current = zoom
|
|
@@ -779,22 +906,39 @@ export default function CanvasPage({ name }) {
|
|
|
779
906
|
|
|
780
907
|
useEffect(() => {
|
|
781
908
|
function handleKeyDown(e) {
|
|
782
|
-
if (
|
|
909
|
+
if (selectedWidgetIds.size === 0) return
|
|
783
910
|
const tag = e.target.tagName
|
|
784
911
|
if (tag === 'INPUT' || tag === 'TEXTAREA' || e.target.isContentEditable) return
|
|
785
912
|
if (e.key === 'Escape') {
|
|
786
913
|
e.preventDefault()
|
|
787
|
-
|
|
914
|
+
setSelectedWidgetIds(new Set())
|
|
788
915
|
}
|
|
789
916
|
if (e.key === 'Delete' || e.key === 'Backspace') {
|
|
790
917
|
e.preventDefault()
|
|
791
|
-
|
|
792
|
-
|
|
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())
|
|
793
937
|
}
|
|
794
938
|
}
|
|
795
939
|
document.addEventListener('keydown', handleKeyDown)
|
|
796
940
|
return () => document.removeEventListener('keydown', handleKeyDown)
|
|
797
|
-
}, [
|
|
941
|
+
}, [selectedWidgetIds, handleWidgetRemove, undoRedo, name, debouncedSave])
|
|
798
942
|
|
|
799
943
|
// Paste handler — images become image widgets, same-origin URLs become prototypes,
|
|
800
944
|
// other URLs become link previews, text becomes markdown
|
|
@@ -1119,6 +1263,7 @@ export default function CanvasPage({ name }) {
|
|
|
1119
1263
|
colorMode: canvas.colorMode === 'auto'
|
|
1120
1264
|
? getToolbarColorMode(canvasTheme)
|
|
1121
1265
|
: (canvas.colorMode ?? 'auto'),
|
|
1266
|
+
locked: !isLocalDev,
|
|
1122
1267
|
}
|
|
1123
1268
|
|
|
1124
1269
|
const canvasThemeVars = getCanvasThemeVars(canvasTheme)
|
|
@@ -1145,20 +1290,22 @@ export default function CanvasPage({ name }) {
|
|
|
1145
1290
|
id={`jsx-${exportName}`}
|
|
1146
1291
|
data-tc-x={sourcePosition.x}
|
|
1147
1292
|
data-tc-y={sourcePosition.y}
|
|
1148
|
-
{...(isLocalDev ? { 'data-tc-handle': '.tc-drag-handle' } : {})}
|
|
1293
|
+
{...(isLocalDev ? { 'data-tc-handle': '.tc-drag-handle, .tc-drag-surface' } : {})}
|
|
1149
1294
|
{...canvasPrimerAttrs}
|
|
1150
1295
|
style={canvasThemeVars}
|
|
1151
1296
|
onClick={isLocalDev ? (e) => {
|
|
1152
1297
|
e.stopPropagation()
|
|
1153
|
-
|
|
1298
|
+
if (!e.target.closest('.tc-drag-handle')) {
|
|
1299
|
+
handleWidgetSelect(`jsx-${exportName}`, e.shiftKey)
|
|
1300
|
+
}
|
|
1154
1301
|
} : undefined}
|
|
1155
1302
|
>
|
|
1156
1303
|
<WidgetChrome
|
|
1157
1304
|
widgetId={`jsx-${exportName}`}
|
|
1158
1305
|
features={componentFeatures}
|
|
1159
|
-
selected={
|
|
1160
|
-
onSelect={() =>
|
|
1161
|
-
onDeselect={() =>
|
|
1306
|
+
selected={selectedWidgetIds.has(`jsx-${exportName}`)}
|
|
1307
|
+
onSelect={(shiftKey) => handleWidgetSelect(`jsx-${exportName}`, shiftKey)}
|
|
1308
|
+
onDeselect={() => setSelectedWidgetIds(new Set())}
|
|
1162
1309
|
readOnly={!isLocalDev}
|
|
1163
1310
|
>
|
|
1164
1311
|
<ComponentWidget
|
|
@@ -1181,24 +1328,27 @@ export default function CanvasPage({ name }) {
|
|
|
1181
1328
|
id={widget.id}
|
|
1182
1329
|
data-tc-x={widget?.position?.x ?? 0}
|
|
1183
1330
|
data-tc-y={widget?.position?.y ?? 0}
|
|
1184
|
-
{...(isLocalDev ? { 'data-tc-handle': '.tc-drag-handle' } : {})}
|
|
1331
|
+
{...(isLocalDev ? { 'data-tc-handle': '.tc-drag-handle, .tc-drag-surface' } : {})}
|
|
1185
1332
|
{...canvasPrimerAttrs}
|
|
1186
1333
|
style={canvasThemeVars}
|
|
1187
1334
|
onClick={isLocalDev ? (e) => {
|
|
1188
1335
|
e.stopPropagation()
|
|
1189
|
-
|
|
1336
|
+
if (!e.target.closest('.tc-drag-handle')) {
|
|
1337
|
+
handleWidgetSelect(widget.id, e.shiftKey)
|
|
1338
|
+
}
|
|
1190
1339
|
} : undefined}
|
|
1191
1340
|
>
|
|
1192
1341
|
<ChromeWrappedWidget
|
|
1193
1342
|
widget={widget}
|
|
1194
|
-
selected={
|
|
1195
|
-
|
|
1196
|
-
|
|
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())}
|
|
1197
1347
|
onUpdate={isLocalDev ? handleWidgetUpdate : undefined}
|
|
1198
1348
|
onCopy={isLocalDev ? handleWidgetCopy : undefined}
|
|
1199
1349
|
onRemove={isLocalDev ? (id) => {
|
|
1200
1350
|
handleWidgetRemove(id)
|
|
1201
|
-
|
|
1351
|
+
setSelectedWidgetIds(new Set())
|
|
1202
1352
|
} : undefined}
|
|
1203
1353
|
readOnly={!isLocalDev}
|
|
1204
1354
|
/>
|
|
@@ -1239,7 +1389,7 @@ export default function CanvasPage({ name }) {
|
|
|
1239
1389
|
...canvasThemeVars,
|
|
1240
1390
|
...(spaceHeld ? { cursor: panningActive ? 'grabbing' : 'grab' } : {}),
|
|
1241
1391
|
}}
|
|
1242
|
-
onClick={() =>
|
|
1392
|
+
onClick={() => setSelectedWidgetIds(new Set())}
|
|
1243
1393
|
onMouseDown={handlePanStart}
|
|
1244
1394
|
>
|
|
1245
1395
|
<div
|
|
@@ -1254,7 +1404,7 @@ export default function CanvasPage({ name }) {
|
|
|
1254
1404
|
...(spaceHeld ? { pointerEvents: 'none' } : {}),
|
|
1255
1405
|
}}
|
|
1256
1406
|
>
|
|
1257
|
-
<Canvas {...canvasProps} onDragEnd={isLocalDev ? handleItemDragEnd : undefined}>
|
|
1407
|
+
<Canvas {...canvasProps} onDragStart={isLocalDev ? handleItemDragStart : undefined} onDrag={isLocalDev ? handleItemDrag : undefined} onDragEnd={isLocalDev ? handleItemDragEnd : undefined}>
|
|
1258
1408
|
{allChildren}
|
|
1259
1409
|
</Canvas>
|
|
1260
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
|
}
|
|
@@ -53,7 +53,7 @@ describe('useUndoRedo', () => {
|
|
|
53
53
|
const { result } = renderHook(() => useUndoRedo())
|
|
54
54
|
const s0 = [{ id: '1' }]
|
|
55
55
|
const s1 = [{ id: '1' }, { id: '2' }]
|
|
56
|
-
const s2 = [{ id: '1' }, { id: '3' }]
|
|
56
|
+
const s2 = [{ id: '1' }, { id: '3' }] // eslint-disable-line no-unused-vars
|
|
57
57
|
|
|
58
58
|
act(() => result.current.snapshot(s0, 'add'))
|
|
59
59
|
act(() => result.current.undo(s1))
|
|
@@ -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,10 +313,11 @@ 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,
|
|
310
|
-
onDeselect,
|
|
320
|
+
onDeselect, // eslint-disable-line no-unused-vars
|
|
311
321
|
onAction,
|
|
312
322
|
onUpdate,
|
|
313
323
|
children,
|
|
@@ -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
|
/**
|