@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 +3 -3
- package/src/canvas/CanvasPage.bridge.test.jsx +1 -0
- package/src/canvas/CanvasPage.jsx +4 -2
- package/src/canvas/CanvasPage.multiselect.test.jsx +1 -0
- 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.8",
|
|
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.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"
|
|
@@ -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>
|
|
@@ -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
|
+
})
|