@dfosco/storyboard-react 3.11.0-beta.7 → 3.11.0-beta.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json 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.8",
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.8",
7
+ "@dfosco/tiny-canvas": "3.11.0-beta.8",
8
8
  "@neodrag/react": "^2.3.1",
9
9
  "glob": "^11.0.0",
10
10
  "jsonc-parser": "^3.3.1"
@@ -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
  }
@@ -1313,6 +1314,7 @@ export default function CanvasPage({ name }) {
1313
1314
  width={sourceData.width}
1314
1315
  height={sourceData.height}
1315
1316
  onUpdate={isLocalDev ? (updates) => handleSourceUpdate(exportName, updates) : undefined}
1317
+ resizable={isResizable('component') && isLocalDev}
1316
1318
  />
1317
1319
  </WidgetChrome>
1318
1320
  </div>
@@ -87,6 +87,7 @@ vi.mock('./widgets/widgetProps.js', () => ({
87
87
 
88
88
  vi.mock('./widgets/widgetConfig.js', () => ({
89
89
  getFeatures: () => [],
90
+ isResizable: () => false,
90
91
  schemas: {},
91
92
  getMenuWidgetTypes: () => [],
92
93
  }))
@@ -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
+ })