@dfosco/storyboard-react 4.0.0-beta.3 → 4.0.0-beta.31
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 +7 -4
- package/src/canvas/CanvasControls.jsx +51 -2
- package/src/canvas/CanvasControls.module.css +31 -0
- package/src/canvas/CanvasPage.bridge.test.jsx +95 -10
- package/src/canvas/CanvasPage.dragdrop.test.jsx +346 -0
- package/src/canvas/CanvasPage.jsx +790 -302
- package/src/canvas/CanvasPage.module.css +70 -47
- package/src/canvas/CanvasPage.multiselect.test.jsx +13 -11
- package/src/canvas/CanvasToolbar.jsx +2 -2
- package/src/canvas/ComponentErrorBoundary.jsx +50 -0
- package/src/canvas/PageSelector.jsx +102 -0
- package/src/canvas/PageSelector.module.css +93 -0
- package/src/canvas/PageSelector.test.jsx +104 -0
- package/src/canvas/canvasApi.js +22 -8
- package/src/canvas/canvasReloadGuard.js +37 -0
- package/src/canvas/canvasReloadGuard.test.js +27 -0
- package/src/canvas/componentIsolate.jsx +135 -0
- package/src/canvas/useCanvas.js +15 -10
- package/src/canvas/widgets/CodePenEmbed.jsx +292 -0
- package/src/canvas/widgets/CodePenEmbed.module.css +161 -0
- package/src/canvas/widgets/ComponentWidget.jsx +82 -9
- package/src/canvas/widgets/ComponentWidget.module.css +14 -6
- package/src/canvas/widgets/FigmaEmbed.jsx +110 -24
- package/src/canvas/widgets/FigmaEmbed.module.css +21 -7
- package/src/canvas/widgets/LinkPreview.jsx +247 -18
- package/src/canvas/widgets/LinkPreview.module.css +349 -8
- package/src/canvas/widgets/LinkPreview.test.jsx +71 -0
- package/src/canvas/widgets/MarkdownBlock.jsx +95 -21
- package/src/canvas/widgets/MarkdownBlock.module.css +133 -2
- package/src/canvas/widgets/MarkdownBlock.test.jsx +39 -0
- package/src/canvas/widgets/PrototypeEmbed.jsx +319 -70
- package/src/canvas/widgets/PrototypeEmbed.module.css +74 -4
- package/src/canvas/widgets/StickyNote.module.css +5 -0
- package/src/canvas/widgets/StickyNote.test.jsx +9 -9
- package/src/canvas/widgets/StoryWidget.jsx +512 -0
- package/src/canvas/widgets/StoryWidget.module.css +211 -0
- package/src/canvas/widgets/WidgetChrome.jsx +76 -20
- package/src/canvas/widgets/WidgetChrome.module.css +4 -7
- package/src/canvas/widgets/WidgetWrapper.module.css +2 -0
- package/src/canvas/widgets/codepenUrl.js +75 -0
- package/src/canvas/widgets/codepenUrl.test.js +76 -0
- package/src/canvas/widgets/embedInteraction.test.jsx +235 -0
- package/src/canvas/widgets/embedOverlay.module.css +35 -0
- package/src/canvas/widgets/embedTheme.js +56 -0
- package/src/canvas/widgets/githubUrl.js +82 -0
- package/src/canvas/widgets/githubUrl.test.js +74 -0
- package/src/canvas/widgets/iframeDevLogs.js +49 -0
- package/src/canvas/widgets/iframeDevLogs.test.jsx +81 -0
- package/src/canvas/widgets/index.js +4 -0
- package/src/canvas/widgets/pasteRules.js +295 -0
- package/src/canvas/widgets/pasteRules.test.js +474 -0
- package/src/canvas/widgets/refreshQueue.js +108 -0
- package/src/canvas/widgets/snapshotDisplay.test.jsx +259 -0
- package/src/canvas/widgets/useSnapshotCapture.js +157 -0
- package/src/canvas/widgets/useSnapshotCapture.test.jsx +164 -0
- package/src/canvas/widgets/widgetConfig.js +16 -5
- package/src/canvas/widgets/widgetConfig.test.js +34 -12
- package/src/context.jsx +141 -16
- package/src/hooks/useSceneData.js +4 -2
- package/src/story/StoryPage.jsx +117 -0
- package/src/story/StoryPage.module.css +18 -0
- package/src/vite/data-plugin.js +458 -71
- package/src/vite/data-plugin.test.js +405 -5
|
@@ -0,0 +1,259 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for iframe snapshot display — single snapshot prop.
|
|
3
|
+
*/
|
|
4
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
|
5
|
+
import { render, fireEvent, waitFor, act } from '@testing-library/react'
|
|
6
|
+
import PrototypeEmbed from './PrototypeEmbed.jsx'
|
|
7
|
+
import StoryWidget from './StoryWidget.jsx'
|
|
8
|
+
|
|
9
|
+
vi.mock('@dfosco/storyboard-core', () => ({
|
|
10
|
+
buildPrototypeIndex: () => ({
|
|
11
|
+
folders: [],
|
|
12
|
+
prototypes: [
|
|
13
|
+
{
|
|
14
|
+
name: 'Design Overview',
|
|
15
|
+
dirName: 'examples',
|
|
16
|
+
isExternal: false,
|
|
17
|
+
hideFlows: true,
|
|
18
|
+
flows: [{ route: '/test', name: 'default', meta: { title: 'Design Overview' } }],
|
|
19
|
+
},
|
|
20
|
+
],
|
|
21
|
+
globalFlows: [],
|
|
22
|
+
sorted: { title: { prototypes: [], folders: [] } },
|
|
23
|
+
}),
|
|
24
|
+
getStoryData: (storyId) => ({ _route: `/components/${storyId}` }),
|
|
25
|
+
}))
|
|
26
|
+
|
|
27
|
+
vi.mock('./WidgetWrapper.jsx', () => ({
|
|
28
|
+
default: ({ children }) => <div data-testid="widget-wrapper">{children}</div>,
|
|
29
|
+
}))
|
|
30
|
+
|
|
31
|
+
vi.mock('@dfosco/storyboard-core/inspector/highlighter', () => ({
|
|
32
|
+
createInspectorHighlighter: async () => ({
|
|
33
|
+
codeToHtml: () => '<pre><code></code></pre>',
|
|
34
|
+
}),
|
|
35
|
+
}), { virtual: true })
|
|
36
|
+
|
|
37
|
+
vi.mock('./ResizeHandle.jsx', () => ({
|
|
38
|
+
default: () => <div data-testid="resize-handle" />,
|
|
39
|
+
}))
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Render inside a wrapper with data-sb-canvas-theme, matching the real
|
|
43
|
+
* CanvasPage DOM structure that subscribeCanvasTheme reads from.
|
|
44
|
+
*/
|
|
45
|
+
function renderInCanvas(ui, theme = 'light') {
|
|
46
|
+
const wrapper = document.createElement('div')
|
|
47
|
+
wrapper.setAttribute('data-sb-canvas-theme', theme)
|
|
48
|
+
document.body.appendChild(wrapper)
|
|
49
|
+
const result = render(ui, { container: wrapper })
|
|
50
|
+
return { ...result, wrapper }
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
afterEach(() => {
|
|
54
|
+
document.querySelectorAll('[data-sb-canvas-theme]').forEach(el => el.remove())
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
describe('Snapshot display', () => {
|
|
58
|
+
describe('PrototypeEmbed', () => {
|
|
59
|
+
it('shows snapshot image when valid snapshot prop exists', () => {
|
|
60
|
+
const { wrapper } = renderInCanvas(
|
|
61
|
+
<PrototypeEmbed
|
|
62
|
+
id="proto-abc123"
|
|
63
|
+
props={{
|
|
64
|
+
src: '/test',
|
|
65
|
+
width: 400,
|
|
66
|
+
height: 300,
|
|
67
|
+
zoom: 100,
|
|
68
|
+
snapshot: '/_storyboard/canvas/images/snapshot-proto-abc123.webp?v=123',
|
|
69
|
+
}}
|
|
70
|
+
onUpdate={vi.fn()}
|
|
71
|
+
resizable={false}
|
|
72
|
+
/>
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
const img = wrapper.querySelector('img')
|
|
76
|
+
expect(img).toBeInTheDocument()
|
|
77
|
+
expect(img.src).toContain('snapshot-proto-abc123.webp')
|
|
78
|
+
expect(wrapper.querySelector('iframe')).not.toBeInTheDocument()
|
|
79
|
+
})
|
|
80
|
+
|
|
81
|
+
it('falls back to snapshotLight for backward compat', () => {
|
|
82
|
+
const { wrapper } = renderInCanvas(
|
|
83
|
+
<PrototypeEmbed
|
|
84
|
+
id="proto-abc123"
|
|
85
|
+
props={{
|
|
86
|
+
src: '/test',
|
|
87
|
+
width: 400,
|
|
88
|
+
height: 300,
|
|
89
|
+
zoom: 100,
|
|
90
|
+
snapshotLight: '/_storyboard/canvas/images/snapshot-proto-abc123--light.webp?v=1',
|
|
91
|
+
}}
|
|
92
|
+
onUpdate={vi.fn()}
|
|
93
|
+
resizable={false}
|
|
94
|
+
/>
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
const img = wrapper.querySelector('img')
|
|
98
|
+
expect(img).toBeInTheDocument()
|
|
99
|
+
expect(img.src).toContain('snapshot-proto-abc123--light.webp')
|
|
100
|
+
})
|
|
101
|
+
|
|
102
|
+
it('shows placeholder when no snapshot exists', () => {
|
|
103
|
+
const { wrapper } = renderInCanvas(
|
|
104
|
+
<PrototypeEmbed
|
|
105
|
+
id="proto-xyz"
|
|
106
|
+
props={{ src: '/test', width: 400, height: 300, zoom: 100 }}
|
|
107
|
+
onUpdate={vi.fn()}
|
|
108
|
+
resizable={false}
|
|
109
|
+
/>
|
|
110
|
+
)
|
|
111
|
+
|
|
112
|
+
expect(wrapper.querySelector('img')).not.toBeInTheDocument()
|
|
113
|
+
expect(wrapper.querySelector('iframe')).not.toBeInTheDocument()
|
|
114
|
+
})
|
|
115
|
+
|
|
116
|
+
it('falls back to placeholder when snapshot image fails to load', () => {
|
|
117
|
+
const { wrapper } = renderInCanvas(
|
|
118
|
+
<PrototypeEmbed
|
|
119
|
+
id="proto-abc123"
|
|
120
|
+
props={{
|
|
121
|
+
src: '/test',
|
|
122
|
+
width: 400,
|
|
123
|
+
height: 300,
|
|
124
|
+
zoom: 100,
|
|
125
|
+
snapshot: '/_storyboard/canvas/images/snapshot-proto-abc123.webp?v=123',
|
|
126
|
+
}}
|
|
127
|
+
onUpdate={vi.fn()}
|
|
128
|
+
resizable={false}
|
|
129
|
+
/>
|
|
130
|
+
)
|
|
131
|
+
|
|
132
|
+
const img = wrapper.querySelector('img')
|
|
133
|
+
expect(img).toBeInTheDocument()
|
|
134
|
+
fireEvent.error(img)
|
|
135
|
+
expect(wrapper.querySelector('img')).not.toBeInTheDocument()
|
|
136
|
+
})
|
|
137
|
+
|
|
138
|
+
it('ignores snapshot that does not match widget ID', () => {
|
|
139
|
+
const { wrapper } = renderInCanvas(
|
|
140
|
+
<PrototypeEmbed
|
|
141
|
+
id="proto-abc123"
|
|
142
|
+
props={{
|
|
143
|
+
src: '/test',
|
|
144
|
+
width: 400,
|
|
145
|
+
height: 300,
|
|
146
|
+
zoom: 100,
|
|
147
|
+
snapshot: '/_storyboard/canvas/images/snapshot-other-widget.webp?v=123',
|
|
148
|
+
}}
|
|
149
|
+
onUpdate={vi.fn()}
|
|
150
|
+
resizable={false}
|
|
151
|
+
/>
|
|
152
|
+
)
|
|
153
|
+
|
|
154
|
+
expect(wrapper.querySelector('img')).not.toBeInTheDocument()
|
|
155
|
+
})
|
|
156
|
+
|
|
157
|
+
it('does not show snapshot for external URLs', () => {
|
|
158
|
+
const { wrapper } = renderInCanvas(
|
|
159
|
+
<PrototypeEmbed
|
|
160
|
+
id="proto-ext"
|
|
161
|
+
props={{
|
|
162
|
+
src: 'https://example.com',
|
|
163
|
+
width: 400,
|
|
164
|
+
height: 300,
|
|
165
|
+
zoom: 100,
|
|
166
|
+
snapshot: '/_storyboard/canvas/images/snapshot-proto-ext.webp?v=123',
|
|
167
|
+
}}
|
|
168
|
+
onUpdate={vi.fn()}
|
|
169
|
+
resizable={false}
|
|
170
|
+
/>
|
|
171
|
+
)
|
|
172
|
+
|
|
173
|
+
expect(wrapper.querySelector('img')).not.toBeInTheDocument()
|
|
174
|
+
})
|
|
175
|
+
})
|
|
176
|
+
|
|
177
|
+
describe('StoryWidget', () => {
|
|
178
|
+
it('shows snapshot image when valid snapshot prop exists', () => {
|
|
179
|
+
const { wrapper } = renderInCanvas(
|
|
180
|
+
<StoryWidget
|
|
181
|
+
id="story-abc123"
|
|
182
|
+
props={{
|
|
183
|
+
storyId: 'button-patterns',
|
|
184
|
+
exportName: 'Primary',
|
|
185
|
+
width: 400,
|
|
186
|
+
height: 300,
|
|
187
|
+
snapshot: '/_storyboard/canvas/images/snapshot-story-abc123.webp?v=456',
|
|
188
|
+
}}
|
|
189
|
+
onUpdate={vi.fn()}
|
|
190
|
+
resizable={false}
|
|
191
|
+
/>
|
|
192
|
+
)
|
|
193
|
+
|
|
194
|
+
const img = wrapper.querySelector('img')
|
|
195
|
+
expect(img).toBeInTheDocument()
|
|
196
|
+
expect(img.src).toContain('snapshot-story-abc123.webp')
|
|
197
|
+
expect(wrapper.querySelector('iframe')).not.toBeInTheDocument()
|
|
198
|
+
})
|
|
199
|
+
|
|
200
|
+
it('falls back to snapshotDark for backward compat', () => {
|
|
201
|
+
const { wrapper } = renderInCanvas(
|
|
202
|
+
<StoryWidget
|
|
203
|
+
id="story-abc123"
|
|
204
|
+
props={{
|
|
205
|
+
storyId: 'button-patterns',
|
|
206
|
+
snapshotDark: '/_storyboard/canvas/images/snapshot-story-abc123--dark.webp?v=1',
|
|
207
|
+
}}
|
|
208
|
+
onUpdate={vi.fn()}
|
|
209
|
+
resizable={false}
|
|
210
|
+
/>
|
|
211
|
+
)
|
|
212
|
+
|
|
213
|
+
const img = wrapper.querySelector('img')
|
|
214
|
+
expect(img).toBeInTheDocument()
|
|
215
|
+
expect(img.src).toContain('snapshot-story-abc123--dark.webp')
|
|
216
|
+
})
|
|
217
|
+
|
|
218
|
+
it('shows placeholder when no snapshot exists', () => {
|
|
219
|
+
const { wrapper } = renderInCanvas(
|
|
220
|
+
<StoryWidget
|
|
221
|
+
id="story-xyz"
|
|
222
|
+
props={{
|
|
223
|
+
storyId: 'button-patterns',
|
|
224
|
+
exportName: 'Primary',
|
|
225
|
+
width: 400,
|
|
226
|
+
height: 300,
|
|
227
|
+
}}
|
|
228
|
+
onUpdate={vi.fn()}
|
|
229
|
+
resizable={false}
|
|
230
|
+
/>
|
|
231
|
+
)
|
|
232
|
+
|
|
233
|
+
expect(wrapper.querySelector('img')).not.toBeInTheDocument()
|
|
234
|
+
expect(wrapper.querySelector('iframe')).not.toBeInTheDocument()
|
|
235
|
+
})
|
|
236
|
+
|
|
237
|
+
it('falls back to placeholder when snapshot image fails to load', () => {
|
|
238
|
+
const { wrapper } = renderInCanvas(
|
|
239
|
+
<StoryWidget
|
|
240
|
+
id="story-abc123"
|
|
241
|
+
props={{
|
|
242
|
+
storyId: 'button-patterns',
|
|
243
|
+
exportName: 'Primary',
|
|
244
|
+
width: 400,
|
|
245
|
+
height: 300,
|
|
246
|
+
snapshot: '/_storyboard/canvas/images/snapshot-story-abc123.webp?v=456',
|
|
247
|
+
}}
|
|
248
|
+
onUpdate={vi.fn()}
|
|
249
|
+
resizable={false}
|
|
250
|
+
/>
|
|
251
|
+
)
|
|
252
|
+
|
|
253
|
+
const img = wrapper.querySelector('img')
|
|
254
|
+
expect(img).toBeInTheDocument()
|
|
255
|
+
fireEvent.error(img)
|
|
256
|
+
expect(wrapper.querySelector('img')).not.toBeInTheDocument()
|
|
257
|
+
})
|
|
258
|
+
})
|
|
259
|
+
})
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* useSnapshotCapture — parent-side capture orchestration hook.
|
|
3
|
+
*
|
|
4
|
+
* Listens for snapshot-ready signals from an embedded iframe and
|
|
5
|
+
* provides a requestCapture() function that triggers a single capture
|
|
6
|
+
* of whatever the iframe is currently showing.
|
|
7
|
+
*
|
|
8
|
+
* Saves a single `snapshot` prop — overwritten every time.
|
|
9
|
+
* Only active in dev mode (when onUpdate is provided).
|
|
10
|
+
*/
|
|
11
|
+
import { useState, useEffect, useCallback, useRef } from 'react'
|
|
12
|
+
import { uploadImage } from '../canvasApi.js'
|
|
13
|
+
|
|
14
|
+
const CAPTURE_TIMEOUT = 3000
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Run a single capture request against the iframe.
|
|
18
|
+
* Returns the dataUrl or null on failure.
|
|
19
|
+
*/
|
|
20
|
+
function captureOnce(iframeContentWindow, requestId, listeners) {
|
|
21
|
+
return new Promise((resolve) => {
|
|
22
|
+
const timer = setTimeout(() => {
|
|
23
|
+
cleanup()
|
|
24
|
+
resolve(null)
|
|
25
|
+
}, CAPTURE_TIMEOUT)
|
|
26
|
+
|
|
27
|
+
function cleanup() {
|
|
28
|
+
clearTimeout(timer)
|
|
29
|
+
const idx = listeners.indexOf(handler)
|
|
30
|
+
if (idx !== -1) listeners.splice(idx, 1)
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function handler(data) {
|
|
34
|
+
if (data.requestId !== requestId) return
|
|
35
|
+
cleanup()
|
|
36
|
+
if (data.error || !data.dataUrl) {
|
|
37
|
+
if (data.error) console.warn('[snapshot] Capture failed:', data.error)
|
|
38
|
+
resolve(null)
|
|
39
|
+
} else {
|
|
40
|
+
resolve(data.dataUrl)
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
listeners.push(handler)
|
|
45
|
+
iframeContentWindow.postMessage({
|
|
46
|
+
type: 'storyboard:embed:capture',
|
|
47
|
+
requestId,
|
|
48
|
+
}, '*')
|
|
49
|
+
})
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function useSnapshotCapture({
|
|
53
|
+
iframeRef,
|
|
54
|
+
widgetId,
|
|
55
|
+
onUpdate,
|
|
56
|
+
showIframe,
|
|
57
|
+
}) {
|
|
58
|
+
const [iframeReady, setIframeReady] = useState(false)
|
|
59
|
+
const iframeReadyRef = useRef(false)
|
|
60
|
+
const capturingRef = useRef(false)
|
|
61
|
+
const requestIdCounter = useRef(0)
|
|
62
|
+
const captureGeneration = useRef(0)
|
|
63
|
+
const responseHandlers = useRef([])
|
|
64
|
+
// Track the iframe contentWindow to reset readiness on remount
|
|
65
|
+
const lastContentWindowRef = useRef(null)
|
|
66
|
+
|
|
67
|
+
// Reset ready state when iframe is unmounted/remounted
|
|
68
|
+
useEffect(() => {
|
|
69
|
+
setIframeReady(false)
|
|
70
|
+
iframeReadyRef.current = false
|
|
71
|
+
}, [widgetId])
|
|
72
|
+
|
|
73
|
+
// Reset readiness when iframe is torn down so remount waits for new snapshot-ready
|
|
74
|
+
useEffect(() => {
|
|
75
|
+
if (!showIframe) {
|
|
76
|
+
setIframeReady(false)
|
|
77
|
+
iframeReadyRef.current = false
|
|
78
|
+
lastContentWindowRef.current = null
|
|
79
|
+
}
|
|
80
|
+
}, [showIframe])
|
|
81
|
+
|
|
82
|
+
// Listen for postMessage events from the embedded iframe
|
|
83
|
+
useEffect(() => {
|
|
84
|
+
if (!onUpdate) return
|
|
85
|
+
|
|
86
|
+
function handler(e) {
|
|
87
|
+
if (!iframeRef.current) return
|
|
88
|
+
if (e.source !== iframeRef.current.contentWindow) return
|
|
89
|
+
|
|
90
|
+
// Detect new iframe instance → reset readiness
|
|
91
|
+
if (e.source !== lastContentWindowRef.current) {
|
|
92
|
+
lastContentWindowRef.current = e.source
|
|
93
|
+
setIframeReady(false)
|
|
94
|
+
iframeReadyRef.current = false
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
if (e.data?.type === 'storyboard:embed:snapshot-ready') {
|
|
98
|
+
setIframeReady(true)
|
|
99
|
+
iframeReadyRef.current = true
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
if (e.data?.type === 'storyboard:embed:snapshot') {
|
|
103
|
+
for (const fn of responseHandlers.current) {
|
|
104
|
+
fn(e.data)
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
window.addEventListener('message', handler)
|
|
110
|
+
return () => window.removeEventListener('message', handler)
|
|
111
|
+
}, [iframeRef, onUpdate])
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Capture a single snapshot of the current iframe state.
|
|
115
|
+
* Uploads and saves as `snapshot` prop, overwriting any previous value.
|
|
116
|
+
*/
|
|
117
|
+
const requestCapture = useCallback(async ({ force = false } = {}) => {
|
|
118
|
+
if (!onUpdate) return {}
|
|
119
|
+
if (!iframeRef.current?.contentWindow) return {}
|
|
120
|
+
if (capturingRef.current) return {}
|
|
121
|
+
if (!force && !iframeReadyRef.current) return {}
|
|
122
|
+
|
|
123
|
+
capturingRef.current = true
|
|
124
|
+
const gen = ++captureGeneration.current
|
|
125
|
+
const cw = iframeRef.current.contentWindow
|
|
126
|
+
const base = (import.meta.env?.BASE_URL || '/').replace(/\/$/, '')
|
|
127
|
+
|
|
128
|
+
try {
|
|
129
|
+
const reqId = ++requestIdCounter.current
|
|
130
|
+
const dataUrl = await captureOnce(cw, reqId, responseHandlers.current)
|
|
131
|
+
|
|
132
|
+
if (gen !== captureGeneration.current) return {}
|
|
133
|
+
if (!dataUrl) return {}
|
|
134
|
+
|
|
135
|
+
const filename = `snapshot-${widgetId}.webp`
|
|
136
|
+
const result = await uploadImage(dataUrl, `snapshot-${widgetId}`, filename)
|
|
137
|
+
|
|
138
|
+
if (gen !== captureGeneration.current) return {}
|
|
139
|
+
|
|
140
|
+
if (result?.filename) {
|
|
141
|
+
const cacheBust = `?v=${Date.now()}`
|
|
142
|
+
const url = `${base}/_storyboard/canvas/images/${result.filename}${cacheBust}`
|
|
143
|
+
const updates = { snapshot: url }
|
|
144
|
+
onUpdate?.(updates)
|
|
145
|
+
return updates
|
|
146
|
+
}
|
|
147
|
+
return {}
|
|
148
|
+
} catch (err) {
|
|
149
|
+
console.warn('[snapshot] Capture failed:', err)
|
|
150
|
+
return {}
|
|
151
|
+
} finally {
|
|
152
|
+
capturingRef.current = false
|
|
153
|
+
}
|
|
154
|
+
}, [onUpdate, iframeRef, widgetId])
|
|
155
|
+
|
|
156
|
+
return { iframeReady, requestCapture }
|
|
157
|
+
}
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for useSnapshotCapture hook — single-capture orchestration.
|
|
3
|
+
*/
|
|
4
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
|
5
|
+
import { renderHook, act } from '@testing-library/react'
|
|
6
|
+
import { useSnapshotCapture } from './useSnapshotCapture.js'
|
|
7
|
+
|
|
8
|
+
vi.mock('../canvasApi.js', () => ({
|
|
9
|
+
uploadImage: vi.fn().mockResolvedValue({ filename: 'snapshot-test-widget.webp' }),
|
|
10
|
+
}))
|
|
11
|
+
|
|
12
|
+
import { uploadImage } from '../canvasApi.js'
|
|
13
|
+
|
|
14
|
+
function createMockIframeRef(contentWindow = null) {
|
|
15
|
+
return { current: contentWindow ? { contentWindow } : null }
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function createMockContentWindow() {
|
|
19
|
+
return { postMessage: vi.fn() }
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
describe('useSnapshotCapture', () => {
|
|
23
|
+
let listeners = []
|
|
24
|
+
|
|
25
|
+
beforeEach(() => {
|
|
26
|
+
vi.clearAllMocks()
|
|
27
|
+
listeners = []
|
|
28
|
+
const origAdd = window.addEventListener
|
|
29
|
+
const origRemove = window.removeEventListener
|
|
30
|
+
vi.spyOn(window, 'addEventListener').mockImplementation((type, fn, opts) => {
|
|
31
|
+
if (type === 'message') listeners.push(fn)
|
|
32
|
+
origAdd.call(window, type, fn, opts)
|
|
33
|
+
})
|
|
34
|
+
vi.spyOn(window, 'removeEventListener').mockImplementation((type, fn, opts) => {
|
|
35
|
+
if (type === 'message') listeners = listeners.filter(l => l !== fn)
|
|
36
|
+
origRemove.call(window, type, fn, opts)
|
|
37
|
+
})
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
afterEach(() => { vi.restoreAllMocks() })
|
|
41
|
+
|
|
42
|
+
function dispatchMessage(source, data) {
|
|
43
|
+
const event = new MessageEvent('message', { source, data })
|
|
44
|
+
listeners.forEach(fn => fn(event))
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
it('returns iframeReady=false initially', () => {
|
|
48
|
+
const iframeRef = createMockIframeRef()
|
|
49
|
+
const { result } = renderHook(() =>
|
|
50
|
+
useSnapshotCapture({ iframeRef, widgetId: 'w1', onUpdate: vi.fn() })
|
|
51
|
+
)
|
|
52
|
+
expect(result.current.iframeReady).toBe(false)
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
it('sets iframeReady=true on snapshot-ready message', () => {
|
|
56
|
+
const cw = createMockContentWindow()
|
|
57
|
+
const iframeRef = createMockIframeRef(cw)
|
|
58
|
+
const { result } = renderHook(() =>
|
|
59
|
+
useSnapshotCapture({ iframeRef, widgetId: 'w1', onUpdate: vi.fn() })
|
|
60
|
+
)
|
|
61
|
+
act(() => { dispatchMessage(cw, { type: 'storyboard:embed:snapshot-ready' }) })
|
|
62
|
+
expect(result.current.iframeReady).toBe(true)
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
it('no-ops when onUpdate is null', async () => {
|
|
66
|
+
const cw = createMockContentWindow()
|
|
67
|
+
const iframeRef = createMockIframeRef(cw)
|
|
68
|
+
const { result } = renderHook(() =>
|
|
69
|
+
useSnapshotCapture({ iframeRef, widgetId: 'w1', onUpdate: null })
|
|
70
|
+
)
|
|
71
|
+
await act(async () => { await result.current.requestCapture() })
|
|
72
|
+
expect(cw.postMessage).not.toHaveBeenCalled()
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
it('no-ops when iframe not ready', async () => {
|
|
76
|
+
const cw = createMockContentWindow()
|
|
77
|
+
const iframeRef = createMockIframeRef(cw)
|
|
78
|
+
const { result } = renderHook(() =>
|
|
79
|
+
useSnapshotCapture({ iframeRef, widgetId: 'w1', onUpdate: vi.fn() })
|
|
80
|
+
)
|
|
81
|
+
await act(async () => { await result.current.requestCapture() })
|
|
82
|
+
expect(cw.postMessage).not.toHaveBeenCalled()
|
|
83
|
+
})
|
|
84
|
+
|
|
85
|
+
it('sends single capture and calls onUpdate with snapshot', async () => {
|
|
86
|
+
const cw = createMockContentWindow()
|
|
87
|
+
const iframeRef = createMockIframeRef(cw)
|
|
88
|
+
const onUpdate = vi.fn()
|
|
89
|
+
const { result } = renderHook(() =>
|
|
90
|
+
useSnapshotCapture({ iframeRef, widgetId: 'test-widget', onUpdate })
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
act(() => { dispatchMessage(cw, { type: 'storyboard:embed:snapshot-ready' }) })
|
|
94
|
+
|
|
95
|
+
uploadImage.mockResolvedValueOnce({ filename: 'snapshot-test-widget.webp' })
|
|
96
|
+
|
|
97
|
+
await act(async () => {
|
|
98
|
+
const promise = result.current.requestCapture()
|
|
99
|
+
|
|
100
|
+
await new Promise(r => setTimeout(r, 10))
|
|
101
|
+
dispatchMessage(cw, { type: 'storyboard:embed:snapshot', requestId: 1, dataUrl: 'data:image/webp;base64,IMG' })
|
|
102
|
+
|
|
103
|
+
await promise
|
|
104
|
+
})
|
|
105
|
+
|
|
106
|
+
// Single capture, single postMessage
|
|
107
|
+
expect(cw.postMessage).toHaveBeenCalledTimes(1)
|
|
108
|
+
expect(uploadImage).toHaveBeenCalledTimes(1)
|
|
109
|
+
expect(onUpdate).toHaveBeenCalledWith(
|
|
110
|
+
expect.objectContaining({
|
|
111
|
+
snapshot: expect.stringContaining('snapshot-test-widget.webp'),
|
|
112
|
+
})
|
|
113
|
+
)
|
|
114
|
+
})
|
|
115
|
+
|
|
116
|
+
it('guards against concurrent captures', async () => {
|
|
117
|
+
const cw = createMockContentWindow()
|
|
118
|
+
const iframeRef = createMockIframeRef(cw)
|
|
119
|
+
const onUpdate = vi.fn()
|
|
120
|
+
const { result } = renderHook(() =>
|
|
121
|
+
useSnapshotCapture({ iframeRef, widgetId: 'w1', onUpdate })
|
|
122
|
+
)
|
|
123
|
+
|
|
124
|
+
act(() => { dispatchMessage(cw, { type: 'storyboard:embed:snapshot-ready' }) })
|
|
125
|
+
uploadImage.mockResolvedValue({ filename: 'snapshot-w1.webp' })
|
|
126
|
+
|
|
127
|
+
await act(async () => {
|
|
128
|
+
const p1 = result.current.requestCapture()
|
|
129
|
+
// Second call while first is in-flight should no-op
|
|
130
|
+
const p2 = result.current.requestCapture()
|
|
131
|
+
|
|
132
|
+
await new Promise(r => setTimeout(r, 10))
|
|
133
|
+
dispatchMessage(cw, { type: 'storyboard:embed:snapshot', requestId: 1, dataUrl: 'data:image/webp;base64,IMG' })
|
|
134
|
+
|
|
135
|
+
await p1
|
|
136
|
+
await p2
|
|
137
|
+
})
|
|
138
|
+
|
|
139
|
+
expect(cw.postMessage).toHaveBeenCalledTimes(1)
|
|
140
|
+
})
|
|
141
|
+
|
|
142
|
+
it('handles capture failure gracefully', async () => {
|
|
143
|
+
const cw = createMockContentWindow()
|
|
144
|
+
const iframeRef = createMockIframeRef(cw)
|
|
145
|
+
const onUpdate = vi.fn()
|
|
146
|
+
const { result } = renderHook(() =>
|
|
147
|
+
useSnapshotCapture({ iframeRef, widgetId: 'w1', onUpdate })
|
|
148
|
+
)
|
|
149
|
+
|
|
150
|
+
act(() => { dispatchMessage(cw, { type: 'storyboard:embed:snapshot-ready' }) })
|
|
151
|
+
uploadImage.mockRejectedValueOnce(new Error('upload failed'))
|
|
152
|
+
|
|
153
|
+
await act(async () => {
|
|
154
|
+
const promise = result.current.requestCapture()
|
|
155
|
+
|
|
156
|
+
await new Promise(r => setTimeout(r, 10))
|
|
157
|
+
dispatchMessage(cw, { type: 'storyboard:embed:snapshot', requestId: 1, dataUrl: 'data:image/webp;base64,FAIL' })
|
|
158
|
+
|
|
159
|
+
await promise
|
|
160
|
+
})
|
|
161
|
+
|
|
162
|
+
expect(onUpdate).not.toHaveBeenCalled()
|
|
163
|
+
})
|
|
164
|
+
})
|
|
@@ -34,7 +34,16 @@ function resolveFeature(feature) {
|
|
|
34
34
|
if (key === 'items' && Array.isArray(val)) {
|
|
35
35
|
resolved[key] = val.map((item) => {
|
|
36
36
|
const r = {}
|
|
37
|
-
for (const [k, v] of Object.entries(item))
|
|
37
|
+
for (const [k, v] of Object.entries(item)) {
|
|
38
|
+
// Resolve nested alt object inside items
|
|
39
|
+
if (k === 'alt' && v && typeof v === 'object') {
|
|
40
|
+
const altResolved = {}
|
|
41
|
+
for (const [ak, av] of Object.entries(v)) altResolved[ak] = resolveVar(av)
|
|
42
|
+
r[k] = altResolved
|
|
43
|
+
} else {
|
|
44
|
+
r[k] = resolveVar(v)
|
|
45
|
+
}
|
|
46
|
+
}
|
|
38
47
|
return r
|
|
39
48
|
})
|
|
40
49
|
} else if (key === 'alt' && val && typeof val === 'object') {
|
|
@@ -103,14 +112,16 @@ export const widgetTypes = buildWidgetTypes()
|
|
|
103
112
|
|
|
104
113
|
/**
|
|
105
114
|
* Get the feature list for a widget type.
|
|
106
|
-
* In production
|
|
115
|
+
* In production (or when isLocalDev is false, e.g. ?prodMode simulation),
|
|
116
|
+
* only features with `prod: true` are returned.
|
|
107
117
|
* In dev, all features are returned.
|
|
108
118
|
* @param {string} type — widget type string
|
|
119
|
+
* @param {{ isLocalDev?: boolean }} [options]
|
|
109
120
|
* @returns {Array} features array from config (variables resolved), or empty array
|
|
110
121
|
*/
|
|
111
|
-
export function getFeatures(type) {
|
|
122
|
+
export function getFeatures(type, { isLocalDev = true } = {}) {
|
|
112
123
|
const features = widgetTypes[type]?.features ?? []
|
|
113
|
-
if (import.meta.env?.PROD) {
|
|
124
|
+
if (import.meta.env?.PROD || !isLocalDev) {
|
|
114
125
|
return features.filter(f => f.prod)
|
|
115
126
|
}
|
|
116
127
|
return features
|
|
@@ -146,6 +157,6 @@ export function getWidgetMeta(type) {
|
|
|
146
157
|
*/
|
|
147
158
|
export function getMenuWidgetTypes() {
|
|
148
159
|
return Object.entries(widgetTypes)
|
|
149
|
-
.filter(([type]) => type !== 'link-preview' && type !== 'image' && type !== 'figma-embed')
|
|
160
|
+
.filter(([type]) => type !== 'link-preview' && type !== 'image' && type !== 'figma-embed' && type !== 'codepen-embed' && type !== 'story')
|
|
150
161
|
.map(([type, def]) => ({ type, label: def.label, icon: def.icon }))
|
|
151
162
|
}
|
|
@@ -2,21 +2,24 @@ import { describe, expect, it } from 'vitest'
|
|
|
2
2
|
import { isResizable, getFeatures, getWidgetMeta } from './widgetConfig.js'
|
|
3
3
|
|
|
4
4
|
describe('isResizable', () => {
|
|
5
|
-
// Vitest runs
|
|
6
|
-
//
|
|
7
|
-
it('returns
|
|
8
|
-
expect(isResizable('sticky-note')).toBe(
|
|
9
|
-
expect(isResizable('prototype')).toBe(
|
|
10
|
-
expect(isResizable('figma-embed')).toBe(
|
|
11
|
-
expect(isResizable('image')).toBe(
|
|
12
|
-
expect(isResizable('component')).toBe(
|
|
13
|
-
})
|
|
14
|
-
|
|
15
|
-
it('returns false for
|
|
16
|
-
expect(isResizable('markdown')).toBe(false)
|
|
5
|
+
// Vitest runs in dev mode by default (import.meta.env.PROD = false)
|
|
6
|
+
// In dev mode, all resize-enabled widgets are resizable
|
|
7
|
+
it('returns true for resize-enabled widgets in dev mode', () => {
|
|
8
|
+
expect(isResizable('sticky-note')).toBe(true)
|
|
9
|
+
expect(isResizable('prototype')).toBe(true)
|
|
10
|
+
expect(isResizable('figma-embed')).toBe(true)
|
|
11
|
+
expect(isResizable('image')).toBe(true)
|
|
12
|
+
expect(isResizable('component')).toBe(true)
|
|
13
|
+
})
|
|
14
|
+
|
|
15
|
+
it('returns false for link-preview (resize disabled)', () => {
|
|
17
16
|
expect(isResizable('link-preview')).toBe(false)
|
|
18
17
|
})
|
|
19
18
|
|
|
19
|
+
it('returns true for markdown (resize enabled, dev only)', () => {
|
|
20
|
+
expect(isResizable('markdown')).toBe(true)
|
|
21
|
+
})
|
|
22
|
+
|
|
20
23
|
it('returns false for unknown widget types', () => {
|
|
21
24
|
expect(isResizable('nonexistent')).toBe(false)
|
|
22
25
|
})
|
|
@@ -32,6 +35,25 @@ describe('getFeatures', () => {
|
|
|
32
35
|
it('returns empty array for unknown widget types', () => {
|
|
33
36
|
expect(getFeatures('nonexistent')).toEqual([])
|
|
34
37
|
})
|
|
38
|
+
|
|
39
|
+
it('returns only prod features when isLocalDev is false', () => {
|
|
40
|
+
const features = getFeatures('figma-embed', { isLocalDev: false })
|
|
41
|
+
expect(features.length).toBeGreaterThan(0)
|
|
42
|
+
expect(features.every(f => f.prod === true)).toBe(true)
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
it('returns all features when isLocalDev is true (default)', () => {
|
|
46
|
+
const allFeatures = getFeatures('figma-embed')
|
|
47
|
+
const prodFeatures = getFeatures('figma-embed', { isLocalDev: false })
|
|
48
|
+
expect(allFeatures.length).toBeGreaterThan(prodFeatures.length)
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
it('includes menu-only prod features when isLocalDev is false', () => {
|
|
52
|
+
const features = getFeatures('figma-embed', { isLocalDev: false })
|
|
53
|
+
const menuFeature = features.find(f => f.menu)
|
|
54
|
+
expect(menuFeature).toBeDefined()
|
|
55
|
+
expect(menuFeature.prod).toBe(true)
|
|
56
|
+
})
|
|
35
57
|
})
|
|
36
58
|
|
|
37
59
|
describe('getWidgetMeta', () => {
|