@dfosco/storyboard-react 4.0.0-beta.35 → 4.0.0-beta.37
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 +4 -3
- package/src/Icon.jsx +179 -0
- package/src/ViewfinderNew.jsx +1172 -0
- package/src/ViewfinderNew.module.css +1773 -0
- package/src/canvas/CanvasPage.jsx +14 -0
- package/src/canvas/widgets/LinkPreview.jsx +74 -10
- package/src/canvas/widgets/MarkdownBlock.module.css +2 -2
- package/src/canvas/widgets/PrototypeEmbed.jsx +11 -8
- package/src/canvas/widgets/StoryWidget.jsx +47 -283
- package/src/canvas/widgets/StoryWidget.module.css +3 -3
- package/src/index.js +1 -1
- package/src/vite/data-plugin.js +24 -0
- package/src/Viewfinder.jsx +0 -72
- package/src/Viewfinder.module.css +0 -235
- package/src/canvas/widgets/refreshQueue.js +0 -111
- package/src/canvas/widgets/useSnapshotCapture.js +0 -161
- package/src/canvas/widgets/useSnapshotCapture.test.jsx +0 -164
|
@@ -1,235 +0,0 @@
|
|
|
1
|
-
.container {
|
|
2
|
-
min-height: 100vh;
|
|
3
|
-
background-color: var(--bgColor-default, #0d1117);
|
|
4
|
-
color: var(--fgColor-default, #e6edf3);
|
|
5
|
-
padding: 80px 32px 48px;
|
|
6
|
-
}
|
|
7
|
-
|
|
8
|
-
.header {
|
|
9
|
-
max-width: 720px;
|
|
10
|
-
margin: 0 auto 64px;
|
|
11
|
-
}
|
|
12
|
-
|
|
13
|
-
.title {
|
|
14
|
-
font-size: 72px;
|
|
15
|
-
font-weight: 400;
|
|
16
|
-
margin: 0 0 12px;
|
|
17
|
-
color: var(--fgColor-default, #e6edf3);
|
|
18
|
-
letter-spacing: -0.03em;
|
|
19
|
-
line-height: 1;
|
|
20
|
-
}
|
|
21
|
-
|
|
22
|
-
.subtitle {
|
|
23
|
-
font-size: 15px;
|
|
24
|
-
color: var(--fgColor-muted, #848d97);
|
|
25
|
-
margin: 4px 0 0;
|
|
26
|
-
letter-spacing: 0.01em;
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
.sceneCount {
|
|
30
|
-
font-size: 13px;
|
|
31
|
-
color: var(--fgColor-muted, #848d97);
|
|
32
|
-
margin: 16px 0 0;
|
|
33
|
-
letter-spacing: 0.01em;
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
.grid {
|
|
37
|
-
display: grid;
|
|
38
|
-
grid-template-columns: repeat(auto-fill, minmax(240px, 1fr));
|
|
39
|
-
gap: 16px;
|
|
40
|
-
max-width: 720px;
|
|
41
|
-
margin: 0 auto;
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
.list {
|
|
45
|
-
display: flex;
|
|
46
|
-
flex-direction: column;
|
|
47
|
-
max-width: 720px;
|
|
48
|
-
margin: 0 auto;
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
.listItem {
|
|
52
|
-
display: block;
|
|
53
|
-
padding: 8px 0;
|
|
54
|
-
text-decoration: none;
|
|
55
|
-
color: inherit;
|
|
56
|
-
/* border-bottom: 1px solid var(--borderColor-muted, #21262d); */
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
.listItem:first-child {
|
|
60
|
-
/* border-top: 1px solid var(--borderColor-muted, #21262d); */
|
|
61
|
-
}
|
|
62
|
-
|
|
63
|
-
.listItem:hover {
|
|
64
|
-
text-decoration: none !important;
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
.listItem .author {
|
|
68
|
-
margin-top: 8px;
|
|
69
|
-
}
|
|
70
|
-
|
|
71
|
-
.card {
|
|
72
|
-
display: block;
|
|
73
|
-
border: 1px solid var(--borderColor-default, #30363d);
|
|
74
|
-
border-radius: 8px;
|
|
75
|
-
overflow: hidden;
|
|
76
|
-
background: var(--bgColor-muted, #161b22);
|
|
77
|
-
text-decoration: none;
|
|
78
|
-
color: inherit;
|
|
79
|
-
transition: border-color 0.15s ease, box-shadow 0.15s ease;
|
|
80
|
-
}
|
|
81
|
-
|
|
82
|
-
.card:hover {
|
|
83
|
-
border-color: var(--borderColor-accent-emphasis, #1f6feb);
|
|
84
|
-
box-shadow: 0 0 0 1px var(--borderColor-accent-emphasis, #1f6feb);
|
|
85
|
-
text-decoration: none !important;
|
|
86
|
-
}
|
|
87
|
-
|
|
88
|
-
.thumbnail {
|
|
89
|
-
aspect-ratio: 16 / 10;
|
|
90
|
-
display: flex;
|
|
91
|
-
align-items: center;
|
|
92
|
-
justify-content: center;
|
|
93
|
-
overflow: hidden;
|
|
94
|
-
background: var(--bgColor-inset, #010409);
|
|
95
|
-
|
|
96
|
-
--placeholder-bg: var(--bgColor-inset, #010409);
|
|
97
|
-
--placeholder-grid: var(--borderColor-default, #30363d);
|
|
98
|
-
--placeholder-accent: var(--fgColor-accent, #58a6ff);
|
|
99
|
-
--placeholder-fg: var(--fgColor-default, #c9d1d9);
|
|
100
|
-
--placeholder-muted: var(--fgColor-muted, #484f58);
|
|
101
|
-
}
|
|
102
|
-
|
|
103
|
-
.thumbnail svg {
|
|
104
|
-
width: 100%;
|
|
105
|
-
height: 100%;
|
|
106
|
-
}
|
|
107
|
-
|
|
108
|
-
.cardBody {
|
|
109
|
-
padding: 12px 16px;
|
|
110
|
-
|
|
111
|
-
&:hover {
|
|
112
|
-
background-color: var(--bgColor-muted);
|
|
113
|
-
}
|
|
114
|
-
}
|
|
115
|
-
|
|
116
|
-
.sceneName {
|
|
117
|
-
font-size: 28px;
|
|
118
|
-
font-weight: 400;
|
|
119
|
-
color: var(--fgColor-default, #e6edf3);
|
|
120
|
-
margin: 0;
|
|
121
|
-
letter-spacing: -0.02em;
|
|
122
|
-
line-height: 1.2;
|
|
123
|
-
transition: font-style 0.15s ease;
|
|
124
|
-
}
|
|
125
|
-
|
|
126
|
-
.empty {
|
|
127
|
-
text-align: center;
|
|
128
|
-
padding: 80px 24px;
|
|
129
|
-
color: var(--fgColor-muted, #848d97);
|
|
130
|
-
font-size: 15px;
|
|
131
|
-
max-width: 720px;
|
|
132
|
-
margin: 0 auto;
|
|
133
|
-
}
|
|
134
|
-
|
|
135
|
-
.sectionTitle {
|
|
136
|
-
font-size: 11px;
|
|
137
|
-
font-weight: 700;
|
|
138
|
-
text-transform: uppercase;
|
|
139
|
-
letter-spacing: 0.12em;
|
|
140
|
-
color: var(--fgColor-muted, #848d97);
|
|
141
|
-
margin: 0 auto 20px;
|
|
142
|
-
max-width: 720px;
|
|
143
|
-
}
|
|
144
|
-
|
|
145
|
-
.headerTop {
|
|
146
|
-
display: flex;
|
|
147
|
-
align-items: baseline;
|
|
148
|
-
justify-content: space-between;
|
|
149
|
-
gap: 16px;
|
|
150
|
-
}
|
|
151
|
-
|
|
152
|
-
.branchDropdown {
|
|
153
|
-
display: flex;
|
|
154
|
-
align-items: center;
|
|
155
|
-
gap: 0;
|
|
156
|
-
flex-shrink: 0;
|
|
157
|
-
position: relative;
|
|
158
|
-
}
|
|
159
|
-
|
|
160
|
-
.branchIcon {
|
|
161
|
-
position: absolute;
|
|
162
|
-
left: 10px;
|
|
163
|
-
color: var(--fgColor-muted, #848d97);
|
|
164
|
-
pointer-events: none;
|
|
165
|
-
z-index: 1;
|
|
166
|
-
}
|
|
167
|
-
|
|
168
|
-
.branchSelect {
|
|
169
|
-
appearance: none;
|
|
170
|
-
background-color: transparent;
|
|
171
|
-
color: var(--fgColor-default, #e6edf3);
|
|
172
|
-
border: 1px solid var(--borderColor-default, #30363d);
|
|
173
|
-
border-radius: 20px;
|
|
174
|
-
padding: 6px 32px 6px 32px;
|
|
175
|
-
font-size: 13px;
|
|
176
|
-
font-family: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace;
|
|
177
|
-
cursor: pointer;
|
|
178
|
-
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' fill='%23848d97'%3E%3Cpath d='M6 8.5L1.5 4h9L6 8.5z'/%3E%3C/svg%3E");
|
|
179
|
-
background-repeat: no-repeat;
|
|
180
|
-
background-position: right 12px center;
|
|
181
|
-
min-width: 140px;
|
|
182
|
-
max-width: 220px;
|
|
183
|
-
text-overflow: ellipsis;
|
|
184
|
-
overflow: hidden;
|
|
185
|
-
transition: border-color 0.15s ease;
|
|
186
|
-
}
|
|
187
|
-
|
|
188
|
-
.branchSelect:hover {
|
|
189
|
-
border-color: var(--fgColor-muted, #848d97);
|
|
190
|
-
}
|
|
191
|
-
|
|
192
|
-
.branchSelect:focus-visible {
|
|
193
|
-
outline: 2px solid var(--borderColor-accent-emphasis, #1f6feb);
|
|
194
|
-
outline-offset: -1px;
|
|
195
|
-
}
|
|
196
|
-
|
|
197
|
-
.author {
|
|
198
|
-
display: flex;
|
|
199
|
-
align-items: center;
|
|
200
|
-
gap: 8px;
|
|
201
|
-
margin-top: 6px;
|
|
202
|
-
}
|
|
203
|
-
|
|
204
|
-
.authorAvatars {
|
|
205
|
-
display: flex;
|
|
206
|
-
flex-direction: row;
|
|
207
|
-
}
|
|
208
|
-
|
|
209
|
-
.authorAvatars:hover .authorAvatar {
|
|
210
|
-
margin-left: 2px;
|
|
211
|
-
}
|
|
212
|
-
|
|
213
|
-
.authorAvatars:hover .authorAvatar:first-child {
|
|
214
|
-
margin-left: 0;
|
|
215
|
-
}
|
|
216
|
-
|
|
217
|
-
.authorAvatar {
|
|
218
|
-
width: 18px;
|
|
219
|
-
height: 18px;
|
|
220
|
-
border-radius: 50%;
|
|
221
|
-
margin-left: -6px;
|
|
222
|
-
transition: margin-left 0.15s ease;
|
|
223
|
-
outline: 2px solid var(--bgColor-default, #0d1117);
|
|
224
|
-
position: relative;
|
|
225
|
-
}
|
|
226
|
-
|
|
227
|
-
.authorAvatar:first-child {
|
|
228
|
-
margin-left: 0;
|
|
229
|
-
}
|
|
230
|
-
|
|
231
|
-
.authorName {
|
|
232
|
-
font-size: 13px;
|
|
233
|
-
color: var(--fgColor-muted, #848d97);
|
|
234
|
-
letter-spacing: 0.01em;
|
|
235
|
-
}
|
|
@@ -1,111 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Concurrent refresh queue for bulk snapshot recapture (e.g. on theme change).
|
|
3
|
-
*
|
|
4
|
-
* Captures run in parallel (up to MAX_CONCURRENT) for speed, but REVEALS are
|
|
5
|
-
* staggered on a fixed timeline — widget 0 reveals at 0ms, widget 1 at
|
|
6
|
-
* REVEAL_INTERVAL ms, widget 2 at 2×REVEAL_INTERVAL ms, etc., all relative to
|
|
7
|
-
* batch start. This creates a clean, predictable wave sweep regardless of how
|
|
8
|
-
* fast each capture completes.
|
|
9
|
-
*
|
|
10
|
-
* After a batch completes, any widgets that failed are re-enqueued for a
|
|
11
|
-
* single retry pass.
|
|
12
|
-
*
|
|
13
|
-
* Sorted spatially (top-to-bottom, left-to-right) before assigning reveal slots.
|
|
14
|
-
* Supports cancellation by widget ID.
|
|
15
|
-
*/
|
|
16
|
-
const queue = []
|
|
17
|
-
let running = 0
|
|
18
|
-
let drainScheduled = false
|
|
19
|
-
let batchTotal = 0
|
|
20
|
-
let batchDone = 0
|
|
21
|
-
const batchFailed = []
|
|
22
|
-
|
|
23
|
-
const MAX_CONCURRENT = 4
|
|
24
|
-
export const REVEAL_INTERVAL = 200
|
|
25
|
-
|
|
26
|
-
/**
|
|
27
|
-
* Enqueue a snapshot refresh task for a widget.
|
|
28
|
-
* @param {string} widgetId — unique widget identifier (for cancellation)
|
|
29
|
-
* @param {(meta: { revealOrder: number, batchStart: number }) => Promise<boolean>} fn
|
|
30
|
-
* Must resolve to `true` on success, `false` on failure.
|
|
31
|
-
* @param {{ x: number, y: number }} [pos] — spatial position for wave ordering
|
|
32
|
-
*/
|
|
33
|
-
export function enqueueRefresh(widgetId, fn, pos) {
|
|
34
|
-
console.log(`[refreshQueue] enqueue: ${widgetId}, queueLen=${queue.length}`)
|
|
35
|
-
const existing = queue.findIndex(item => item.widgetId === widgetId)
|
|
36
|
-
if (existing !== -1) queue.splice(existing, 1)
|
|
37
|
-
|
|
38
|
-
queue.push({ widgetId, fn, x: pos?.x ?? 0, y: pos?.y ?? 0 })
|
|
39
|
-
scheduleDrain()
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
/**
|
|
43
|
-
* Cancel a pending refresh for a widget (e.g. user activated it manually).
|
|
44
|
-
*/
|
|
45
|
-
export function cancelRefresh(widgetId) {
|
|
46
|
-
const idx = queue.findIndex(item => item.widgetId === widgetId)
|
|
47
|
-
if (idx !== -1) queue.splice(idx, 1)
|
|
48
|
-
}
|
|
49
|
-
|
|
50
|
-
function scheduleDrain() {
|
|
51
|
-
if (drainScheduled) return
|
|
52
|
-
drainScheduled = true
|
|
53
|
-
// Batch all enqueueRefresh calls from the same React commit, then sort
|
|
54
|
-
// spatially and assign reveal slots before starting captures.
|
|
55
|
-
setTimeout(() => {
|
|
56
|
-
drainScheduled = false
|
|
57
|
-
queue.sort((a, b) => a.y - b.y || a.x - b.x)
|
|
58
|
-
const batchStart = Date.now()
|
|
59
|
-
batchTotal = queue.length
|
|
60
|
-
batchDone = 0
|
|
61
|
-
batchFailed.length = 0
|
|
62
|
-
queue.forEach((item, i) => {
|
|
63
|
-
item.revealOrder = i
|
|
64
|
-
item.batchStart = batchStart
|
|
65
|
-
item.isRetry = item.isRetry || false
|
|
66
|
-
})
|
|
67
|
-
drain()
|
|
68
|
-
}, 0)
|
|
69
|
-
}
|
|
70
|
-
|
|
71
|
-
function onTaskDone(success, item) {
|
|
72
|
-
batchDone++
|
|
73
|
-
console.log(`[refreshQueue] taskDone: ${item.widgetId}, success=${success}, done=${batchDone}/${batchTotal}, retry=${item.isRetry}`)
|
|
74
|
-
if (!success && !item.isRetry) {
|
|
75
|
-
batchFailed.push(item)
|
|
76
|
-
}
|
|
77
|
-
// When batch is complete, re-enqueue failures for one retry
|
|
78
|
-
if (batchDone >= batchTotal && batchFailed.length > 0) {
|
|
79
|
-
console.log(`[refreshQueue] batch complete, retrying ${batchFailed.length} failed`)
|
|
80
|
-
const retries = batchFailed.splice(0)
|
|
81
|
-
for (const failed of retries) {
|
|
82
|
-
failed.isRetry = true
|
|
83
|
-
queue.push(failed)
|
|
84
|
-
}
|
|
85
|
-
batchTotal = queue.length
|
|
86
|
-
batchDone = 0
|
|
87
|
-
const batchStart = Date.now()
|
|
88
|
-
queue.forEach((item, i) => {
|
|
89
|
-
item.revealOrder = i
|
|
90
|
-
item.batchStart = batchStart
|
|
91
|
-
})
|
|
92
|
-
}
|
|
93
|
-
drain()
|
|
94
|
-
}
|
|
95
|
-
|
|
96
|
-
function drain() {
|
|
97
|
-
if (running >= MAX_CONCURRENT || queue.length === 0) return
|
|
98
|
-
|
|
99
|
-
running++
|
|
100
|
-
const item = queue.shift()
|
|
101
|
-
const { fn, revealOrder, batchStart } = item
|
|
102
|
-
Promise.resolve()
|
|
103
|
-
.then(() => fn({ revealOrder, batchStart }))
|
|
104
|
-
.then((success) => { running--; onTaskDone(success !== false, item) })
|
|
105
|
-
.catch(() => { running--; onTaskDone(false, item) })
|
|
106
|
-
|
|
107
|
-
// Start next capture immediately (no stagger on capture start — only reveals are staggered)
|
|
108
|
-
if (queue.length > 0 && running < MAX_CONCURRENT) {
|
|
109
|
-
drain()
|
|
110
|
-
}
|
|
111
|
-
}
|
|
@@ -1,161 +0,0 @@
|
|
|
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
|
-
console.log(`[snapshot:${widgetId}] iframe ready`)
|
|
99
|
-
setIframeReady(true)
|
|
100
|
-
iframeReadyRef.current = true
|
|
101
|
-
}
|
|
102
|
-
|
|
103
|
-
if (e.data?.type === 'storyboard:embed:snapshot') {
|
|
104
|
-
for (const fn of responseHandlers.current) {
|
|
105
|
-
fn(e.data)
|
|
106
|
-
}
|
|
107
|
-
}
|
|
108
|
-
}
|
|
109
|
-
|
|
110
|
-
window.addEventListener('message', handler)
|
|
111
|
-
return () => window.removeEventListener('message', handler)
|
|
112
|
-
}, [iframeRef, onUpdate])
|
|
113
|
-
|
|
114
|
-
/**
|
|
115
|
-
* Capture a single snapshot of the current iframe state.
|
|
116
|
-
* Uploads and saves as `snapshot` prop, overwriting any previous value.
|
|
117
|
-
*/
|
|
118
|
-
const requestCapture = useCallback(async ({ force = false } = {}) => {
|
|
119
|
-
console.log(`[snapshot:${widgetId}] requestCapture: force=${force}, hasContentWindow=${!!iframeRef.current?.contentWindow}, capturing=${capturingRef.current}, ready=${iframeReadyRef.current}`)
|
|
120
|
-
if (!onUpdate) return {}
|
|
121
|
-
if (!iframeRef.current?.contentWindow) { console.log(`[snapshot:${widgetId}] requestCapture: no contentWindow`); return {} }
|
|
122
|
-
if (capturingRef.current) { console.log(`[snapshot:${widgetId}] requestCapture: already capturing`); return {} }
|
|
123
|
-
if (!force && !iframeReadyRef.current) { console.log(`[snapshot:${widgetId}] requestCapture: not ready`); return {} }
|
|
124
|
-
|
|
125
|
-
capturingRef.current = true
|
|
126
|
-
const gen = ++captureGeneration.current
|
|
127
|
-
const cw = iframeRef.current.contentWindow
|
|
128
|
-
const base = (import.meta.env?.BASE_URL || '/').replace(/\/$/, '')
|
|
129
|
-
|
|
130
|
-
try {
|
|
131
|
-
const reqId = ++requestIdCounter.current
|
|
132
|
-
const dataUrl = await captureOnce(cw, reqId, responseHandlers.current)
|
|
133
|
-
|
|
134
|
-
if (gen !== captureGeneration.current) { console.log(`[snapshot:${widgetId}] stale gen after capture`); return {} }
|
|
135
|
-
if (!dataUrl) { console.log(`[snapshot:${widgetId}] captureOnce returned null`); return {} }
|
|
136
|
-
|
|
137
|
-
const filename = `snapshot-${widgetId}.webp`
|
|
138
|
-
console.log(`[snapshot:${widgetId}] uploading ${filename}`)
|
|
139
|
-
const result = await uploadImage(dataUrl, `snapshot-${widgetId}`, filename)
|
|
140
|
-
|
|
141
|
-
if (gen !== captureGeneration.current) { console.log(`[snapshot:${widgetId}] stale gen after upload`); return {} }
|
|
142
|
-
|
|
143
|
-
if (result?.filename) {
|
|
144
|
-
const cacheBust = `?v=${Date.now()}`
|
|
145
|
-
const url = `${base}/_storyboard/canvas/images/${result.filename}${cacheBust}`
|
|
146
|
-
const updates = { snapshot: url }
|
|
147
|
-
console.log(`[snapshot:${widgetId}] saved: ${url.slice(0, 60)}`)
|
|
148
|
-
onUpdate?.(updates)
|
|
149
|
-
return updates
|
|
150
|
-
}
|
|
151
|
-
return {}
|
|
152
|
-
} catch (err) {
|
|
153
|
-
console.warn('[snapshot] Capture failed:', err)
|
|
154
|
-
return {}
|
|
155
|
-
} finally {
|
|
156
|
-
capturingRef.current = false
|
|
157
|
-
}
|
|
158
|
-
}, [onUpdate, iframeRef, widgetId])
|
|
159
|
-
|
|
160
|
-
return { iframeReady, requestCapture }
|
|
161
|
-
}
|
|
@@ -1,164 +0,0 @@
|
|
|
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
|
-
})
|