@dfosco/storyboard-react 4.0.0-beta.24 → 4.0.0-beta.26
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 +8 -8
- package/src/canvas/CanvasPage.dragdrop.test.jsx +8 -8
- package/src/canvas/CanvasPage.jsx +170 -103
- package/src/canvas/CanvasPage.module.css +7 -0
- package/src/canvas/CanvasPage.multiselect.test.jsx +11 -11
- package/src/canvas/CanvasToolbar.jsx +2 -2
- package/src/canvas/canvasApi.js +12 -10
- package/src/canvas/useCanvas.js +9 -8
- package/src/canvas/widgets/ComponentWidget.jsx +21 -6
- package/src/canvas/widgets/ComponentWidget.module.css +5 -0
- package/src/canvas/widgets/FigmaEmbed.jsx +44 -15
- package/src/canvas/widgets/PrototypeEmbed.jsx +210 -208
- package/src/canvas/widgets/PrototypeEmbed.module.css +61 -19
- package/src/canvas/widgets/StoryWidget.jsx +135 -171
- package/src/canvas/widgets/StoryWidget.module.css +38 -28
- package/src/canvas/widgets/WidgetChrome.jsx +3 -2
- package/src/canvas/widgets/embedInteraction.test.jsx +86 -4
- package/src/canvas/widgets/embedTheme.js +20 -0
- package/src/canvas/widgets/iframeDevLogs.js +49 -0
- package/src/canvas/widgets/iframeDevLogs.test.jsx +81 -0
- package/src/canvas/widgets/snapshotDisplay.test.jsx +289 -0
- package/src/canvas/widgets/useSnapshotCapture.js +258 -0
- package/src/canvas/widgets/useSnapshotCapture.test.jsx +225 -0
- package/src/context.jsx +14 -14
- package/src/story/StoryPage.jsx +7 -7
- package/src/vite/data-plugin.js +25 -20
- package/src/vite/data-plugin.test.js +4 -4
- package/src/canvas/widgets/useViewportEntry.js +0 -93
|
@@ -0,0 +1,258 @@
|
|
|
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 capture.
|
|
6
|
+
*
|
|
7
|
+
* Performs TRUE dual-theme capture: captures current theme, then
|
|
8
|
+
* tells the iframe to switch theme (hidden via visibility:hidden),
|
|
9
|
+
* captures the alternate theme, and switches back. The user never
|
|
10
|
+
* sees the theme flash because the iframe is hidden during the switch.
|
|
11
|
+
*
|
|
12
|
+
* Optimized pipeline: the first theme's upload runs in parallel with
|
|
13
|
+
* the alternate theme's capture, and capture generation tokens prevent
|
|
14
|
+
* stale results from overwriting newer snapshots.
|
|
15
|
+
*
|
|
16
|
+
* Only active in dev mode (when onUpdate is provided).
|
|
17
|
+
*/
|
|
18
|
+
import { useState, useEffect, useCallback, useRef } from 'react'
|
|
19
|
+
import { uploadImage } from '../canvasApi.js'
|
|
20
|
+
|
|
21
|
+
const CAPTURE_TIMEOUT = 3000
|
|
22
|
+
const THEME_SWITCH_TIMEOUT = 2000
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Run a single capture request against the iframe.
|
|
26
|
+
* Returns the dataUrl or null on failure.
|
|
27
|
+
*/
|
|
28
|
+
function captureOnce(iframeContentWindow, requestId, listeners) {
|
|
29
|
+
return new Promise((resolve) => {
|
|
30
|
+
const timer = setTimeout(() => {
|
|
31
|
+
cleanup()
|
|
32
|
+
resolve(null)
|
|
33
|
+
}, CAPTURE_TIMEOUT)
|
|
34
|
+
|
|
35
|
+
function cleanup() {
|
|
36
|
+
clearTimeout(timer)
|
|
37
|
+
const idx = listeners.indexOf(handler)
|
|
38
|
+
if (idx !== -1) listeners.splice(idx, 1)
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function handler(data) {
|
|
42
|
+
if (data.requestId !== requestId) return
|
|
43
|
+
cleanup()
|
|
44
|
+
if (data.error || !data.dataUrl) {
|
|
45
|
+
if (data.error) console.warn('[snapshot] Capture failed:', data.error)
|
|
46
|
+
resolve(null)
|
|
47
|
+
} else {
|
|
48
|
+
resolve(data.dataUrl)
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
listeners.push(handler)
|
|
53
|
+
iframeContentWindow.postMessage({
|
|
54
|
+
type: 'storyboard:embed:capture',
|
|
55
|
+
requestId,
|
|
56
|
+
}, '*')
|
|
57
|
+
})
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Tell the iframe to switch its Primer theme and wait for confirmation.
|
|
62
|
+
*/
|
|
63
|
+
function switchTheme(iframeContentWindow, theme, requestId, listeners) {
|
|
64
|
+
return new Promise((resolve) => {
|
|
65
|
+
const timer = setTimeout(() => {
|
|
66
|
+
cleanup()
|
|
67
|
+
resolve(false)
|
|
68
|
+
}, THEME_SWITCH_TIMEOUT)
|
|
69
|
+
|
|
70
|
+
function cleanup() {
|
|
71
|
+
clearTimeout(timer)
|
|
72
|
+
const idx = listeners.indexOf(handler)
|
|
73
|
+
if (idx !== -1) listeners.splice(idx, 1)
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function handler(data) {
|
|
77
|
+
if (data.requestId !== requestId) return
|
|
78
|
+
cleanup()
|
|
79
|
+
resolve(true)
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
listeners.push(handler)
|
|
83
|
+
iframeContentWindow.postMessage({
|
|
84
|
+
type: 'storyboard:embed:set-theme',
|
|
85
|
+
theme,
|
|
86
|
+
requestId,
|
|
87
|
+
}, '*')
|
|
88
|
+
})
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Upload a captured dataUrl and return the resolved image URL.
|
|
93
|
+
*/
|
|
94
|
+
async function uploadAndResolve(dataUrl, widgetId, themeLabel, base) {
|
|
95
|
+
const filename = `snapshot-${widgetId}--${themeLabel}.webp`
|
|
96
|
+
const result = await uploadImage(dataUrl, `snapshot-${widgetId}`, filename)
|
|
97
|
+
if (result?.filename) {
|
|
98
|
+
const cacheBust = `?v=${Date.now()}`
|
|
99
|
+
return `${base}/_storyboard/canvas/images/${result.filename}${cacheBust}`
|
|
100
|
+
}
|
|
101
|
+
return null
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export function useSnapshotCapture({
|
|
105
|
+
iframeRef,
|
|
106
|
+
widgetId,
|
|
107
|
+
onUpdate,
|
|
108
|
+
canvasTheme,
|
|
109
|
+
}) {
|
|
110
|
+
const [iframeReady, setIframeReady] = useState(false)
|
|
111
|
+
const iframeReadyRef = useRef(false)
|
|
112
|
+
const capturingRef = useRef(false)
|
|
113
|
+
const requestIdCounter = useRef(0)
|
|
114
|
+
// Generation token — incremented on each capture request to detect stale results
|
|
115
|
+
const captureGeneration = useRef(0)
|
|
116
|
+
// Handlers for both snapshot and theme-applied responses
|
|
117
|
+
const responseHandlers = useRef([])
|
|
118
|
+
|
|
119
|
+
// Reset ready state when iframe is unmounted/remounted
|
|
120
|
+
useEffect(() => {
|
|
121
|
+
setIframeReady(false)
|
|
122
|
+
iframeReadyRef.current = false
|
|
123
|
+
}, [widgetId])
|
|
124
|
+
|
|
125
|
+
// Listen for postMessage events from the embedded iframe
|
|
126
|
+
useEffect(() => {
|
|
127
|
+
if (!onUpdate) return
|
|
128
|
+
|
|
129
|
+
function handler(e) {
|
|
130
|
+
if (!iframeRef.current) return
|
|
131
|
+
if (e.source !== iframeRef.current.contentWindow) return
|
|
132
|
+
|
|
133
|
+
if (e.data?.type === 'storyboard:embed:snapshot-ready') {
|
|
134
|
+
setIframeReady(true)
|
|
135
|
+
iframeReadyRef.current = true
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
if (e.data?.type === 'storyboard:embed:snapshot' ||
|
|
139
|
+
e.data?.type === 'storyboard:embed:theme-applied') {
|
|
140
|
+
for (const fn of responseHandlers.current) {
|
|
141
|
+
fn(e.data)
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
window.addEventListener('message', handler)
|
|
147
|
+
return () => window.removeEventListener('message', handler)
|
|
148
|
+
}, [iframeRef, onUpdate])
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Dual-theme capture with pipelined uploads.
|
|
152
|
+
*
|
|
153
|
+
* Pipeline: capture theme-1 → start upload-1 in parallel with
|
|
154
|
+
* (hide iframe → switch theme → capture theme-2) → upload-2.
|
|
155
|
+
*
|
|
156
|
+
* Generation tokens prevent stale captures from overwriting newer ones.
|
|
157
|
+
*
|
|
158
|
+
* @param {Object} opts
|
|
159
|
+
* @param {boolean} opts.force - Skip the iframeReady guard
|
|
160
|
+
*/
|
|
161
|
+
const requestCapture = useCallback(async ({ force = false } = {}) => {
|
|
162
|
+
if (!onUpdate) return {}
|
|
163
|
+
if (!iframeRef.current?.contentWindow) return {}
|
|
164
|
+
if (capturingRef.current) return {}
|
|
165
|
+
if (!force && !iframeReadyRef.current) return {}
|
|
166
|
+
|
|
167
|
+
capturingRef.current = true
|
|
168
|
+
const gen = ++captureGeneration.current
|
|
169
|
+
const cw = iframeRef.current.contentWindow
|
|
170
|
+
const iframe = iframeRef.current
|
|
171
|
+
const base = (import.meta.env?.BASE_URL || '/').replace(/\/$/, '')
|
|
172
|
+
const updates = {}
|
|
173
|
+
const currentTheme = canvasTheme || 'light'
|
|
174
|
+
const isCurrentDark = currentTheme.startsWith('dark')
|
|
175
|
+
const alternateTheme = isCurrentDark ? 'light' : 'dark'
|
|
176
|
+
|
|
177
|
+
try {
|
|
178
|
+
// 1. Capture current theme (iframe is visible, user sees current state)
|
|
179
|
+
const currentKey = isCurrentDark ? 'snapshotDark' : 'snapshotLight'
|
|
180
|
+
const currentLabel = isCurrentDark ? 'dark' : 'light'
|
|
181
|
+
const currentReqId = ++requestIdCounter.current
|
|
182
|
+
const currentDataUrl = await captureOnce(cw, currentReqId, responseHandlers.current)
|
|
183
|
+
|
|
184
|
+
// Bail if a newer capture started while we were waiting
|
|
185
|
+
if (gen !== captureGeneration.current) return {}
|
|
186
|
+
|
|
187
|
+
// 2. Start upload of theme-1 in parallel with alternate-theme capture
|
|
188
|
+
const uploadPromise1 = currentDataUrl
|
|
189
|
+
? uploadAndResolve(currentDataUrl, widgetId, currentLabel, base)
|
|
190
|
+
: Promise.resolve(null)
|
|
191
|
+
|
|
192
|
+
// Publish theme-1 immediately so snapshot img is ready before iframe hides
|
|
193
|
+
if (currentDataUrl) {
|
|
194
|
+
uploadPromise1.then(url => {
|
|
195
|
+
if (url && gen === captureGeneration.current) {
|
|
196
|
+
updates[currentKey] = url
|
|
197
|
+
onUpdate?.({ [currentKey]: url })
|
|
198
|
+
}
|
|
199
|
+
}).catch(() => {})
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
// 3. Hide iframe, switch theme, capture alternate — overlaps with upload-1
|
|
203
|
+
const savedVisibility = iframe.style.visibility
|
|
204
|
+
iframe.style.visibility = 'hidden'
|
|
205
|
+
|
|
206
|
+
const switchReqId = ++requestIdCounter.current
|
|
207
|
+
const switched = await switchTheme(cw, alternateTheme, switchReqId, responseHandlers.current)
|
|
208
|
+
|
|
209
|
+
if (gen !== captureGeneration.current) {
|
|
210
|
+
iframe.style.visibility = savedVisibility || ''
|
|
211
|
+
return {}
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
if (switched) {
|
|
215
|
+
const altKey = isCurrentDark ? 'snapshotLight' : 'snapshotDark'
|
|
216
|
+
const altLabel = isCurrentDark ? 'light' : 'dark'
|
|
217
|
+
const altReqId = ++requestIdCounter.current
|
|
218
|
+
const altDataUrl = await captureOnce(cw, altReqId, responseHandlers.current)
|
|
219
|
+
|
|
220
|
+
if (gen !== captureGeneration.current) {
|
|
221
|
+
iframe.style.visibility = savedVisibility || ''
|
|
222
|
+
return {}
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
if (altDataUrl) {
|
|
226
|
+
const altUrl = await uploadAndResolve(altDataUrl, widgetId, altLabel, base)
|
|
227
|
+
if (altUrl && gen === captureGeneration.current) {
|
|
228
|
+
updates[altKey] = altUrl
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
// 4. Switch back to original theme
|
|
233
|
+
const switchBackReqId = ++requestIdCounter.current
|
|
234
|
+
await switchTheme(cw, currentTheme, switchBackReqId, responseHandlers.current)
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
// Ensure upload-1 is complete before final update
|
|
238
|
+
await uploadPromise1
|
|
239
|
+
|
|
240
|
+
// 5. Restore iframe visibility
|
|
241
|
+
iframe.style.visibility = savedVisibility || ''
|
|
242
|
+
|
|
243
|
+
if (gen === captureGeneration.current && Object.keys(updates).length > 0) {
|
|
244
|
+
onUpdate?.(updates)
|
|
245
|
+
}
|
|
246
|
+
return updates
|
|
247
|
+
} catch (err) {
|
|
248
|
+
// Always restore visibility on error
|
|
249
|
+
if (iframe) iframe.style.visibility = ''
|
|
250
|
+
console.warn('[snapshot] Capture failed:', err)
|
|
251
|
+
return {}
|
|
252
|
+
} finally {
|
|
253
|
+
capturingRef.current = false
|
|
254
|
+
}
|
|
255
|
+
}, [onUpdate, iframeRef, widgetId, canvasTheme])
|
|
256
|
+
|
|
257
|
+
return { iframeReady, requestCapture }
|
|
258
|
+
}
|
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for useSnapshotCapture hook — parent-side 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--light.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
|
+
// Mock Image so preload resolves immediately in tests
|
|
39
|
+
vi.stubGlobal('Image', class MockImage {
|
|
40
|
+
constructor() { this._onload = null }
|
|
41
|
+
set onload(fn) { this._onload = fn }
|
|
42
|
+
get onload() { return this._onload }
|
|
43
|
+
set onerror(fn) { this._onerror = fn }
|
|
44
|
+
set src(v) { this._src = v; Promise.resolve().then(() => this._onload?.()) }
|
|
45
|
+
get src() { return this._src }
|
|
46
|
+
})
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
afterEach(() => { vi.restoreAllMocks() })
|
|
50
|
+
|
|
51
|
+
function dispatchMessage(source, data) {
|
|
52
|
+
const event = new MessageEvent('message', { source, data })
|
|
53
|
+
listeners.forEach(fn => fn(event))
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
it('returns iframeReady=false initially', () => {
|
|
57
|
+
const iframeRef = createMockIframeRef()
|
|
58
|
+
const { result } = renderHook(() =>
|
|
59
|
+
useSnapshotCapture({ iframeRef, widgetId: 'w1', onUpdate: vi.fn(), canvasTheme: 'light' })
|
|
60
|
+
)
|
|
61
|
+
expect(result.current.iframeReady).toBe(false)
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
it('sets iframeReady=true on snapshot-ready message', () => {
|
|
65
|
+
const cw = createMockContentWindow()
|
|
66
|
+
const iframeRef = createMockIframeRef(cw)
|
|
67
|
+
const { result } = renderHook(() =>
|
|
68
|
+
useSnapshotCapture({ iframeRef, widgetId: 'w1', onUpdate: vi.fn(), canvasTheme: 'light' })
|
|
69
|
+
)
|
|
70
|
+
act(() => { dispatchMessage(cw, { type: 'storyboard:embed:snapshot-ready' }) })
|
|
71
|
+
expect(result.current.iframeReady).toBe(true)
|
|
72
|
+
})
|
|
73
|
+
|
|
74
|
+
it('no-ops when onUpdate is null', async () => {
|
|
75
|
+
const cw = createMockContentWindow()
|
|
76
|
+
const iframeRef = createMockIframeRef(cw)
|
|
77
|
+
const { result } = renderHook(() =>
|
|
78
|
+
useSnapshotCapture({ iframeRef, widgetId: 'w1', onUpdate: null, canvasTheme: 'light' })
|
|
79
|
+
)
|
|
80
|
+
await act(async () => { await result.current.requestCapture() })
|
|
81
|
+
expect(cw.postMessage).not.toHaveBeenCalled()
|
|
82
|
+
})
|
|
83
|
+
|
|
84
|
+
it('no-ops when iframe not ready', async () => {
|
|
85
|
+
const cw = createMockContentWindow()
|
|
86
|
+
const iframeRef = createMockIframeRef(cw)
|
|
87
|
+
const { result } = renderHook(() =>
|
|
88
|
+
useSnapshotCapture({ iframeRef, widgetId: 'w1', onUpdate: vi.fn(), canvasTheme: 'light' })
|
|
89
|
+
)
|
|
90
|
+
await act(async () => { await result.current.requestCapture() })
|
|
91
|
+
expect(cw.postMessage).not.toHaveBeenCalled()
|
|
92
|
+
})
|
|
93
|
+
|
|
94
|
+
it('sends capture + set-theme + capture + set-theme for dual-theme', async () => {
|
|
95
|
+
const cw = createMockContentWindow()
|
|
96
|
+
// Need a real iframe element for style.visibility
|
|
97
|
+
const style = { visibility: "" }
|
|
98
|
+
|
|
99
|
+
const iframeRef = { current: { contentWindow: cw, style } }
|
|
100
|
+
const onUpdate = vi.fn()
|
|
101
|
+
const { result } = renderHook(() =>
|
|
102
|
+
useSnapshotCapture({ iframeRef, widgetId: 'test-widget', onUpdate, canvasTheme: 'light' })
|
|
103
|
+
)
|
|
104
|
+
|
|
105
|
+
act(() => { dispatchMessage(cw, { type: 'storyboard:embed:snapshot-ready' }) })
|
|
106
|
+
|
|
107
|
+
uploadImage
|
|
108
|
+
.mockResolvedValueOnce({ filename: 'snapshot-test-widget--light.webp' })
|
|
109
|
+
.mockResolvedValueOnce({ filename: 'snapshot-test-widget--dark.webp' })
|
|
110
|
+
|
|
111
|
+
await act(async () => {
|
|
112
|
+
const promise = result.current.requestCapture()
|
|
113
|
+
|
|
114
|
+
// Respond to capture 1 (current=light)
|
|
115
|
+
await new Promise(r => setTimeout(r, 10))
|
|
116
|
+
dispatchMessage(cw, { type: 'storyboard:embed:snapshot', requestId: 1, dataUrl: 'data:image/webp;base64,LIGHT' })
|
|
117
|
+
|
|
118
|
+
// Respond to set-theme (switch to dark)
|
|
119
|
+
await new Promise(r => setTimeout(r, 10))
|
|
120
|
+
dispatchMessage(cw, { type: 'storyboard:embed:theme-applied', requestId: 2 })
|
|
121
|
+
|
|
122
|
+
// Respond to capture 2 (alternate=dark)
|
|
123
|
+
await new Promise(r => setTimeout(r, 10))
|
|
124
|
+
dispatchMessage(cw, { type: 'storyboard:embed:snapshot', requestId: 3, dataUrl: 'data:image/webp;base64,DARK' })
|
|
125
|
+
|
|
126
|
+
// Respond to set-theme back (switch to light)
|
|
127
|
+
await new Promise(r => setTimeout(r, 10))
|
|
128
|
+
dispatchMessage(cw, { type: 'storyboard:embed:theme-applied', requestId: 4 })
|
|
129
|
+
|
|
130
|
+
await promise
|
|
131
|
+
})
|
|
132
|
+
|
|
133
|
+
// 2 captures + 2 theme switches = 4 postMessages
|
|
134
|
+
expect(cw.postMessage).toHaveBeenCalledTimes(4)
|
|
135
|
+
expect(uploadImage).toHaveBeenCalledTimes(2)
|
|
136
|
+
// Intermediate onUpdate for current theme + final with both
|
|
137
|
+
expect(onUpdate).toHaveBeenCalledWith(
|
|
138
|
+
expect.objectContaining({
|
|
139
|
+
snapshotLight: expect.stringContaining('snapshot-test-widget--light.webp'),
|
|
140
|
+
})
|
|
141
|
+
)
|
|
142
|
+
expect(onUpdate).toHaveBeenCalledWith(
|
|
143
|
+
expect.objectContaining({
|
|
144
|
+
snapshotLight: expect.stringContaining('snapshot-test-widget--light.webp'),
|
|
145
|
+
snapshotDark: expect.stringContaining('snapshot-test-widget--dark.webp'),
|
|
146
|
+
})
|
|
147
|
+
)
|
|
148
|
+
})
|
|
149
|
+
|
|
150
|
+
it('discards stale capture results via generation token', async () => {
|
|
151
|
+
const cw = createMockContentWindow()
|
|
152
|
+
const style = { visibility: "" }
|
|
153
|
+
const iframeRef = { current: { contentWindow: cw, style } }
|
|
154
|
+
const onUpdate = vi.fn()
|
|
155
|
+
const { result } = renderHook(() =>
|
|
156
|
+
useSnapshotCapture({ iframeRef, widgetId: 'gen-widget', onUpdate, canvasTheme: 'light' })
|
|
157
|
+
)
|
|
158
|
+
|
|
159
|
+
act(() => { dispatchMessage(cw, { type: 'storyboard:embed:snapshot-ready' }) })
|
|
160
|
+
|
|
161
|
+
uploadImage.mockResolvedValue({ filename: 'snapshot-gen-widget--light.webp' })
|
|
162
|
+
|
|
163
|
+
// Start first capture — will be waiting for response
|
|
164
|
+
let capture1Done = false
|
|
165
|
+
let capture2Done = false
|
|
166
|
+
await act(async () => {
|
|
167
|
+
const promise1 = result.current.requestCapture().then(() => { capture1Done = true })
|
|
168
|
+
|
|
169
|
+
// Respond to first capture
|
|
170
|
+
await new Promise(r => setTimeout(r, 10))
|
|
171
|
+
dispatchMessage(cw, { type: 'storyboard:embed:snapshot', requestId: 1, dataUrl: 'data:image/webp;base64,FIRST' })
|
|
172
|
+
|
|
173
|
+
// Start second capture before first completes its alternate-theme work
|
|
174
|
+
// This won't start because capturingRef is still true, so it will return {}
|
|
175
|
+
const promise2 = result.current.requestCapture().then(() => { capture2Done = true })
|
|
176
|
+
|
|
177
|
+
// Complete first capture's theme switch and alternate capture
|
|
178
|
+
await new Promise(r => setTimeout(r, 10))
|
|
179
|
+
dispatchMessage(cw, { type: 'storyboard:embed:theme-applied', requestId: 2 })
|
|
180
|
+
await new Promise(r => setTimeout(r, 10))
|
|
181
|
+
dispatchMessage(cw, { type: 'storyboard:embed:snapshot', requestId: 3, dataUrl: 'data:image/webp;base64,DARK' })
|
|
182
|
+
await new Promise(r => setTimeout(r, 10))
|
|
183
|
+
dispatchMessage(cw, { type: 'storyboard:embed:theme-applied', requestId: 4 })
|
|
184
|
+
|
|
185
|
+
await promise1
|
|
186
|
+
await promise2
|
|
187
|
+
})
|
|
188
|
+
|
|
189
|
+
// The second capture should have no-opped (capturingRef guard)
|
|
190
|
+
expect(capture1Done).toBe(true)
|
|
191
|
+
expect(capture2Done).toBe(true)
|
|
192
|
+
})
|
|
193
|
+
|
|
194
|
+
it('restores iframe visibility on error', async () => {
|
|
195
|
+
const cw = createMockContentWindow()
|
|
196
|
+
const style = { visibility: "visible" }
|
|
197
|
+
const iframeRef = { current: { contentWindow: cw, style } }
|
|
198
|
+
const onUpdate = vi.fn()
|
|
199
|
+
const { result } = renderHook(() =>
|
|
200
|
+
useSnapshotCapture({ iframeRef, widgetId: 'err-widget', onUpdate, canvasTheme: 'light' })
|
|
201
|
+
)
|
|
202
|
+
|
|
203
|
+
act(() => { dispatchMessage(cw, { type: 'storyboard:embed:snapshot-ready' }) })
|
|
204
|
+
|
|
205
|
+
// Make uploadImage throw to trigger error path
|
|
206
|
+
uploadImage.mockRejectedValueOnce(new Error('upload failed'))
|
|
207
|
+
|
|
208
|
+
await act(async () => {
|
|
209
|
+
const promise = result.current.requestCapture()
|
|
210
|
+
|
|
211
|
+
// Respond to capture
|
|
212
|
+
await new Promise(r => setTimeout(r, 10))
|
|
213
|
+
dispatchMessage(cw, { type: 'storyboard:embed:snapshot', requestId: 1, dataUrl: 'data:image/webp;base64,FAIL' })
|
|
214
|
+
|
|
215
|
+
await promise
|
|
216
|
+
})
|
|
217
|
+
|
|
218
|
+
// Visibility should be restored after error
|
|
219
|
+
expect(style.visibility).toBe('')
|
|
220
|
+
// onUpdate should not be called with failed data
|
|
221
|
+
expect(onUpdate).not.toHaveBeenCalledWith(
|
|
222
|
+
expect.objectContaining({ snapshotLight: expect.any(String) })
|
|
223
|
+
)
|
|
224
|
+
})
|
|
225
|
+
})
|
package/src/context.jsx
CHANGED
|
@@ -119,10 +119,10 @@ export default function StoryboardProvider({ flowName, sceneName, recordName, re
|
|
|
119
119
|
const params = useParams()
|
|
120
120
|
|
|
121
121
|
// Canvas route detection — matches current URL against registered canvas routes
|
|
122
|
-
const
|
|
122
|
+
const canvasId = useMemo(() => matchCanvasRoute(location.pathname), [location.pathname])
|
|
123
123
|
const isMissingCanvasRoute = useMemo(
|
|
124
|
-
() => isCanvasPath(location.pathname) && !
|
|
125
|
-
[location.pathname,
|
|
124
|
+
() => isCanvasPath(location.pathname) && !canvasId && !matchStoryRoute(location.pathname),
|
|
125
|
+
[location.pathname, canvasId],
|
|
126
126
|
)
|
|
127
127
|
|
|
128
128
|
// Story route detection — matches current URL against registered story routes
|
|
@@ -139,7 +139,7 @@ export default function StoryboardProvider({ flowName, sceneName, recordName, re
|
|
|
139
139
|
|
|
140
140
|
// Resolve flow name with prototype scoping (skip for canvas/story pages)
|
|
141
141
|
const activeFlowName = useMemo(() => {
|
|
142
|
-
if (
|
|
142
|
+
if (canvasId || isMissingCanvasRoute || storyName || isMissingStoryRoute) return null
|
|
143
143
|
const requested = sceneParam || flowName || sceneName
|
|
144
144
|
if (requested) {
|
|
145
145
|
// Allow fully-scoped flow names from URLs/widgets without re-prefixing
|
|
@@ -163,7 +163,7 @@ export default function StoryboardProvider({ flowName, sceneName, recordName, re
|
|
|
163
163
|
// 4. Global default — or null if no flow exists at all
|
|
164
164
|
if (flowExists('default')) return 'default'
|
|
165
165
|
return null
|
|
166
|
-
}, [
|
|
166
|
+
}, [canvasId, isMissingCanvasRoute, storyName, isMissingStoryRoute, sceneParam, flowName, sceneName, prototypeName, pageFlow])
|
|
167
167
|
|
|
168
168
|
// Auto-install body class sync (sb-key--value classes on <body>)
|
|
169
169
|
useEffect(() => installBodyClassSync(), [])
|
|
@@ -175,10 +175,10 @@ export default function StoryboardProvider({ flowName, sceneName, recordName, re
|
|
|
175
175
|
const branchSuffix = branchMatch ? ` (${branchMatch[1]})` : ''
|
|
176
176
|
|
|
177
177
|
let title
|
|
178
|
-
if (
|
|
179
|
-
const canvasData = canvases?.[
|
|
178
|
+
if (canvasId) {
|
|
179
|
+
const canvasData = canvases?.[canvasId]
|
|
180
180
|
const meta = canvasData?._canvasMeta
|
|
181
|
-
const pageTitle = canvasData?.title ||
|
|
181
|
+
const pageTitle = canvasData?.title || canvasId.split('/').pop()
|
|
182
182
|
title = (meta?.title || pageTitle) + ' · Storyboard'
|
|
183
183
|
} else if (prototypeName) {
|
|
184
184
|
title = prototypeName + ' · Storyboard'
|
|
@@ -187,7 +187,7 @@ export default function StoryboardProvider({ flowName, sceneName, recordName, re
|
|
|
187
187
|
}
|
|
188
188
|
|
|
189
189
|
document.title = title + branchSuffix
|
|
190
|
-
}, [
|
|
190
|
+
}, [canvasId, prototypeName])
|
|
191
191
|
|
|
192
192
|
// Mount design modes UI when enabled in storyboard.config.json
|
|
193
193
|
useEffect(() => {
|
|
@@ -207,7 +207,7 @@ export default function StoryboardProvider({ flowName, sceneName, recordName, re
|
|
|
207
207
|
|
|
208
208
|
// Skip flow loading for canvas/story pages and flow-less pages
|
|
209
209
|
const { data, error } = useMemo(() => {
|
|
210
|
-
if (
|
|
210
|
+
if (canvasId || isMissingCanvasRoute || storyName || isMissingStoryRoute) return { data: null, error: null }
|
|
211
211
|
if (!activeFlowName) return { data: {}, error: null }
|
|
212
212
|
try {
|
|
213
213
|
let flowData = loadFlow(activeFlowName)
|
|
@@ -226,11 +226,11 @@ export default function StoryboardProvider({ flowName, sceneName, recordName, re
|
|
|
226
226
|
} catch (err) {
|
|
227
227
|
return { data: null, error: err.message }
|
|
228
228
|
}
|
|
229
|
-
}, [
|
|
229
|
+
}, [canvasId, isMissingCanvasRoute, storyName, isMissingStoryRoute, activeFlowName, recordName, recordParam, params, prototypeName])
|
|
230
230
|
|
|
231
231
|
// Canvas pages get their own rendering path — no flow data needed
|
|
232
|
-
if (
|
|
233
|
-
const canvasData = canvases?.[
|
|
232
|
+
if (canvasId) {
|
|
233
|
+
const canvasData = canvases?.[canvasId]
|
|
234
234
|
const group = canvasData?._group
|
|
235
235
|
const siblingPages = group ? canvasGroupMap.get(group) || [] : []
|
|
236
236
|
const canvasMeta = canvasData?._canvasMeta || null
|
|
@@ -245,7 +245,7 @@ export default function StoryboardProvider({ flowName, sceneName, recordName, re
|
|
|
245
245
|
return (
|
|
246
246
|
<StoryboardContext.Provider value={canvasValue}>
|
|
247
247
|
<Suspense fallback={null}>
|
|
248
|
-
<CanvasPageLazy
|
|
248
|
+
<CanvasPageLazy canvasId={canvasId} siblingPages={siblingPages} canvasMeta={canvasMeta} />
|
|
249
249
|
</Suspense>
|
|
250
250
|
</StoryboardContext.Provider>
|
|
251
251
|
)
|
package/src/story/StoryPage.jsx
CHANGED
|
@@ -59,15 +59,15 @@ export default function StoryPage({ name }) {
|
|
|
59
59
|
return () => { cancelled = true }
|
|
60
60
|
}, [name, story])
|
|
61
61
|
|
|
62
|
-
// Signal snapshot-ready after story renders in embed mode
|
|
62
|
+
// Signal snapshot-ready after story renders in embed mode.
|
|
63
|
+
// Stories have no data loading so they render fast — fire
|
|
64
|
+
// the explicit ready signal instead of waiting for the 1.5s fallback.
|
|
63
65
|
useEffect(() => {
|
|
64
66
|
if (!isEmbed || !exports || window.parent === window) return
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
]).then(() => {
|
|
70
|
-
window.parent.postMessage({ type: 'storyboard:embed:snapshot-ready' }, '*')
|
|
67
|
+
document.fonts.ready.then(() => {
|
|
68
|
+
requestAnimationFrame(() => requestAnimationFrame(() => {
|
|
69
|
+
window.__sbSnapshotReady?.()
|
|
70
|
+
}))
|
|
71
71
|
})
|
|
72
72
|
}, [isEmbed, exports])
|
|
73
73
|
|