@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 CHANGED
@@ -1,10 +1,10 @@
1
1
  {
2
2
  "name": "@dfosco/storyboard-react",
3
- "version": "3.11.0-beta.7",
3
+ "version": "3.11.0-beta.9",
4
4
  "type": "module",
5
5
  "dependencies": {
6
- "@dfosco/storyboard-core": "3.11.0-beta.7",
7
- "@dfosco/tiny-canvas": "3.11.0-beta.7",
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"
@@ -65,6 +65,7 @@ vi.mock('./widgets/widgetProps.js', () => ({
65
65
 
66
66
  vi.mock('./widgets/widgetConfig.js', () => ({
67
67
  getFeatures: () => [],
68
+ isResizable: () => false,
68
69
  schemas: {},
69
70
  getMenuWidgetTypes: () => [],
70
71
  }))
@@ -7,7 +7,7 @@ import { shouldPreventCanvasTextSelection } from './textSelection.js'
7
7
  import { getCanvasThemeVars, getCanvasPrimerAttrs } from './canvasTheme.js'
8
8
  import { getWidgetComponent } from './widgets/index.js'
9
9
  import { schemas, getDefaults } from './widgets/widgetProps.js'
10
- import { getFeatures } from './widgets/widgetConfig.js'
10
+ import { getFeatures, isResizable } from './widgets/widgetConfig.js'
11
11
  import { isFigmaUrl, sanitizeFigmaUrl } from './widgets/figmaUrl.js'
12
12
  import WidgetChrome from './widgets/WidgetChrome.jsx'
13
13
  import ComponentWidget from './widgets/ComponentWidget.jsx'
@@ -209,8 +209,9 @@ function WidgetRenderer({ widget, onUpdate, widgetRef }) {
209
209
  console.warn(`[canvas] Unknown widget type: ${widget.type}`)
210
210
  return null
211
211
  }
212
+ const resizable = isResizable(widget.type) && !!onUpdate
212
213
  // Only pass ref to forwardRef-wrapped components (e.g. PrototypeEmbed)
213
- const elementProps = { id: widget.id, props: widget.props, onUpdate }
214
+ const elementProps = { id: widget.id, props: widget.props, onUpdate, resizable }
214
215
  if (Component.$$typeof === Symbol.for('react.forward_ref')) {
215
216
  elementProps.ref = widgetRef
216
217
  }
@@ -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 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
- }
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
- peerSnapshots.current.clear()
357
- draggedArticleRef.current = null
354
+ peerArticlesRef.current.clear()
358
355
  if (ids.size <= 1 || !ids.has(dragId)) return
359
356
 
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)
357
+ // Suppress selection changes for the duration of the drag
358
+ justDraggedRef.current = true
366
359
 
367
- // Snapshot each peer's article translate
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
- peerSnapshots.current.set(id, {
374
- article,
375
- ...parseTranslate(article),
376
- })
366
+ peerArticlesRef.current.set(id, article)
377
367
  }
378
368
  }, [])
379
369
 
380
370
  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
371
+ // Peers stay put during drag — they animate on drag end
372
+ }, [])
387
373
 
388
- for (const [, peer] of peerSnapshots.current) {
389
- peer.article.style.translate = `${peer.x + dx}px ${peer.y + dy}px`
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
- peerSnapshots.current.clear()
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) return
518
- const rounded = { x: Math.max(0, roundPosition(position.x)), y: Math.max(0, roundPosition(position.y)) }
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
- clearDragPreview()
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
- 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 }
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 onDragEnd so tests can trigger drags with specific IDs
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
- <ResizeHandle
55
- targetRef={containerRef}
56
- minWidth={100}
57
- minHeight={60}
58
- onResize={handleResize}
59
- />
54
+ {resizable && (
55
+ <ResizeHandle
56
+ targetRef={containerRef}
57
+ minWidth={100}
58
+ minHeight={60}
59
+ onResize={handleResize}
60
+ />
61
+ )}
60
62
  </div>
61
63
  </WidgetWrapper>
62
64
  )
@@ -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
- <div
143
- className={styles.resizeHandle}
144
- onMouseDown={(e) => {
145
- e.stopPropagation()
146
- e.preventDefault()
147
- const startX = e.clientX
148
- const startY = e.clientY
149
- const startW = width
150
- const startH = height
151
- function onMove(ev) {
152
- const newW = Math.max(200, startW + ev.clientX - startX)
153
- const newH = Math.max(150, startH + ev.clientY - startY)
154
- onUpdate?.({ width: newW, height: newH })
155
- }
156
- function onUp() {
157
- document.removeEventListener('mousemove', onMove)
158
- document.removeEventListener('mouseup', onUp)
159
- }
160
- document.addEventListener('mousemove', onMove)
161
- document.addEventListener('mouseup', onUp)
162
- }}
163
- onPointerDown={(e) => e.stopPropagation()}
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
- <ResizeHandle
103
- targetRef={containerRef}
104
- minWidth={100}
105
- minHeight={60}
106
- onResize={(w) => handleResize(w)}
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
- <div
420
- className={styles.resizeHandle}
421
- onMouseDown={(e) => {
422
- e.stopPropagation()
423
- e.preventDefault()
424
- const startX = e.clientX
425
- const startY = e.clientY
426
- const startW = width
427
- const startH = height
428
- function onMove(ev) {
429
- const newW = Math.max(200, startW + ev.clientX - startX)
430
- const newH = Math.max(150, startH + ev.clientY - startY)
431
- onUpdate?.({ width: newW, height: newH })
432
- }
433
- function onUp() {
434
- document.removeEventListener('mousemove', onMove)
435
- document.removeEventListener('mouseup', onUp)
436
- }
437
- document.addEventListener('mousemove', onMove)
438
- document.addEventListener('mouseup', onUp)
439
- }}
440
- onPointerDown={(e) => e.stopPropagation()}
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
- <ResizeHandle
79
- targetRef={stickyRef}
80
- minWidth={180}
81
- minHeight={60}
82
- onResize={handleResize}
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
+ })