@dfosco/storyboard-react 3.11.0-beta.7 → 3.11.0-beta.9
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/canvas/CanvasPage.bridge.test.jsx +1 -0
- package/src/canvas/CanvasPage.jsx +109 -63
- package/src/canvas/CanvasPage.multiselect.test.jsx +88 -2
- package/src/canvas/widgets/ComponentWidget.jsx +9 -7
- package/src/canvas/widgets/FigmaEmbed.jsx +26 -24
- package/src/canvas/widgets/ImageWidget.jsx +9 -7
- package/src/canvas/widgets/PrototypeEmbed.jsx +26 -24
- package/src/canvas/widgets/StickyNote.jsx +9 -7
- package/src/canvas/widgets/StickyNote.test.jsx +10 -4
- package/src/canvas/widgets/WidgetChrome.module.css +4 -2
- package/src/canvas/widgets/widgetConfig.js +13 -0
- package/src/canvas/widgets/widgetConfig.test.js +46 -0
package/package.json
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@dfosco/storyboard-react",
|
|
3
|
-
"version": "3.11.0-beta.
|
|
3
|
+
"version": "3.11.0-beta.9",
|
|
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.9",
|
|
7
|
+
"@dfosco/tiny-canvas": "3.11.0-beta.9",
|
|
8
8
|
"@neodrag/react": "^2.3.1",
|
|
9
9
|
"glob": "^11.0.0",
|
|
10
10
|
"jsonc-parser": "^3.3.1"
|
|
@@ -7,7 +7,7 @@ import { shouldPreventCanvasTextSelection } from './textSelection.js'
|
|
|
7
7
|
import { getCanvasThemeVars, getCanvasPrimerAttrs } from './canvasTheme.js'
|
|
8
8
|
import { getWidgetComponent } from './widgets/index.js'
|
|
9
9
|
import { schemas, getDefaults } from './widgets/widgetProps.js'
|
|
10
|
-
import { getFeatures } from './widgets/widgetConfig.js'
|
|
10
|
+
import { getFeatures, isResizable } from './widgets/widgetConfig.js'
|
|
11
11
|
import { isFigmaUrl, sanitizeFigmaUrl } from './widgets/figmaUrl.js'
|
|
12
12
|
import WidgetChrome from './widgets/WidgetChrome.jsx'
|
|
13
13
|
import ComponentWidget from './widgets/ComponentWidget.jsx'
|
|
@@ -209,8 +209,9 @@ function WidgetRenderer({ widget, onUpdate, widgetRef }) {
|
|
|
209
209
|
console.warn(`[canvas] Unknown widget type: ${widget.type}`)
|
|
210
210
|
return null
|
|
211
211
|
}
|
|
212
|
+
const resizable = isResizable(widget.type) && !!onUpdate
|
|
212
213
|
// Only pass ref to forwardRef-wrapped components (e.g. PrototypeEmbed)
|
|
213
|
-
const elementProps = { id: widget.id, props: widget.props, onUpdate }
|
|
214
|
+
const elementProps = { id: widget.id, props: widget.props, onUpdate, resizable }
|
|
214
215
|
if (Component.$$typeof === Symbol.for('react.forward_ref')) {
|
|
215
216
|
elementProps.ref = widgetRef
|
|
216
217
|
}
|
|
@@ -320,8 +321,11 @@ export default function CanvasPage({ name }) {
|
|
|
320
321
|
/**
|
|
321
322
|
* Selection handler — shift+click toggles in/out of multi-select set,
|
|
322
323
|
* plain click single-selects (clears others).
|
|
324
|
+
* Suppressed immediately after a multi-drag to prevent the post-drag
|
|
325
|
+
* click from collapsing the selection.
|
|
323
326
|
*/
|
|
324
327
|
const handleWidgetSelect = useCallback((widgetId, shiftKey) => {
|
|
328
|
+
if (justDraggedRef.current) return
|
|
325
329
|
if (shiftKey) {
|
|
326
330
|
setSelectedWidgetIds(prev => {
|
|
327
331
|
const next = new Set(prev)
|
|
@@ -337,62 +341,53 @@ export default function CanvasPage({ name }) {
|
|
|
337
341
|
}
|
|
338
342
|
}, [])
|
|
339
343
|
|
|
340
|
-
// --- Multi-select
|
|
341
|
-
//
|
|
342
|
-
//
|
|
343
|
-
//
|
|
344
|
-
const
|
|
345
|
-
|
|
346
|
-
const
|
|
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
|
-
}
|
|
344
|
+
// --- Multi-select drag: peers animate to new positions on drag end ---
|
|
345
|
+
// During drag, only the dragged widget moves (via neodrag). On drag end,
|
|
346
|
+
// peer widget positions are updated via React state, and we add the
|
|
347
|
+
// tc-on-translation class so they animate smoothly to their new spots.
|
|
348
|
+
const peerArticlesRef = useRef(new Map())
|
|
349
|
+
// Flag to suppress the click-based selection reset that fires after a drag
|
|
350
|
+
const justDraggedRef = useRef(false)
|
|
353
351
|
|
|
354
|
-
const handleItemDragStart = useCallback((dragId) => {
|
|
352
|
+
const handleItemDragStart = useCallback((dragId, position) => {
|
|
355
353
|
const ids = selectedIdsRef.current
|
|
356
|
-
|
|
357
|
-
draggedArticleRef.current = null
|
|
354
|
+
peerArticlesRef.current.clear()
|
|
358
355
|
if (ids.size <= 1 || !ids.has(dragId)) return
|
|
359
356
|
|
|
360
|
-
//
|
|
361
|
-
|
|
362
|
-
const draggedArticle = draggedEl?.closest('article')
|
|
363
|
-
if (!draggedArticle) return
|
|
364
|
-
draggedArticleRef.current = draggedArticle
|
|
365
|
-
draggedStartTranslate.current = parseTranslate(draggedArticle)
|
|
357
|
+
// Suppress selection changes for the duration of the drag
|
|
358
|
+
justDraggedRef.current = true
|
|
366
359
|
|
|
367
|
-
//
|
|
360
|
+
// Collect peer article elements for transition on drag end
|
|
368
361
|
for (const id of ids) {
|
|
369
362
|
if (id === dragId) continue
|
|
370
363
|
const widgetEl = document.getElementById(id)
|
|
371
364
|
const article = widgetEl?.closest('article')
|
|
372
365
|
if (!article) continue
|
|
373
|
-
|
|
374
|
-
article,
|
|
375
|
-
...parseTranslate(article),
|
|
376
|
-
})
|
|
366
|
+
peerArticlesRef.current.set(id, article)
|
|
377
367
|
}
|
|
378
368
|
}, [])
|
|
379
369
|
|
|
380
370
|
const handleItemDrag = useCallback(() => {
|
|
381
|
-
|
|
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
|
|
371
|
+
// Peers stay put during drag — they animate on drag end
|
|
372
|
+
}, [])
|
|
387
373
|
|
|
388
|
-
|
|
389
|
-
|
|
374
|
+
/** Add transition class to peer articles so they animate to new positions. */
|
|
375
|
+
const transitionPeers = useCallback(() => {
|
|
376
|
+
for (const [, article] of peerArticlesRef.current) {
|
|
377
|
+
article.classList.add('tc-on-translation')
|
|
390
378
|
}
|
|
379
|
+
// Remove class after animation completes
|
|
380
|
+
const articles = [...peerArticlesRef.current.values()]
|
|
381
|
+
setTimeout(() => {
|
|
382
|
+
for (const article of articles) {
|
|
383
|
+
article.classList.remove('tc-on-translation')
|
|
384
|
+
}
|
|
385
|
+
}, 250 * 4)
|
|
386
|
+
peerArticlesRef.current.clear()
|
|
391
387
|
}, [])
|
|
392
388
|
|
|
393
389
|
const clearDragPreview = useCallback(() => {
|
|
394
|
-
|
|
395
|
-
draggedArticleRef.current = null
|
|
390
|
+
peerArticlesRef.current.clear()
|
|
396
391
|
}, [])
|
|
397
392
|
|
|
398
393
|
if (canvas !== trackedCanvas) {
|
|
@@ -514,40 +509,39 @@ export default function CanvasPage({ name }) {
|
|
|
514
509
|
}, [name, debouncedSourceSave, undoRedo, snapEnabled, snapGridSize])
|
|
515
510
|
|
|
516
511
|
const handleItemDragEnd = useCallback((dragId, position) => {
|
|
517
|
-
if (!dragId || !position)
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
if (dragId.startsWith('jsx-')) {
|
|
521
|
-
undoRedo.snapshot(stateRef.current, 'move', dragId)
|
|
522
|
-
const sourceExport = dragId.replace(/^jsx-/, '')
|
|
523
|
-
setLocalSources((prev) => {
|
|
524
|
-
const current = Array.isArray(prev) ? prev : []
|
|
525
|
-
const next = current.some((s) => s?.export === sourceExport)
|
|
526
|
-
? current.map((s) => (s?.export === sourceExport ? { ...s, position: rounded } : s))
|
|
527
|
-
: [...current, { export: sourceExport, position: rounded }]
|
|
528
|
-
queueWrite(() =>
|
|
529
|
-
updateCanvas(name, { sources: next }).catch((err) =>
|
|
530
|
-
console.error('[canvas] Failed to save source position:', err)
|
|
531
|
-
)
|
|
532
|
-
)
|
|
533
|
-
return next
|
|
534
|
-
})
|
|
512
|
+
if (!dragId || !position) {
|
|
513
|
+
clearDragPreview()
|
|
535
514
|
return
|
|
536
515
|
}
|
|
516
|
+
const rounded = { x: Math.max(0, roundPosition(position.x)), y: Math.max(0, roundPosition(position.y)) }
|
|
537
517
|
|
|
538
518
|
const ids = selectedIdsRef.current
|
|
539
519
|
// Multi-select move: apply same delta to all selected widgets
|
|
520
|
+
// Checked BEFORE the jsx- early return so mixed selections work
|
|
540
521
|
if (ids.size > 1 && ids.has(dragId)) {
|
|
541
|
-
|
|
522
|
+
transitionPeers()
|
|
523
|
+
// Suppress the click-based selection reset that fires after pointerup
|
|
524
|
+
justDraggedRef.current = true
|
|
525
|
+
requestAnimationFrame(() => { justDraggedRef.current = false })
|
|
542
526
|
undoRedo.snapshot(stateRef.current, 'multi-move')
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
527
|
+
|
|
528
|
+
// Compute delta from the dragged widget's old position
|
|
529
|
+
const isJsx = dragId.startsWith('jsx-')
|
|
530
|
+
let oldPos = { x: 0, y: 0 }
|
|
531
|
+
if (isJsx) {
|
|
532
|
+
const sourceExport = dragId.replace(/^jsx-/, '')
|
|
533
|
+
const source = (stateRef.current.sources ?? []).find(s => s?.export === sourceExport)
|
|
534
|
+
oldPos = source?.position || { x: 0, y: 0 }
|
|
535
|
+
} else {
|
|
536
|
+
const draggedWidget = (stateRef.current.widgets ?? []).find(w => w.id === dragId)
|
|
537
|
+
oldPos = draggedWidget?.position || { x: 0, y: 0 }
|
|
538
|
+
}
|
|
547
539
|
const dx = rounded.x - oldPos.x
|
|
548
540
|
const dy = rounded.y - oldPos.y
|
|
549
541
|
|
|
550
542
|
debouncedSave.cancel()
|
|
543
|
+
|
|
544
|
+
// Update JSON widget positions
|
|
551
545
|
setLocalWidgets((prev) => {
|
|
552
546
|
if (!prev) return prev
|
|
553
547
|
const next = prev.map((w) => {
|
|
@@ -570,6 +564,57 @@ export default function CanvasPage({ name }) {
|
|
|
570
564
|
)
|
|
571
565
|
return next
|
|
572
566
|
})
|
|
567
|
+
|
|
568
|
+
// Update JSX source positions
|
|
569
|
+
setLocalSources((prev) => {
|
|
570
|
+
const current = Array.isArray(prev) ? prev : []
|
|
571
|
+
let changed = false
|
|
572
|
+
const next = current.map((s) => {
|
|
573
|
+
if (!s?.export) return s
|
|
574
|
+
const sid = `jsx-${s.export}`
|
|
575
|
+
if (sid === dragId) {
|
|
576
|
+
changed = true
|
|
577
|
+
return { ...s, position: rounded }
|
|
578
|
+
}
|
|
579
|
+
if (ids.has(sid)) {
|
|
580
|
+
changed = true
|
|
581
|
+
return {
|
|
582
|
+
...s,
|
|
583
|
+
position: {
|
|
584
|
+
x: Math.max(0, roundPosition((s.position?.x ?? 0) + dx)),
|
|
585
|
+
y: Math.max(0, roundPosition((s.position?.y ?? 0) + dy)),
|
|
586
|
+
},
|
|
587
|
+
}
|
|
588
|
+
}
|
|
589
|
+
return s
|
|
590
|
+
})
|
|
591
|
+
if (changed) {
|
|
592
|
+
queueWrite(() =>
|
|
593
|
+
updateCanvas(name, { sources: next }).catch((err) =>
|
|
594
|
+
console.error('[canvas] Failed to save multi-move sources:', err)
|
|
595
|
+
)
|
|
596
|
+
)
|
|
597
|
+
}
|
|
598
|
+
return changed ? next : current
|
|
599
|
+
})
|
|
600
|
+
return
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
if (dragId.startsWith('jsx-')) {
|
|
604
|
+
undoRedo.snapshot(stateRef.current, 'move', dragId)
|
|
605
|
+
const sourceExport = dragId.replace(/^jsx-/, '')
|
|
606
|
+
setLocalSources((prev) => {
|
|
607
|
+
const current = Array.isArray(prev) ? prev : []
|
|
608
|
+
const next = current.some((s) => s?.export === sourceExport)
|
|
609
|
+
? current.map((s) => (s?.export === sourceExport ? { ...s, position: rounded } : s))
|
|
610
|
+
: [...current, { export: sourceExport, position: rounded }]
|
|
611
|
+
queueWrite(() =>
|
|
612
|
+
updateCanvas(name, { sources: next }).catch((err) =>
|
|
613
|
+
console.error('[canvas] Failed to save source position:', err)
|
|
614
|
+
)
|
|
615
|
+
)
|
|
616
|
+
return next
|
|
617
|
+
})
|
|
573
618
|
return
|
|
574
619
|
}
|
|
575
620
|
|
|
@@ -586,7 +631,7 @@ export default function CanvasPage({ name }) {
|
|
|
586
631
|
)
|
|
587
632
|
return next
|
|
588
633
|
})
|
|
589
|
-
}, [name, undoRedo, debouncedSave, clearDragPreview])
|
|
634
|
+
}, [name, undoRedo, debouncedSave, transitionPeers, clearDragPreview])
|
|
590
635
|
|
|
591
636
|
useEffect(() => {
|
|
592
637
|
zoomRef.current = zoom
|
|
@@ -1313,6 +1358,7 @@ export default function CanvasPage({ name }) {
|
|
|
1313
1358
|
width={sourceData.width}
|
|
1314
1359
|
height={sourceData.height}
|
|
1315
1360
|
onUpdate={isLocalDev ? (updates) => handleSourceUpdate(exportName, updates) : undefined}
|
|
1361
|
+
resizable={isResizable('component') && isLocalDev}
|
|
1316
1362
|
/>
|
|
1317
1363
|
</WidgetChrome>
|
|
1318
1364
|
</div>
|
|
@@ -15,10 +15,14 @@ vi.mock('./useUndoRedo.js', () => ({
|
|
|
15
15
|
default: () => MOCK_UNDO_REDO,
|
|
16
16
|
}))
|
|
17
17
|
|
|
18
|
-
// Expose
|
|
18
|
+
// Expose drag callbacks so tests can trigger drags with specific IDs
|
|
19
|
+
let capturedOnDragStart = null
|
|
20
|
+
let capturedOnDrag = null
|
|
19
21
|
let capturedOnDragEnd = null
|
|
20
22
|
vi.mock('@dfosco/tiny-canvas', () => ({
|
|
21
|
-
Canvas: ({ children, onDragEnd }) => {
|
|
23
|
+
Canvas: ({ children, onDragStart, onDrag, onDragEnd }) => {
|
|
24
|
+
capturedOnDragStart = onDragStart
|
|
25
|
+
capturedOnDrag = onDrag
|
|
22
26
|
capturedOnDragEnd = onDragEnd
|
|
23
27
|
return <div data-testid="tiny-canvas">{children}</div>
|
|
24
28
|
},
|
|
@@ -87,6 +91,7 @@ vi.mock('./widgets/widgetProps.js', () => ({
|
|
|
87
91
|
|
|
88
92
|
vi.mock('./widgets/widgetConfig.js', () => ({
|
|
89
93
|
getFeatures: () => [],
|
|
94
|
+
isResizable: () => false,
|
|
90
95
|
schemas: {},
|
|
91
96
|
getMenuWidgetTypes: () => [],
|
|
92
97
|
}))
|
|
@@ -108,6 +113,8 @@ describe('CanvasPage multi-select', () => {
|
|
|
108
113
|
delete window.__storyboardCanvasBridgeState
|
|
109
114
|
window.__SB_LOCAL_DEV__ = true
|
|
110
115
|
vi.clearAllMocks()
|
|
116
|
+
capturedOnDragStart = null
|
|
117
|
+
capturedOnDrag = null
|
|
111
118
|
capturedOnDragEnd = null
|
|
112
119
|
})
|
|
113
120
|
|
|
@@ -256,4 +263,83 @@ describe('CanvasPage multi-select', () => {
|
|
|
256
263
|
})
|
|
257
264
|
)
|
|
258
265
|
})
|
|
266
|
+
|
|
267
|
+
it('multi-select drag captures peer articles on drag start', async () => {
|
|
268
|
+
render(<CanvasPage name="test-canvas" />)
|
|
269
|
+
|
|
270
|
+
// Multi-select w1 and w2
|
|
271
|
+
fireEvent.click(screen.getByTestId('select-w1'))
|
|
272
|
+
fireEvent.click(screen.getByTestId('shift-select-w2'))
|
|
273
|
+
await act(async () => { await new Promise((r) => setTimeout(r, 0)) })
|
|
274
|
+
|
|
275
|
+
// All drag callbacks should be captured
|
|
276
|
+
expect(capturedOnDragStart).toBeTruthy()
|
|
277
|
+
expect(capturedOnDrag).toBeTruthy()
|
|
278
|
+
|
|
279
|
+
// Trigger drag start — should not throw
|
|
280
|
+
act(() => {
|
|
281
|
+
capturedOnDragStart('w1', { x: 100, y: 100 })
|
|
282
|
+
})
|
|
283
|
+
|
|
284
|
+
// Drag tick — peers stay put (no live preview), should not throw
|
|
285
|
+
act(() => {
|
|
286
|
+
capturedOnDrag('w1', { x: 150, y: 200 })
|
|
287
|
+
})
|
|
288
|
+
})
|
|
289
|
+
|
|
290
|
+
it('multi-select drag preserves selection after drag end', async () => {
|
|
291
|
+
render(<CanvasPage name="test-canvas" />)
|
|
292
|
+
|
|
293
|
+
// Multi-select w1 and w2
|
|
294
|
+
fireEvent.click(screen.getByTestId('select-w1'))
|
|
295
|
+
fireEvent.click(screen.getByTestId('shift-select-w2'))
|
|
296
|
+
await act(async () => { await new Promise((r) => setTimeout(r, 0)) })
|
|
297
|
+
|
|
298
|
+
expect(screen.getByTestId('chrome-w1').dataset.selected).toBeDefined()
|
|
299
|
+
expect(screen.getByTestId('chrome-w2').dataset.selected).toBeDefined()
|
|
300
|
+
|
|
301
|
+
// Simulate full drag: start → drag → end
|
|
302
|
+
act(() => {
|
|
303
|
+
capturedOnDragStart('w1', { x: 100, y: 100 })
|
|
304
|
+
})
|
|
305
|
+
act(() => {
|
|
306
|
+
capturedOnDragEnd('w1', { x: 150, y: 200 })
|
|
307
|
+
})
|
|
308
|
+
await act(async () => { await new Promise((r) => setTimeout(r, 0)) })
|
|
309
|
+
|
|
310
|
+
// Selection should still include both widgets (justDraggedRef prevents collapse)
|
|
311
|
+
expect(screen.getByTestId('chrome-w1').dataset.selected).toBeDefined()
|
|
312
|
+
expect(screen.getByTestId('chrome-w2').dataset.selected).toBeDefined()
|
|
313
|
+
})
|
|
314
|
+
|
|
315
|
+
it('any selected widget can serve as drag handler for the group', async () => {
|
|
316
|
+
render(<CanvasPage name="test-canvas" />)
|
|
317
|
+
|
|
318
|
+
// Multi-select w1 (100,100), w2 (300,100), w3 (500,200)
|
|
319
|
+
fireEvent.click(screen.getByTestId('select-w1'))
|
|
320
|
+
fireEvent.click(screen.getByTestId('shift-select-w2'))
|
|
321
|
+
fireEvent.click(screen.getByTestId('shift-select-w3'))
|
|
322
|
+
await act(async () => { await new Promise((r) => setTimeout(r, 0)) })
|
|
323
|
+
|
|
324
|
+
// Drag w2 (the middle one) to (350, 150) → delta (+50, +50)
|
|
325
|
+
act(() => {
|
|
326
|
+
capturedOnDragStart('w2', { x: 300, y: 100 })
|
|
327
|
+
})
|
|
328
|
+
act(() => {
|
|
329
|
+
capturedOnDragEnd('w2', { x: 350, y: 150 })
|
|
330
|
+
})
|
|
331
|
+
await act(async () => { await new Promise((r) => setTimeout(r, 0)) })
|
|
332
|
+
|
|
333
|
+
// All selected widgets should move by the same delta (+50, +50)
|
|
334
|
+
expect(updateCanvas).toHaveBeenCalledWith(
|
|
335
|
+
'test-canvas',
|
|
336
|
+
expect.objectContaining({
|
|
337
|
+
widgets: expect.arrayContaining([
|
|
338
|
+
expect.objectContaining({ id: 'w1', position: { x: 150, y: 150 } }),
|
|
339
|
+
expect.objectContaining({ id: 'w2', position: { x: 350, y: 150 } }),
|
|
340
|
+
expect.objectContaining({ id: 'w3', position: { x: 550, y: 250 } }),
|
|
341
|
+
]),
|
|
342
|
+
})
|
|
343
|
+
)
|
|
344
|
+
})
|
|
259
345
|
})
|
|
@@ -11,7 +11,7 @@ import styles from './ComponentWidget.module.css'
|
|
|
11
11
|
* Double-click the overlay to enter interactive mode (dropdowns, buttons work).
|
|
12
12
|
* Click outside to exit interactive mode.
|
|
13
13
|
*/
|
|
14
|
-
export default function ComponentWidget({ component: Component, width, height, onUpdate }) {
|
|
14
|
+
export default function ComponentWidget({ component: Component, width, height, onUpdate, resizable }) {
|
|
15
15
|
const containerRef = useRef(null)
|
|
16
16
|
const [interactive, setInteractive] = useState(false)
|
|
17
17
|
|
|
@@ -51,12 +51,14 @@ export default function ComponentWidget({ component: Component, width, height, o
|
|
|
51
51
|
onDoubleClick={enterInteractive}
|
|
52
52
|
/>
|
|
53
53
|
)}
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
54
|
+
{resizable && (
|
|
55
|
+
<ResizeHandle
|
|
56
|
+
targetRef={containerRef}
|
|
57
|
+
minWidth={100}
|
|
58
|
+
minHeight={60}
|
|
59
|
+
onResize={handleResize}
|
|
60
|
+
/>
|
|
61
|
+
)}
|
|
60
62
|
</div>
|
|
61
63
|
</WidgetWrapper>
|
|
62
64
|
)
|
|
@@ -23,7 +23,7 @@ function FigmaLogo() {
|
|
|
23
23
|
|
|
24
24
|
const TYPE_LABELS = { board: 'Board', design: 'Design', proto: 'Prototype' }
|
|
25
25
|
|
|
26
|
-
export default forwardRef(function FigmaEmbed({ props, onUpdate }, ref) {
|
|
26
|
+
export default forwardRef(function FigmaEmbed({ props, onUpdate, resizable }, ref) {
|
|
27
27
|
const url = readProp(props, 'url', figmaEmbedSchema)
|
|
28
28
|
const width = readProp(props, 'width', figmaEmbedSchema)
|
|
29
29
|
const height = readProp(props, 'height', figmaEmbedSchema)
|
|
@@ -139,29 +139,31 @@ export default forwardRef(function FigmaEmbed({ props, onUpdate }, ref) {
|
|
|
139
139
|
</div>
|
|
140
140
|
)}
|
|
141
141
|
</div>
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
e
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
142
|
+
{resizable && (
|
|
143
|
+
<div
|
|
144
|
+
className={styles.resizeHandle}
|
|
145
|
+
onMouseDown={(e) => {
|
|
146
|
+
e.stopPropagation()
|
|
147
|
+
e.preventDefault()
|
|
148
|
+
const startX = e.clientX
|
|
149
|
+
const startY = e.clientY
|
|
150
|
+
const startW = width
|
|
151
|
+
const startH = height
|
|
152
|
+
function onMove(ev) {
|
|
153
|
+
const newW = Math.max(200, startW + ev.clientX - startX)
|
|
154
|
+
const newH = Math.max(150, startH + ev.clientY - startY)
|
|
155
|
+
onUpdate?.({ width: newW, height: newH })
|
|
156
|
+
}
|
|
157
|
+
function onUp() {
|
|
158
|
+
document.removeEventListener('mousemove', onMove)
|
|
159
|
+
document.removeEventListener('mouseup', onUp)
|
|
160
|
+
}
|
|
161
|
+
document.addEventListener('mousemove', onMove)
|
|
162
|
+
document.addEventListener('mouseup', onUp)
|
|
163
|
+
}}
|
|
164
|
+
onPointerDown={(e) => e.stopPropagation()}
|
|
165
|
+
/>
|
|
166
|
+
)}
|
|
165
167
|
</WidgetWrapper>
|
|
166
168
|
{createPortal(
|
|
167
169
|
<div
|
|
@@ -18,7 +18,7 @@ function getImageUrl(src) {
|
|
|
18
18
|
* Canvas widget that displays a pasted image.
|
|
19
19
|
* Supports aspect-ratio locked resize and privacy toggle.
|
|
20
20
|
*/
|
|
21
|
-
const ImageWidget = forwardRef(function ImageWidget({ props, onUpdate }, ref) {
|
|
21
|
+
const ImageWidget = forwardRef(function ImageWidget({ props, onUpdate, resizable }, ref) {
|
|
22
22
|
const containerRef = useRef(null)
|
|
23
23
|
const [naturalRatio, setNaturalRatio] = useState(null)
|
|
24
24
|
|
|
@@ -99,12 +99,14 @@ const ImageWidget = forwardRef(function ImageWidget({ props, onUpdate }, ref) {
|
|
|
99
99
|
</span>
|
|
100
100
|
)}
|
|
101
101
|
</div>
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
102
|
+
{resizable && (
|
|
103
|
+
<ResizeHandle
|
|
104
|
+
targetRef={containerRef}
|
|
105
|
+
minWidth={100}
|
|
106
|
+
minHeight={60}
|
|
107
|
+
onResize={(w) => handleResize(w)}
|
|
108
|
+
/>
|
|
109
|
+
)}
|
|
108
110
|
</div>
|
|
109
111
|
</WidgetWrapper>
|
|
110
112
|
)
|
|
@@ -29,7 +29,7 @@ function resolveCanvasThemeFromStorage() {
|
|
|
29
29
|
return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'
|
|
30
30
|
}
|
|
31
31
|
|
|
32
|
-
export default forwardRef(function PrototypeEmbed({ props, onUpdate }, ref) {
|
|
32
|
+
export default forwardRef(function PrototypeEmbed({ props, onUpdate, resizable }, ref) {
|
|
33
33
|
const src = readProp(props, 'src', prototypeEmbedSchema)
|
|
34
34
|
const width = readProp(props, 'width', prototypeEmbedSchema)
|
|
35
35
|
const height = readProp(props, 'height', prototypeEmbedSchema)
|
|
@@ -416,29 +416,31 @@ export default forwardRef(function PrototypeEmbed({ props, onUpdate }, ref) {
|
|
|
416
416
|
</div>
|
|
417
417
|
)}
|
|
418
418
|
</div>
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
e
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
419
|
+
{resizable && (
|
|
420
|
+
<div
|
|
421
|
+
className={styles.resizeHandle}
|
|
422
|
+
onMouseDown={(e) => {
|
|
423
|
+
e.stopPropagation()
|
|
424
|
+
e.preventDefault()
|
|
425
|
+
const startX = e.clientX
|
|
426
|
+
const startY = e.clientY
|
|
427
|
+
const startW = width
|
|
428
|
+
const startH = height
|
|
429
|
+
function onMove(ev) {
|
|
430
|
+
const newW = Math.max(200, startW + ev.clientX - startX)
|
|
431
|
+
const newH = Math.max(150, startH + ev.clientY - startY)
|
|
432
|
+
onUpdate?.({ width: newW, height: newH })
|
|
433
|
+
}
|
|
434
|
+
function onUp() {
|
|
435
|
+
document.removeEventListener('mousemove', onMove)
|
|
436
|
+
document.removeEventListener('mouseup', onUp)
|
|
437
|
+
}
|
|
438
|
+
document.addEventListener('mousemove', onMove)
|
|
439
|
+
document.addEventListener('mouseup', onUp)
|
|
440
|
+
}}
|
|
441
|
+
onPointerDown={(e) => e.stopPropagation()}
|
|
442
|
+
/>
|
|
443
|
+
)}
|
|
442
444
|
</WidgetWrapper>
|
|
443
445
|
{createPortal(
|
|
444
446
|
<div
|
|
@@ -12,7 +12,7 @@ const COLORS = {
|
|
|
12
12
|
orange: { bg: '#fff1e5', border: '#d18616', dot: '#e8a844' },
|
|
13
13
|
}
|
|
14
14
|
|
|
15
|
-
export default function StickyNote({ props, onUpdate }) {
|
|
15
|
+
export default function StickyNote({ props, onUpdate, resizable }) {
|
|
16
16
|
const text = readProp(props, 'text', stickyNoteSchema)
|
|
17
17
|
const color = readProp(props, 'color', stickyNoteSchema)
|
|
18
18
|
const width = readProp(props, 'width', stickyNoteSchema)
|
|
@@ -75,12 +75,14 @@ export default function StickyNote({ props, onUpdate }) {
|
|
|
75
75
|
placeholder="Type here…"
|
|
76
76
|
/>
|
|
77
77
|
)}
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
78
|
+
{resizable && (
|
|
79
|
+
<ResizeHandle
|
|
80
|
+
targetRef={stickyRef}
|
|
81
|
+
minWidth={180}
|
|
82
|
+
minHeight={60}
|
|
83
|
+
onResize={handleResize}
|
|
84
|
+
/>
|
|
85
|
+
)}
|
|
84
86
|
</article>
|
|
85
87
|
</div>
|
|
86
88
|
)
|
|
@@ -49,16 +49,22 @@ describe('StickyNote', () => {
|
|
|
49
49
|
expect(sticky.style.height).toBe('200px')
|
|
50
50
|
})
|
|
51
51
|
|
|
52
|
-
it('renders a resize handle', () => {
|
|
53
|
-
const { container } = render(<StickyNote props={{ text: 'Hi' }} onUpdate={vi.fn()} />)
|
|
52
|
+
it('renders a resize handle when resizable', () => {
|
|
53
|
+
const { container } = render(<StickyNote props={{ text: 'Hi' }} onUpdate={vi.fn()} resizable />)
|
|
54
54
|
const handle = container.querySelector('[role="separator"]')
|
|
55
55
|
expect(handle).not.toBeNull()
|
|
56
56
|
})
|
|
57
57
|
|
|
58
|
+
it('does not render a resize handle when not resizable', () => {
|
|
59
|
+
const { container } = render(<StickyNote props={{ text: 'Hi' }} onUpdate={vi.fn()} resizable={false} />)
|
|
60
|
+
const handle = container.querySelector('[role="separator"]')
|
|
61
|
+
expect(handle).toBeNull()
|
|
62
|
+
})
|
|
63
|
+
|
|
58
64
|
it('calls onUpdate with new dimensions on resize drag', () => {
|
|
59
65
|
const onUpdate = vi.fn()
|
|
60
66
|
const { container } = render(
|
|
61
|
-
<StickyNote props={{ text: 'Hi', width: 200, height: 150 }} onUpdate={onUpdate} />
|
|
67
|
+
<StickyNote props={{ text: 'Hi', width: 200, height: 150 }} onUpdate={onUpdate} resizable />
|
|
62
68
|
)
|
|
63
69
|
const handle = container.querySelector('[role="separator"]')
|
|
64
70
|
const sticky = container.querySelector('article')
|
|
@@ -78,7 +84,7 @@ describe('StickyNote', () => {
|
|
|
78
84
|
it('enforces minimum dimensions during resize', () => {
|
|
79
85
|
const onUpdate = vi.fn()
|
|
80
86
|
const { container } = render(
|
|
81
|
-
<StickyNote props={{ text: 'Hi', width: 200, height: 150 }} onUpdate={onUpdate} />
|
|
87
|
+
<StickyNote props={{ text: 'Hi', width: 200, height: 150 }} onUpdate={onUpdate} resizable />
|
|
82
88
|
)
|
|
83
89
|
const handle = container.querySelector('[role="separator"]')
|
|
84
90
|
const sticky = container.querySelector('article')
|
|
@@ -138,12 +138,14 @@
|
|
|
138
138
|
border-color: var(--bgColor-accent-emphasis, #2f81f7);
|
|
139
139
|
}
|
|
140
140
|
|
|
141
|
-
.selectHandleActive
|
|
141
|
+
.selectHandleActive,
|
|
142
|
+
:global([data-sb-canvas-theme^='dark']) .selectHandleActive {
|
|
142
143
|
background: var(--bgColor-accent-emphasis, #2f81f7);
|
|
143
144
|
border-color: var(--bgColor-accent-emphasis, #2f81f7);
|
|
144
145
|
}
|
|
145
146
|
|
|
146
|
-
.selectHandleActive:hover
|
|
147
|
+
.selectHandleActive:hover,
|
|
148
|
+
:global([data-sb-canvas-theme^='dark']) .selectHandleActive:hover {
|
|
147
149
|
background: var(--bgColor-accent-emphasis, #388bfd);
|
|
148
150
|
border-color: var(--bgColor-accent-emphasis, #388bfd);
|
|
149
151
|
}
|
|
@@ -112,6 +112,19 @@ export function getFeatures(type) {
|
|
|
112
112
|
return features
|
|
113
113
|
}
|
|
114
114
|
|
|
115
|
+
/**
|
|
116
|
+
* Check if a widget type supports resize in the current environment.
|
|
117
|
+
* Returns false if resize is disabled, or if in production and prod is not true.
|
|
118
|
+
* @param {string} type — widget type string
|
|
119
|
+
* @returns {boolean}
|
|
120
|
+
*/
|
|
121
|
+
export function isResizable(type) {
|
|
122
|
+
const resize = widgetTypes[type]?.resize
|
|
123
|
+
if (!resize?.enabled) return false
|
|
124
|
+
if (import.meta.env?.PROD && !resize.prod) return false
|
|
125
|
+
return true
|
|
126
|
+
}
|
|
127
|
+
|
|
115
128
|
/**
|
|
116
129
|
* Get the display metadata (label, icon) for a widget type.
|
|
117
130
|
* @param {string} type — widget type string
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
import { isResizable, getFeatures, getWidgetMeta } from './widgetConfig.js'
|
|
3
|
+
|
|
4
|
+
describe('isResizable', () => {
|
|
5
|
+
// Vitest runs with import.meta.env.PROD = true, so prod: false widgets
|
|
6
|
+
// correctly return false. This tests the production behavior.
|
|
7
|
+
it('returns false for resize-enabled widgets when prod is false (production env)', () => {
|
|
8
|
+
expect(isResizable('sticky-note')).toBe(false)
|
|
9
|
+
expect(isResizable('prototype')).toBe(false)
|
|
10
|
+
expect(isResizable('figma-embed')).toBe(false)
|
|
11
|
+
expect(isResizable('image')).toBe(false)
|
|
12
|
+
expect(isResizable('component')).toBe(false)
|
|
13
|
+
})
|
|
14
|
+
|
|
15
|
+
it('returns false for widget types with resize disabled', () => {
|
|
16
|
+
expect(isResizable('markdown')).toBe(false)
|
|
17
|
+
expect(isResizable('link-preview')).toBe(false)
|
|
18
|
+
})
|
|
19
|
+
|
|
20
|
+
it('returns false for unknown widget types', () => {
|
|
21
|
+
expect(isResizable('nonexistent')).toBe(false)
|
|
22
|
+
})
|
|
23
|
+
})
|
|
24
|
+
|
|
25
|
+
describe('getFeatures', () => {
|
|
26
|
+
it('returns features array for known widget types', () => {
|
|
27
|
+
const features = getFeatures('sticky-note')
|
|
28
|
+
expect(Array.isArray(features)).toBe(true)
|
|
29
|
+
expect(features.length).toBeGreaterThan(0)
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
it('returns empty array for unknown widget types', () => {
|
|
33
|
+
expect(getFeatures('nonexistent')).toEqual([])
|
|
34
|
+
})
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
describe('getWidgetMeta', () => {
|
|
38
|
+
it('returns label and icon for known types', () => {
|
|
39
|
+
const meta = getWidgetMeta('sticky-note')
|
|
40
|
+
expect(meta).toEqual({ label: 'Sticky Note', icon: '📝' })
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
it('returns null for unknown types', () => {
|
|
44
|
+
expect(getWidgetMeta('nonexistent')).toBeNull()
|
|
45
|
+
})
|
|
46
|
+
})
|