@dfosco/storyboard-react 4.0.0-beta.26 → 4.0.0-beta.28
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 +87 -2
- package/src/canvas/CanvasPage.jsx +161 -18
- package/src/canvas/CanvasPage.module.css +54 -0
- package/src/canvas/CanvasPage.multiselect.test.jsx +2 -0
- package/src/canvas/canvasApi.js +8 -0
- package/src/canvas/widgets/FigmaEmbed.jsx +42 -7
- package/src/canvas/widgets/FigmaEmbed.module.css +21 -0
- 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 +2 -1
- package/src/canvas/widgets/MarkdownBlock.module.css +34 -11
- package/src/canvas/widgets/PrototypeEmbed.jsx +101 -44
- package/src/canvas/widgets/PrototypeEmbed.module.css +1 -0
- package/src/canvas/widgets/StoryWidget.jsx +86 -42
- package/src/canvas/widgets/StoryWidget.module.css +1 -0
- package/src/canvas/widgets/WidgetChrome.jsx +20 -1
- package/src/canvas/widgets/embedInteraction.test.jsx +16 -18
- package/src/canvas/widgets/embedTheme.js +37 -1
- package/src/canvas/widgets/githubUrl.js +82 -0
- package/src/canvas/widgets/githubUrl.test.js +74 -0
- package/src/canvas/widgets/refreshQueue.js +108 -0
- package/src/canvas/widgets/snapshotDisplay.test.jsx +60 -90
- package/src/canvas/widgets/useSnapshotCapture.js +38 -139
- package/src/canvas/widgets/useSnapshotCapture.test.jsx +30 -91
- package/src/canvas/widgets/widgetConfig.test.js +1 -1
- package/src/story/StoryPage.jsx +25 -60
- package/src/story/StoryPage.module.css +0 -55
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Tests for iframe snapshot display —
|
|
2
|
+
* Tests for iframe snapshot display — single snapshot prop.
|
|
3
3
|
*/
|
|
4
|
-
import { describe, it, expect, vi } from 'vitest'
|
|
5
|
-
import { render,
|
|
4
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
|
5
|
+
import { render, fireEvent, waitFor, act } from '@testing-library/react'
|
|
6
6
|
import PrototypeEmbed from './PrototypeEmbed.jsx'
|
|
7
7
|
import StoryWidget from './StoryWidget.jsx'
|
|
8
8
|
|
|
@@ -32,16 +32,32 @@ vi.mock('@dfosco/storyboard-core/inspector/highlighter', () => ({
|
|
|
32
32
|
createInspectorHighlighter: async () => ({
|
|
33
33
|
codeToHtml: () => '<pre><code></code></pre>',
|
|
34
34
|
}),
|
|
35
|
-
}))
|
|
35
|
+
}), { virtual: true })
|
|
36
36
|
|
|
37
37
|
vi.mock('./ResizeHandle.jsx', () => ({
|
|
38
38
|
default: () => <div data-testid="resize-handle" />,
|
|
39
39
|
}))
|
|
40
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
|
+
|
|
41
57
|
describe('Snapshot display', () => {
|
|
42
58
|
describe('PrototypeEmbed', () => {
|
|
43
59
|
it('shows snapshot image when valid snapshot prop exists', () => {
|
|
44
|
-
const {
|
|
60
|
+
const { wrapper } = renderInCanvas(
|
|
45
61
|
<PrototypeEmbed
|
|
46
62
|
id="proto-abc123"
|
|
47
63
|
props={{
|
|
@@ -49,22 +65,21 @@ describe('Snapshot display', () => {
|
|
|
49
65
|
width: 400,
|
|
50
66
|
height: 300,
|
|
51
67
|
zoom: 100,
|
|
52
|
-
|
|
68
|
+
snapshot: '/_storyboard/canvas/images/snapshot-proto-abc123.webp?v=123',
|
|
53
69
|
}}
|
|
54
70
|
onUpdate={vi.fn()}
|
|
55
71
|
resizable={false}
|
|
56
72
|
/>
|
|
57
73
|
)
|
|
58
74
|
|
|
59
|
-
const img =
|
|
75
|
+
const img = wrapper.querySelector('img')
|
|
60
76
|
expect(img).toBeInTheDocument()
|
|
61
|
-
expect(img.src).toContain('snapshot-proto-abc123
|
|
62
|
-
expect(
|
|
63
|
-
expect(container.querySelector('iframe')).not.toBeInTheDocument()
|
|
77
|
+
expect(img.src).toContain('snapshot-proto-abc123.webp')
|
|
78
|
+
expect(wrapper.querySelector('iframe')).not.toBeInTheDocument()
|
|
64
79
|
})
|
|
65
80
|
|
|
66
|
-
it('
|
|
67
|
-
const {
|
|
81
|
+
it('falls back to snapshotLight for backward compat', () => {
|
|
82
|
+
const { wrapper } = renderInCanvas(
|
|
68
83
|
<PrototypeEmbed
|
|
69
84
|
id="proto-abc123"
|
|
70
85
|
props={{
|
|
@@ -73,24 +88,19 @@ describe('Snapshot display', () => {
|
|
|
73
88
|
height: 300,
|
|
74
89
|
zoom: 100,
|
|
75
90
|
snapshotLight: '/_storyboard/canvas/images/snapshot-proto-abc123--light.webp?v=1',
|
|
76
|
-
snapshotDark: '/_storyboard/canvas/images/snapshot-proto-abc123--dark.webp?v=1',
|
|
77
91
|
}}
|
|
78
92
|
onUpdate={vi.fn()}
|
|
79
93
|
resizable={false}
|
|
80
94
|
/>
|
|
81
95
|
)
|
|
82
96
|
|
|
83
|
-
const
|
|
84
|
-
expect(
|
|
85
|
-
|
|
86
|
-
expect(imgs[0].src).toContain('--light.webp')
|
|
87
|
-
expect(imgs[0].style.visibility).not.toBe('hidden')
|
|
88
|
-
expect(imgs[1].src).toContain('--dark.webp')
|
|
89
|
-
expect(imgs[1].style.visibility).toBe('hidden')
|
|
97
|
+
const img = wrapper.querySelector('img')
|
|
98
|
+
expect(img).toBeInTheDocument()
|
|
99
|
+
expect(img.src).toContain('snapshot-proto-abc123--light.webp')
|
|
90
100
|
})
|
|
91
101
|
|
|
92
102
|
it('shows placeholder when no snapshot exists', () => {
|
|
93
|
-
const {
|
|
103
|
+
const { wrapper } = renderInCanvas(
|
|
94
104
|
<PrototypeEmbed
|
|
95
105
|
id="proto-xyz"
|
|
96
106
|
props={{ src: '/test', width: 400, height: 300, zoom: 100 }}
|
|
@@ -99,13 +109,12 @@ describe('Snapshot display', () => {
|
|
|
99
109
|
/>
|
|
100
110
|
)
|
|
101
111
|
|
|
102
|
-
expect(
|
|
103
|
-
expect(
|
|
104
|
-
expect(screen.getByText('Design Overview prototype')).toBeInTheDocument()
|
|
112
|
+
expect(wrapper.querySelector('img')).not.toBeInTheDocument()
|
|
113
|
+
expect(wrapper.querySelector('iframe')).not.toBeInTheDocument()
|
|
105
114
|
})
|
|
106
115
|
|
|
107
116
|
it('falls back to placeholder when snapshot image fails to load', () => {
|
|
108
|
-
const {
|
|
117
|
+
const { wrapper } = renderInCanvas(
|
|
109
118
|
<PrototypeEmbed
|
|
110
119
|
id="proto-abc123"
|
|
111
120
|
props={{
|
|
@@ -113,25 +122,21 @@ describe('Snapshot display', () => {
|
|
|
113
122
|
width: 400,
|
|
114
123
|
height: 300,
|
|
115
124
|
zoom: 100,
|
|
116
|
-
|
|
125
|
+
snapshot: '/_storyboard/canvas/images/snapshot-proto-abc123.webp?v=123',
|
|
117
126
|
}}
|
|
118
127
|
onUpdate={vi.fn()}
|
|
119
128
|
resizable={false}
|
|
120
129
|
/>
|
|
121
130
|
)
|
|
122
131
|
|
|
123
|
-
const img =
|
|
132
|
+
const img = wrapper.querySelector('img')
|
|
124
133
|
expect(img).toBeInTheDocument()
|
|
125
|
-
|
|
126
134
|
fireEvent.error(img)
|
|
127
|
-
|
|
128
|
-
// After error, img should be gone and placeholder shown
|
|
129
|
-
expect(container.querySelector('img')).not.toBeInTheDocument()
|
|
130
|
-
expect(screen.getByText('Design Overview prototype')).toBeInTheDocument()
|
|
135
|
+
expect(wrapper.querySelector('img')).not.toBeInTheDocument()
|
|
131
136
|
})
|
|
132
137
|
|
|
133
138
|
it('ignores snapshot that does not match widget ID', () => {
|
|
134
|
-
const {
|
|
139
|
+
const { wrapper } = renderInCanvas(
|
|
135
140
|
<PrototypeEmbed
|
|
136
141
|
id="proto-abc123"
|
|
137
142
|
props={{
|
|
@@ -139,20 +144,18 @@ describe('Snapshot display', () => {
|
|
|
139
144
|
width: 400,
|
|
140
145
|
height: 300,
|
|
141
146
|
zoom: 100,
|
|
142
|
-
|
|
147
|
+
snapshot: '/_storyboard/canvas/images/snapshot-other-widget.webp?v=123',
|
|
143
148
|
}}
|
|
144
149
|
onUpdate={vi.fn()}
|
|
145
150
|
resizable={false}
|
|
146
151
|
/>
|
|
147
152
|
)
|
|
148
153
|
|
|
149
|
-
|
|
150
|
-
expect(container.querySelector('img')).not.toBeInTheDocument()
|
|
151
|
-
expect(screen.getByText('Design Overview prototype')).toBeInTheDocument()
|
|
154
|
+
expect(wrapper.querySelector('img')).not.toBeInTheDocument()
|
|
152
155
|
})
|
|
153
156
|
|
|
154
157
|
it('does not show snapshot for external URLs', () => {
|
|
155
|
-
const {
|
|
158
|
+
const { wrapper } = renderInCanvas(
|
|
156
159
|
<PrototypeEmbed
|
|
157
160
|
id="proto-ext"
|
|
158
161
|
props={{
|
|
@@ -160,45 +163,20 @@ describe('Snapshot display', () => {
|
|
|
160
163
|
width: 400,
|
|
161
164
|
height: 300,
|
|
162
165
|
zoom: 100,
|
|
163
|
-
|
|
164
|
-
}}
|
|
165
|
-
onUpdate={vi.fn()}
|
|
166
|
-
resizable={false}
|
|
167
|
-
/>
|
|
168
|
-
)
|
|
169
|
-
|
|
170
|
-
// External URLs should never show snapshots
|
|
171
|
-
expect(container.querySelector('img')).not.toBeInTheDocument()
|
|
172
|
-
})
|
|
173
|
-
|
|
174
|
-
it('falls back to light snapshot when dark snapshot is missing', () => {
|
|
175
|
-
const { container } = render(
|
|
176
|
-
<PrototypeEmbed
|
|
177
|
-
id="proto-abc123"
|
|
178
|
-
props={{
|
|
179
|
-
src: '/test',
|
|
180
|
-
width: 400,
|
|
181
|
-
height: 300,
|
|
182
|
-
zoom: 100,
|
|
183
|
-
snapshotLight: '/_storyboard/canvas/images/snapshot-proto-abc123--light.webp?v=1',
|
|
184
|
-
// no snapshotDark — light snapshot should show regardless of theme
|
|
166
|
+
snapshot: '/_storyboard/canvas/images/snapshot-proto-ext.webp?v=123',
|
|
185
167
|
}}
|
|
186
168
|
onUpdate={vi.fn()}
|
|
187
169
|
resizable={false}
|
|
188
170
|
/>
|
|
189
171
|
)
|
|
190
172
|
|
|
191
|
-
|
|
192
|
-
expect(imgs).toHaveLength(1)
|
|
193
|
-
expect(imgs[0].src).toContain('--light.webp')
|
|
194
|
-
// Should be visible (no visibility: hidden) since it's the only snapshot
|
|
195
|
-
expect(imgs[0].style.visibility).not.toBe('hidden')
|
|
173
|
+
expect(wrapper.querySelector('img')).not.toBeInTheDocument()
|
|
196
174
|
})
|
|
197
175
|
})
|
|
198
176
|
|
|
199
177
|
describe('StoryWidget', () => {
|
|
200
178
|
it('shows snapshot image when valid snapshot prop exists', () => {
|
|
201
|
-
const {
|
|
179
|
+
const { wrapper } = renderInCanvas(
|
|
202
180
|
<StoryWidget
|
|
203
181
|
id="story-abc123"
|
|
204
182
|
props={{
|
|
@@ -206,26 +184,25 @@ describe('Snapshot display', () => {
|
|
|
206
184
|
exportName: 'Primary',
|
|
207
185
|
width: 400,
|
|
208
186
|
height: 300,
|
|
209
|
-
|
|
187
|
+
snapshot: '/_storyboard/canvas/images/snapshot-story-abc123.webp?v=456',
|
|
210
188
|
}}
|
|
211
189
|
onUpdate={vi.fn()}
|
|
212
190
|
resizable={false}
|
|
213
191
|
/>
|
|
214
192
|
)
|
|
215
193
|
|
|
216
|
-
const img =
|
|
194
|
+
const img = wrapper.querySelector('img')
|
|
217
195
|
expect(img).toBeInTheDocument()
|
|
218
|
-
expect(img.src).toContain('snapshot-story-abc123
|
|
219
|
-
expect(
|
|
196
|
+
expect(img.src).toContain('snapshot-story-abc123.webp')
|
|
197
|
+
expect(wrapper.querySelector('iframe')).not.toBeInTheDocument()
|
|
220
198
|
})
|
|
221
199
|
|
|
222
|
-
it('
|
|
223
|
-
const {
|
|
200
|
+
it('falls back to snapshotDark for backward compat', () => {
|
|
201
|
+
const { wrapper } = renderInCanvas(
|
|
224
202
|
<StoryWidget
|
|
225
203
|
id="story-abc123"
|
|
226
204
|
props={{
|
|
227
205
|
storyId: 'button-patterns',
|
|
228
|
-
snapshotLight: '/_storyboard/canvas/images/snapshot-story-abc123--light.webp?v=1',
|
|
229
206
|
snapshotDark: '/_storyboard/canvas/images/snapshot-story-abc123--dark.webp?v=1',
|
|
230
207
|
}}
|
|
231
208
|
onUpdate={vi.fn()}
|
|
@@ -233,17 +210,13 @@ describe('Snapshot display', () => {
|
|
|
233
210
|
/>
|
|
234
211
|
)
|
|
235
212
|
|
|
236
|
-
const
|
|
237
|
-
expect(
|
|
238
|
-
|
|
239
|
-
expect(imgs[0].src).toContain('--light.webp')
|
|
240
|
-
expect(imgs[0].style.visibility).not.toBe('hidden')
|
|
241
|
-
expect(imgs[1].src).toContain('--dark.webp')
|
|
242
|
-
expect(imgs[1].style.visibility).toBe('hidden')
|
|
213
|
+
const img = wrapper.querySelector('img')
|
|
214
|
+
expect(img).toBeInTheDocument()
|
|
215
|
+
expect(img.src).toContain('snapshot-story-abc123--dark.webp')
|
|
243
216
|
})
|
|
244
217
|
|
|
245
218
|
it('shows placeholder when no snapshot exists', () => {
|
|
246
|
-
const {
|
|
219
|
+
const { wrapper } = renderInCanvas(
|
|
247
220
|
<StoryWidget
|
|
248
221
|
id="story-xyz"
|
|
249
222
|
props={{
|
|
@@ -257,12 +230,12 @@ describe('Snapshot display', () => {
|
|
|
257
230
|
/>
|
|
258
231
|
)
|
|
259
232
|
|
|
260
|
-
expect(
|
|
261
|
-
expect(
|
|
233
|
+
expect(wrapper.querySelector('img')).not.toBeInTheDocument()
|
|
234
|
+
expect(wrapper.querySelector('iframe')).not.toBeInTheDocument()
|
|
262
235
|
})
|
|
263
236
|
|
|
264
237
|
it('falls back to placeholder when snapshot image fails to load', () => {
|
|
265
|
-
const {
|
|
238
|
+
const { wrapper } = renderInCanvas(
|
|
266
239
|
<StoryWidget
|
|
267
240
|
id="story-abc123"
|
|
268
241
|
props={{
|
|
@@ -270,20 +243,17 @@ describe('Snapshot display', () => {
|
|
|
270
243
|
exportName: 'Primary',
|
|
271
244
|
width: 400,
|
|
272
245
|
height: 300,
|
|
273
|
-
|
|
246
|
+
snapshot: '/_storyboard/canvas/images/snapshot-story-abc123.webp?v=456',
|
|
274
247
|
}}
|
|
275
248
|
onUpdate={vi.fn()}
|
|
276
249
|
resizable={false}
|
|
277
250
|
/>
|
|
278
251
|
)
|
|
279
252
|
|
|
280
|
-
const img =
|
|
253
|
+
const img = wrapper.querySelector('img')
|
|
281
254
|
expect(img).toBeInTheDocument()
|
|
282
|
-
|
|
283
255
|
fireEvent.error(img)
|
|
284
|
-
|
|
285
|
-
// After error, img should be gone and placeholder shown
|
|
286
|
-
expect(container.querySelector('img')).not.toBeInTheDocument()
|
|
256
|
+
expect(wrapper.querySelector('img')).not.toBeInTheDocument()
|
|
287
257
|
})
|
|
288
258
|
})
|
|
289
259
|
})
|
|
@@ -2,24 +2,16 @@
|
|
|
2
2
|
* useSnapshotCapture — parent-side capture orchestration hook.
|
|
3
3
|
*
|
|
4
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.
|
|
5
|
+
* provides a requestCapture() function that triggers a single capture
|
|
6
|
+
* of whatever the iframe is currently showing.
|
|
15
7
|
*
|
|
8
|
+
* Saves a single `snapshot` prop — overwritten every time.
|
|
16
9
|
* Only active in dev mode (when onUpdate is provided).
|
|
17
10
|
*/
|
|
18
11
|
import { useState, useEffect, useCallback, useRef } from 'react'
|
|
19
12
|
import { uploadImage } from '../canvasApi.js'
|
|
20
13
|
|
|
21
14
|
const CAPTURE_TIMEOUT = 3000
|
|
22
|
-
const THEME_SWITCH_TIMEOUT = 2000
|
|
23
15
|
|
|
24
16
|
/**
|
|
25
17
|
* Run a single capture request against the iframe.
|
|
@@ -57,64 +49,20 @@ function captureOnce(iframeContentWindow, requestId, listeners) {
|
|
|
57
49
|
})
|
|
58
50
|
}
|
|
59
51
|
|
|
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
52
|
export function useSnapshotCapture({
|
|
105
53
|
iframeRef,
|
|
106
54
|
widgetId,
|
|
107
55
|
onUpdate,
|
|
108
|
-
|
|
56
|
+
showIframe,
|
|
109
57
|
}) {
|
|
110
58
|
const [iframeReady, setIframeReady] = useState(false)
|
|
111
59
|
const iframeReadyRef = useRef(false)
|
|
112
60
|
const capturingRef = useRef(false)
|
|
113
61
|
const requestIdCounter = useRef(0)
|
|
114
|
-
// Generation token — incremented on each capture request to detect stale results
|
|
115
62
|
const captureGeneration = useRef(0)
|
|
116
|
-
// Handlers for both snapshot and theme-applied responses
|
|
117
63
|
const responseHandlers = useRef([])
|
|
64
|
+
// Track the iframe contentWindow to reset readiness on remount
|
|
65
|
+
const lastContentWindowRef = useRef(null)
|
|
118
66
|
|
|
119
67
|
// Reset ready state when iframe is unmounted/remounted
|
|
120
68
|
useEffect(() => {
|
|
@@ -122,6 +70,15 @@ export function useSnapshotCapture({
|
|
|
122
70
|
iframeReadyRef.current = false
|
|
123
71
|
}, [widgetId])
|
|
124
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
|
+
|
|
125
82
|
// Listen for postMessage events from the embedded iframe
|
|
126
83
|
useEffect(() => {
|
|
127
84
|
if (!onUpdate) return
|
|
@@ -130,13 +87,19 @@ export function useSnapshotCapture({
|
|
|
130
87
|
if (!iframeRef.current) return
|
|
131
88
|
if (e.source !== iframeRef.current.contentWindow) return
|
|
132
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
|
+
|
|
133
97
|
if (e.data?.type === 'storyboard:embed:snapshot-ready') {
|
|
134
98
|
setIframeReady(true)
|
|
135
99
|
iframeReadyRef.current = true
|
|
136
100
|
}
|
|
137
101
|
|
|
138
|
-
if (e.data?.type === 'storyboard:embed:snapshot'
|
|
139
|
-
e.data?.type === 'storyboard:embed:theme-applied') {
|
|
102
|
+
if (e.data?.type === 'storyboard:embed:snapshot') {
|
|
140
103
|
for (const fn of responseHandlers.current) {
|
|
141
104
|
fn(e.data)
|
|
142
105
|
}
|
|
@@ -148,15 +111,8 @@ export function useSnapshotCapture({
|
|
|
148
111
|
}, [iframeRef, onUpdate])
|
|
149
112
|
|
|
150
113
|
/**
|
|
151
|
-
*
|
|
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
|
|
114
|
+
* Capture a single snapshot of the current iframe state.
|
|
115
|
+
* Uploads and saves as `snapshot` prop, overwriting any previous value.
|
|
160
116
|
*/
|
|
161
117
|
const requestCapture = useCallback(async ({ force = false } = {}) => {
|
|
162
118
|
if (!onUpdate) return {}
|
|
@@ -167,92 +123,35 @@ export function useSnapshotCapture({
|
|
|
167
123
|
capturingRef.current = true
|
|
168
124
|
const gen = ++captureGeneration.current
|
|
169
125
|
const cw = iframeRef.current.contentWindow
|
|
170
|
-
const iframe = iframeRef.current
|
|
171
126
|
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
127
|
|
|
177
128
|
try {
|
|
178
|
-
|
|
179
|
-
const
|
|
180
|
-
const currentLabel = isCurrentDark ? 'dark' : 'light'
|
|
181
|
-
const currentReqId = ++requestIdCounter.current
|
|
182
|
-
const currentDataUrl = await captureOnce(cw, currentReqId, responseHandlers.current)
|
|
129
|
+
const reqId = ++requestIdCounter.current
|
|
130
|
+
const dataUrl = await captureOnce(cw, reqId, responseHandlers.current)
|
|
183
131
|
|
|
184
|
-
// Bail if a newer capture started while we were waiting
|
|
185
132
|
if (gen !== captureGeneration.current) return {}
|
|
133
|
+
if (!dataUrl) return {}
|
|
186
134
|
|
|
187
|
-
|
|
188
|
-
const
|
|
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)
|
|
135
|
+
const filename = `snapshot-${widgetId}.webp`
|
|
136
|
+
const result = await uploadImage(dataUrl, `snapshot-${widgetId}`, filename)
|
|
208
137
|
|
|
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 || ''
|
|
138
|
+
if (gen !== captureGeneration.current) return {}
|
|
242
139
|
|
|
243
|
-
if (
|
|
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 }
|
|
244
144
|
onUpdate?.(updates)
|
|
145
|
+
return updates
|
|
245
146
|
}
|
|
246
|
-
return
|
|
147
|
+
return {}
|
|
247
148
|
} catch (err) {
|
|
248
|
-
// Always restore visibility on error
|
|
249
|
-
if (iframe) iframe.style.visibility = ''
|
|
250
149
|
console.warn('[snapshot] Capture failed:', err)
|
|
251
150
|
return {}
|
|
252
151
|
} finally {
|
|
253
152
|
capturingRef.current = false
|
|
254
153
|
}
|
|
255
|
-
}, [onUpdate, iframeRef, widgetId
|
|
154
|
+
}, [onUpdate, iframeRef, widgetId])
|
|
256
155
|
|
|
257
156
|
return { iframeReady, requestCapture }
|
|
258
157
|
}
|