@dfosco/storyboard-react 4.0.0-beta.25 → 4.0.0-beta.27
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.jsx +95 -28
- package/src/canvas/CanvasPage.module.css +7 -0
- package/src/canvas/widgets/PrototypeEmbed.jsx +5 -12
- package/src/canvas/widgets/StoryWidget.jsx +5 -12
- package/src/canvas/widgets/useSnapshotCapture.js +67 -30
- package/src/canvas/widgets/useSnapshotCapture.test.jsx +76 -0
package/package.json
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@dfosco/storyboard-react",
|
|
3
|
-
"version": "4.0.0-beta.
|
|
3
|
+
"version": "4.0.0-beta.27",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"dependencies": {
|
|
6
|
-
"@dfosco/storyboard-core": "4.0.0-beta.
|
|
7
|
-
"@dfosco/tiny-canvas": "4.0.0-beta.
|
|
6
|
+
"@dfosco/storyboard-core": "4.0.0-beta.27",
|
|
7
|
+
"@dfosco/tiny-canvas": "4.0.0-beta.27",
|
|
8
8
|
"@neodrag/react": "^2.3.1",
|
|
9
9
|
"glob": "^11.0.0",
|
|
10
10
|
"jsonc-parser": "^3.3.1",
|
|
@@ -1,5 +1,4 @@
|
|
|
1
|
-
import { createElement, useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
|
2
|
-
import { flushSync } from 'react-dom'
|
|
1
|
+
import { createElement, memo, useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
|
3
2
|
import { Canvas } from '@dfosco/tiny-canvas'
|
|
4
3
|
import '@dfosco/tiny-canvas/style.css'
|
|
5
4
|
import { useCanvas } from './useCanvas.js'
|
|
@@ -267,8 +266,10 @@ function WidgetRenderer({ widget, onUpdate, widgetRef }) {
|
|
|
267
266
|
/**
|
|
268
267
|
* Wrapper for each JSON widget that holds its own ref for imperative actions.
|
|
269
268
|
* This allows WidgetChrome to dispatch actions to the widget via ref.
|
|
269
|
+
*
|
|
270
|
+
* Memoized to prevent re-renders during zoom and unrelated state changes.
|
|
270
271
|
*/
|
|
271
|
-
function ChromeWrappedWidget({
|
|
272
|
+
const ChromeWrappedWidget = memo(function ChromeWrappedWidget({
|
|
272
273
|
widget,
|
|
273
274
|
selected,
|
|
274
275
|
multiSelected,
|
|
@@ -293,6 +294,10 @@ function ChromeWrappedWidget({
|
|
|
293
294
|
}
|
|
294
295
|
}, [widget, onRemove, onCopy])
|
|
295
296
|
|
|
297
|
+
const handleWidgetFieldUpdate = useCallback((updates) => {
|
|
298
|
+
onUpdate?.(widget.id, updates)
|
|
299
|
+
}, [onUpdate, widget.id])
|
|
300
|
+
|
|
296
301
|
return (
|
|
297
302
|
<WidgetChrome
|
|
298
303
|
widgetId={widget.id}
|
|
@@ -305,17 +310,29 @@ function ChromeWrappedWidget({
|
|
|
305
310
|
onSelect={onSelect}
|
|
306
311
|
onDeselect={onDeselect}
|
|
307
312
|
onAction={handleAction}
|
|
308
|
-
onUpdate={onUpdate ?
|
|
313
|
+
onUpdate={onUpdate ? handleWidgetFieldUpdate : undefined}
|
|
309
314
|
readOnly={readOnly}
|
|
310
315
|
>
|
|
311
316
|
<WidgetRenderer
|
|
312
317
|
widget={widget}
|
|
313
|
-
onUpdate={onUpdate ?
|
|
318
|
+
onUpdate={onUpdate ? handleWidgetFieldUpdate : undefined}
|
|
314
319
|
widgetRef={widgetRef}
|
|
315
320
|
/>
|
|
316
321
|
</WidgetChrome>
|
|
317
322
|
)
|
|
318
|
-
}
|
|
323
|
+
}, function chromeWidgetAreEqual(prev, next) {
|
|
324
|
+
return (
|
|
325
|
+
prev.widget === next.widget &&
|
|
326
|
+
prev.selected === next.selected &&
|
|
327
|
+
prev.multiSelected === next.multiSelected &&
|
|
328
|
+
prev.readOnly === next.readOnly &&
|
|
329
|
+
prev.onSelect === next.onSelect &&
|
|
330
|
+
prev.onDeselect === next.onDeselect &&
|
|
331
|
+
prev.onUpdate === next.onUpdate &&
|
|
332
|
+
prev.onRemove === next.onRemove &&
|
|
333
|
+
prev.onCopy === next.onCopy
|
|
334
|
+
)
|
|
335
|
+
})
|
|
319
336
|
|
|
320
337
|
/**
|
|
321
338
|
* Generic canvas page component.
|
|
@@ -335,6 +352,9 @@ export default function CanvasPage({ canvasId, siblingPages = [], canvasMeta = n
|
|
|
335
352
|
const [zoom, setZoom] = useState(initialViewport?.zoom ?? 100)
|
|
336
353
|
const zoomRef = useRef(initialViewport?.zoom ?? 100)
|
|
337
354
|
const scrollRef = useRef(null)
|
|
355
|
+
const zoomElRef = useRef(null)
|
|
356
|
+
const zoomCommitTimer = useRef(null)
|
|
357
|
+
const zoomEventTimer = useRef(null)
|
|
338
358
|
const pendingScrollRestore = useRef(initialViewport)
|
|
339
359
|
// Gate viewport persistence until initial positioning is complete.
|
|
340
360
|
// Tracks which canvasId was last initialized — save effects only
|
|
@@ -704,10 +724,17 @@ export default function CanvasPage({ canvasId, siblingPages = [], canvasMeta = n
|
|
|
704
724
|
})
|
|
705
725
|
}, [canvasId, undoRedo, debouncedSave, transitionPeers, clearDragPreview])
|
|
706
726
|
|
|
727
|
+
// Keep zoomRef in sync when React state is set (e.g. by toolbar or zoom-to-fit)
|
|
707
728
|
useEffect(() => {
|
|
708
729
|
zoomRef.current = zoom
|
|
709
730
|
}, [zoom])
|
|
710
731
|
|
|
732
|
+
// Cleanup zoom timers on unmount
|
|
733
|
+
useEffect(() => () => {
|
|
734
|
+
clearTimeout(zoomCommitTimer.current)
|
|
735
|
+
clearTimeout(zoomEventTimer.current)
|
|
736
|
+
}, [])
|
|
737
|
+
|
|
711
738
|
// Restore scroll position from localStorage after first render.
|
|
712
739
|
// When saved state is fresh (< 15 min), restore it. Otherwise zoom-to-fit
|
|
713
740
|
// all objects so the user sees a useful overview instead of stale coordinates.
|
|
@@ -730,7 +757,14 @@ export default function CanvasPage({ canvasId, siblingPages = [], canvasMeta = n
|
|
|
730
757
|
const fitZoom = Math.min(ZOOM_MAX, Math.max(ZOOM_MIN, Math.round(fitScale * 100)))
|
|
731
758
|
const newScale = fitZoom / 100
|
|
732
759
|
zoomRef.current = fitZoom
|
|
733
|
-
|
|
760
|
+
// Imperative DOM update for initial zoom-to-fit — same path as applyZoom
|
|
761
|
+
const zoomEl = zoomElRef.current
|
|
762
|
+
if (zoomEl) {
|
|
763
|
+
zoomEl.style.transform = `scale(${newScale})`
|
|
764
|
+
zoomEl.style.width = `${Math.max(10000, 100 / newScale)}vw`
|
|
765
|
+
zoomEl.style.height = `${Math.max(10000, 100 / newScale)}vh`
|
|
766
|
+
}
|
|
767
|
+
setZoom(fitZoom)
|
|
734
768
|
el.scrollLeft = (bounds.minX - FIT_PADDING) * newScale
|
|
735
769
|
el.scrollTop = (bounds.minY - FIT_PADDING) * newScale
|
|
736
770
|
} else {
|
|
@@ -846,12 +880,19 @@ export default function CanvasPage({ canvasId, siblingPages = [], canvasMeta = n
|
|
|
846
880
|
* When a cursor position is provided (e.g. from a wheel event), the
|
|
847
881
|
* canvas point under the cursor stays fixed. Otherwise falls back to
|
|
848
882
|
* the viewport center.
|
|
883
|
+
*
|
|
884
|
+
* Performs an imperative DOM mutation instead of a React state update
|
|
885
|
+
* to avoid triggering a full re-render of the widget tree on every
|
|
886
|
+
* zoom tick. React state is committed after a debounce for toolbar
|
|
887
|
+
* display updates.
|
|
849
888
|
*/
|
|
850
889
|
function applyZoom(newZoom, clientX, clientY) {
|
|
851
890
|
const el = scrollRef.current
|
|
891
|
+
const zoomEl = zoomElRef.current
|
|
852
892
|
const clampedZoom = Math.min(ZOOM_MAX, Math.max(ZOOM_MIN, newZoom))
|
|
853
893
|
|
|
854
|
-
if (!el) {
|
|
894
|
+
if (!el || !zoomEl) {
|
|
895
|
+
zoomRef.current = clampedZoom
|
|
855
896
|
setZoom(clampedZoom)
|
|
856
897
|
return
|
|
857
898
|
}
|
|
@@ -869,23 +910,36 @@ export default function CanvasPage({ canvasId, siblingPages = [], canvasMeta = n
|
|
|
869
910
|
const canvasX = (el.scrollLeft + anchorX) / oldScale
|
|
870
911
|
const canvasY = (el.scrollTop + anchorY) / oldScale
|
|
871
912
|
|
|
872
|
-
//
|
|
913
|
+
// Imperative DOM update — no React re-render
|
|
873
914
|
zoomRef.current = clampedZoom
|
|
874
|
-
|
|
915
|
+
zoomEl.style.transform = `scale(${newScale})`
|
|
916
|
+
zoomEl.style.width = `${Math.max(10000, 100 / newScale)}vw`
|
|
917
|
+
zoomEl.style.height = `${Math.max(10000, 100 / newScale)}vh`
|
|
918
|
+
|
|
919
|
+
// Hint GPU compositing during active zoom
|
|
920
|
+
zoomEl.dataset.zooming = ''
|
|
875
921
|
|
|
876
922
|
// Scroll so the same canvas point stays under the anchor
|
|
877
923
|
el.scrollLeft = canvasX * newScale - anchorX
|
|
878
924
|
el.scrollTop = canvasY * newScale - anchorY
|
|
879
925
|
|
|
880
|
-
//
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
926
|
+
// Debounced commit: update React state for toolbar display + persistence
|
|
927
|
+
clearTimeout(zoomCommitTimer.current)
|
|
928
|
+
zoomCommitTimer.current = setTimeout(() => {
|
|
929
|
+
// Remove GPU compositing hint
|
|
930
|
+
delete zoomEl.dataset.zooming
|
|
931
|
+
setZoom(clampedZoom)
|
|
932
|
+
}, 150)
|
|
933
|
+
|
|
934
|
+
// Throttled zoom-changed event for external consumers (toolbar)
|
|
935
|
+
if (!zoomEventTimer.current) {
|
|
936
|
+
zoomEventTimer.current = setTimeout(() => {
|
|
937
|
+
zoomEventTimer.current = null
|
|
938
|
+
window[CANVAS_BRIDGE_STATE_KEY] = { active: true, canvasId, zoom: zoomRef.current }
|
|
939
|
+
document.dispatchEvent(new CustomEvent('storyboard:canvas:zoom-changed', {
|
|
940
|
+
detail: { zoom: zoomRef.current }
|
|
941
|
+
}))
|
|
942
|
+
}, 100)
|
|
889
943
|
}
|
|
890
944
|
}
|
|
891
945
|
|
|
@@ -1067,9 +1121,15 @@ export default function CanvasPage({ canvasId, siblingPages = [], canvasMeta = n
|
|
|
1067
1121
|
const fitZoom = Math.min(ZOOM_MAX, Math.max(ZOOM_MIN, Math.round(fitScale * 100)))
|
|
1068
1122
|
const newScale = fitZoom / 100
|
|
1069
1123
|
|
|
1070
|
-
//
|
|
1124
|
+
// Imperative DOM update — same path as applyZoom
|
|
1071
1125
|
zoomRef.current = fitZoom
|
|
1072
|
-
|
|
1126
|
+
const zoomEl = zoomElRef.current
|
|
1127
|
+
if (zoomEl) {
|
|
1128
|
+
zoomEl.style.transform = `scale(${newScale})`
|
|
1129
|
+
zoomEl.style.width = `${Math.max(10000, 100 / newScale)}vw`
|
|
1130
|
+
zoomEl.style.height = `${Math.max(10000, 100 / newScale)}vh`
|
|
1131
|
+
}
|
|
1132
|
+
setZoom(fitZoom)
|
|
1073
1133
|
|
|
1074
1134
|
// Scroll so the bounding box top-left (with padding) is at viewport top-left
|
|
1075
1135
|
el.scrollLeft = (bounds.minX - FIT_PADDING) * newScale
|
|
@@ -1592,6 +1652,15 @@ export default function CanvasPage({ canvasId, siblingPages = [], canvasMeta = n
|
|
|
1592
1652
|
document.addEventListener('mouseup', handlePanEnd)
|
|
1593
1653
|
}, [spaceHeld])
|
|
1594
1654
|
|
|
1655
|
+
// Stable callback for deselecting all widgets
|
|
1656
|
+
const handleDeselectAll = useCallback(() => setSelectedWidgetIds(new Set()), [])
|
|
1657
|
+
|
|
1658
|
+
// Stable callback for widget removal + deselect
|
|
1659
|
+
const handleWidgetRemoveAndDeselect = useCallback((id) => {
|
|
1660
|
+
handleWidgetRemove(id)
|
|
1661
|
+
setSelectedWidgetIds(new Set())
|
|
1662
|
+
}, [handleWidgetRemove])
|
|
1663
|
+
|
|
1595
1664
|
if (!canvas) {
|
|
1596
1665
|
return (
|
|
1597
1666
|
<div className={styles.empty}>
|
|
@@ -1653,7 +1722,7 @@ export default function CanvasPage({ canvasId, siblingPages = [], canvasMeta = n
|
|
|
1653
1722
|
selected={selectedWidgetIds.has(`jsx-${exportName}`)}
|
|
1654
1723
|
multiSelected={isMultiSelected && selectedWidgetIds.has(`jsx-${exportName}`)}
|
|
1655
1724
|
onSelect={(shiftKey) => handleWidgetSelect(`jsx-${exportName}`, shiftKey)}
|
|
1656
|
-
onDeselect={
|
|
1725
|
+
onDeselect={handleDeselectAll}
|
|
1657
1726
|
readOnly={!isLocalDev}
|
|
1658
1727
|
>
|
|
1659
1728
|
<ComponentWidget
|
|
@@ -1695,13 +1764,10 @@ export default function CanvasPage({ canvasId, siblingPages = [], canvasMeta = n
|
|
|
1695
1764
|
selected={selectedWidgetIds.has(widget.id)}
|
|
1696
1765
|
multiSelected={isMultiSelected && selectedWidgetIds.has(widget.id)}
|
|
1697
1766
|
onSelect={(shiftKey) => handleWidgetSelect(widget.id, shiftKey)}
|
|
1698
|
-
onDeselect={
|
|
1767
|
+
onDeselect={handleDeselectAll}
|
|
1699
1768
|
onUpdate={isLocalDev ? handleWidgetUpdate : undefined}
|
|
1700
1769
|
onCopy={isLocalDev ? handleWidgetCopy : undefined}
|
|
1701
|
-
onRemove={isLocalDev ?
|
|
1702
|
-
handleWidgetRemove(id)
|
|
1703
|
-
setSelectedWidgetIds(new Set())
|
|
1704
|
-
} : undefined}
|
|
1770
|
+
onRemove={isLocalDev ? handleWidgetRemoveAndDeselect : undefined}
|
|
1705
1771
|
readOnly={!isLocalDev}
|
|
1706
1772
|
/>
|
|
1707
1773
|
</div>
|
|
@@ -1729,10 +1795,11 @@ export default function CanvasPage({ canvasId, siblingPages = [], canvasMeta = n
|
|
|
1729
1795
|
...canvasThemeVars,
|
|
1730
1796
|
...(spaceHeld ? { cursor: panningActive ? 'grabbing' : 'grab' } : {}),
|
|
1731
1797
|
}}
|
|
1732
|
-
onClick={
|
|
1798
|
+
onClick={handleDeselectAll}
|
|
1733
1799
|
onMouseDown={handlePanStart}
|
|
1734
1800
|
>
|
|
1735
1801
|
<div
|
|
1802
|
+
ref={zoomElRef}
|
|
1736
1803
|
data-storyboard-canvas-zoom
|
|
1737
1804
|
data-sb-canvas-theme={canvasTheme}
|
|
1738
1805
|
className={styles.canvasZoom}
|
|
@@ -32,6 +32,13 @@
|
|
|
32
32
|
min-height: 100%;
|
|
33
33
|
}
|
|
34
34
|
|
|
35
|
+
/* GPU compositing hint during active zoom gestures — applied imperatively
|
|
36
|
+
via data-zooming attribute and removed on zoom-end to avoid permanent
|
|
37
|
+
memory pressure on the large canvas surface. */
|
|
38
|
+
.canvasZoom[data-zooming] {
|
|
39
|
+
will-change: transform;
|
|
40
|
+
}
|
|
41
|
+
|
|
35
42
|
/* Selection outline is now handled by WidgetChrome.module.css (.widgetSlotSelected) */
|
|
36
43
|
|
|
37
44
|
.canvasTitle {
|
|
@@ -249,7 +249,8 @@ export default forwardRef(function PrototypeEmbed({ id: widgetId, props, onUpdat
|
|
|
249
249
|
}, [showIframe])
|
|
250
250
|
|
|
251
251
|
// Exit interactive mode when clicking outside the embed.
|
|
252
|
-
//
|
|
252
|
+
// Hides iframe immediately for a responsive feel, then captures
|
|
253
|
+
// snapshots in the background with the iframe hidden but still mounted.
|
|
253
254
|
useEffect(() => {
|
|
254
255
|
if (!interactive || expanded) return
|
|
255
256
|
function handlePointerDown(e) {
|
|
@@ -259,18 +260,10 @@ export default forwardRef(function PrototypeEmbed({ id: widgetId, props, onUpdat
|
|
|
259
260
|
|
|
260
261
|
setInteractive(false)
|
|
261
262
|
if (onUpdate && !isExternal && iframeLoaded && iframeRef.current?.contentWindow) {
|
|
263
|
+
// Keep iframe mounted but hidden for background capture
|
|
264
|
+
if (iframeRef.current) iframeRef.current.style.visibility = 'hidden'
|
|
262
265
|
const session = ++exitSessionRef.current
|
|
263
|
-
requestCapture({ force: true }).then(
|
|
264
|
-
if (exitSessionRef.current !== session) return
|
|
265
|
-
const urls = [updates?.snapshotLight, updates?.snapshotDark].filter(Boolean)
|
|
266
|
-
if (urls.length > 0) {
|
|
267
|
-
await Promise.all(urls.map(url => new Promise(resolve => {
|
|
268
|
-
const img = new Image()
|
|
269
|
-
img.onload = resolve
|
|
270
|
-
img.onerror = resolve
|
|
271
|
-
img.src = url
|
|
272
|
-
})))
|
|
273
|
-
}
|
|
266
|
+
requestCapture({ force: true }).then(() => {
|
|
274
267
|
if (exitSessionRef.current !== session) return
|
|
275
268
|
setShowIframe(false)
|
|
276
269
|
})
|
|
@@ -156,7 +156,8 @@ export default forwardRef(function StoryWidget({ id: widgetId, props, onUpdate,
|
|
|
156
156
|
}, [showIframe])
|
|
157
157
|
|
|
158
158
|
// Exit interactive mode when clicking outside.
|
|
159
|
-
//
|
|
159
|
+
// Hides iframe immediately for a responsive feel, then captures
|
|
160
|
+
// snapshots in the background with the iframe hidden but still mounted.
|
|
160
161
|
useEffect(() => {
|
|
161
162
|
if (!interactive) return
|
|
162
163
|
function handlePointerDown(e) {
|
|
@@ -166,18 +167,10 @@ export default forwardRef(function StoryWidget({ id: widgetId, props, onUpdate,
|
|
|
166
167
|
|
|
167
168
|
setInteractive(false)
|
|
168
169
|
if (onUpdate && iframeLoaded && iframeRef.current?.contentWindow) {
|
|
170
|
+
// Keep iframe mounted but hidden for background capture
|
|
171
|
+
if (iframeRef.current) iframeRef.current.style.visibility = 'hidden'
|
|
169
172
|
const session = ++exitSessionRef.current
|
|
170
|
-
requestCapture({ force: true }).then(
|
|
171
|
-
if (exitSessionRef.current !== session) return
|
|
172
|
-
const urls = [updates?.snapshotLight, updates?.snapshotDark].filter(Boolean)
|
|
173
|
-
if (urls.length > 0) {
|
|
174
|
-
await Promise.all(urls.map(url => new Promise(resolve => {
|
|
175
|
-
const img = new Image()
|
|
176
|
-
img.onload = resolve
|
|
177
|
-
img.onerror = resolve
|
|
178
|
-
img.src = url
|
|
179
|
-
})))
|
|
180
|
-
}
|
|
173
|
+
requestCapture({ force: true }).then(() => {
|
|
181
174
|
if (exitSessionRef.current !== session) return
|
|
182
175
|
setShowIframe(false)
|
|
183
176
|
})
|
|
@@ -9,6 +9,10 @@
|
|
|
9
9
|
* captures the alternate theme, and switches back. The user never
|
|
10
10
|
* sees the theme flash because the iframe is hidden during the switch.
|
|
11
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
|
+
*
|
|
12
16
|
* Only active in dev mode (when onUpdate is provided).
|
|
13
17
|
*/
|
|
14
18
|
import { useState, useEffect, useCallback, useRef } from 'react'
|
|
@@ -84,6 +88,19 @@ function switchTheme(iframeContentWindow, theme, requestId, listeners) {
|
|
|
84
88
|
})
|
|
85
89
|
}
|
|
86
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
|
+
|
|
87
104
|
export function useSnapshotCapture({
|
|
88
105
|
iframeRef,
|
|
89
106
|
widgetId,
|
|
@@ -94,6 +111,8 @@ export function useSnapshotCapture({
|
|
|
94
111
|
const iframeReadyRef = useRef(false)
|
|
95
112
|
const capturingRef = useRef(false)
|
|
96
113
|
const requestIdCounter = useRef(0)
|
|
114
|
+
// Generation token — incremented on each capture request to detect stale results
|
|
115
|
+
const captureGeneration = useRef(0)
|
|
97
116
|
// Handlers for both snapshot and theme-applied responses
|
|
98
117
|
const responseHandlers = useRef([])
|
|
99
118
|
|
|
@@ -126,14 +145,18 @@ export function useSnapshotCapture({
|
|
|
126
145
|
|
|
127
146
|
window.addEventListener('message', handler)
|
|
128
147
|
return () => window.removeEventListener('message', handler)
|
|
129
|
-
}, [iframeRef, onUpdate])
|
|
148
|
+
}, [iframeRef, onUpdate])
|
|
130
149
|
|
|
131
150
|
/**
|
|
132
|
-
* Dual-theme capture
|
|
133
|
-
*
|
|
134
|
-
*
|
|
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
|
+
*
|
|
135
158
|
* @param {Object} opts
|
|
136
|
-
* @param {boolean} opts.force - Skip the iframeReady guard
|
|
159
|
+
* @param {boolean} opts.force - Skip the iframeReady guard
|
|
137
160
|
*/
|
|
138
161
|
const requestCapture = useCallback(async ({ force = false } = {}) => {
|
|
139
162
|
if (!onUpdate) return {}
|
|
@@ -142,6 +165,7 @@ export function useSnapshotCapture({
|
|
|
142
165
|
if (!force && !iframeReadyRef.current) return {}
|
|
143
166
|
|
|
144
167
|
capturingRef.current = true
|
|
168
|
+
const gen = ++captureGeneration.current
|
|
145
169
|
const cw = iframeRef.current.contentWindow
|
|
146
170
|
const iframe = iframeRef.current
|
|
147
171
|
const base = (import.meta.env?.BASE_URL || '/').replace(/\/$/, '')
|
|
@@ -153,57 +177,70 @@ export function useSnapshotCapture({
|
|
|
153
177
|
try {
|
|
154
178
|
// 1. Capture current theme (iframe is visible, user sees current state)
|
|
155
179
|
const currentKey = isCurrentDark ? 'snapshotDark' : 'snapshotLight'
|
|
180
|
+
const currentLabel = isCurrentDark ? 'dark' : 'light'
|
|
156
181
|
const currentReqId = ++requestIdCounter.current
|
|
157
182
|
const currentDataUrl = await captureOnce(cw, currentReqId, responseHandlers.current)
|
|
158
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
|
|
159
193
|
if (currentDataUrl) {
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
onUpdate?.({ [currentKey]: updates[currentKey] })
|
|
167
|
-
await new Promise(resolve => {
|
|
168
|
-
const img = new Image()
|
|
169
|
-
img.onload = resolve
|
|
170
|
-
img.onerror = resolve
|
|
171
|
-
img.src = updates[currentKey]
|
|
172
|
-
setTimeout(resolve, 2000)
|
|
173
|
-
})
|
|
174
|
-
}
|
|
194
|
+
uploadPromise1.then(url => {
|
|
195
|
+
if (url && gen === captureGeneration.current) {
|
|
196
|
+
updates[currentKey] = url
|
|
197
|
+
onUpdate?.({ [currentKey]: url })
|
|
198
|
+
}
|
|
199
|
+
}).catch(() => {})
|
|
175
200
|
}
|
|
176
201
|
|
|
177
|
-
//
|
|
202
|
+
// 3. Hide iframe, switch theme, capture alternate — overlaps with upload-1
|
|
178
203
|
const savedVisibility = iframe.style.visibility
|
|
179
204
|
iframe.style.visibility = 'hidden'
|
|
180
205
|
|
|
181
206
|
const switchReqId = ++requestIdCounter.current
|
|
182
207
|
const switched = await switchTheme(cw, alternateTheme, switchReqId, responseHandlers.current)
|
|
183
208
|
|
|
209
|
+
if (gen !== captureGeneration.current) {
|
|
210
|
+
iframe.style.visibility = savedVisibility || ''
|
|
211
|
+
return {}
|
|
212
|
+
}
|
|
213
|
+
|
|
184
214
|
if (switched) {
|
|
185
215
|
const altKey = isCurrentDark ? 'snapshotLight' : 'snapshotDark'
|
|
216
|
+
const altLabel = isCurrentDark ? 'light' : 'dark'
|
|
186
217
|
const altReqId = ++requestIdCounter.current
|
|
187
218
|
const altDataUrl = await captureOnce(cw, altReqId, responseHandlers.current)
|
|
188
219
|
|
|
220
|
+
if (gen !== captureGeneration.current) {
|
|
221
|
+
iframe.style.visibility = savedVisibility || ''
|
|
222
|
+
return {}
|
|
223
|
+
}
|
|
224
|
+
|
|
189
225
|
if (altDataUrl) {
|
|
190
|
-
const
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
const cacheBust = `?v=${Date.now()}`
|
|
194
|
-
updates[altKey] = `${base}/_storyboard/canvas/images/${result.filename}${cacheBust}`
|
|
226
|
+
const altUrl = await uploadAndResolve(altDataUrl, widgetId, altLabel, base)
|
|
227
|
+
if (altUrl && gen === captureGeneration.current) {
|
|
228
|
+
updates[altKey] = altUrl
|
|
195
229
|
}
|
|
196
230
|
}
|
|
197
231
|
|
|
198
|
-
//
|
|
232
|
+
// 4. Switch back to original theme
|
|
199
233
|
const switchBackReqId = ++requestIdCounter.current
|
|
200
234
|
await switchTheme(cw, currentTheme, switchBackReqId, responseHandlers.current)
|
|
201
235
|
}
|
|
202
236
|
|
|
203
|
-
//
|
|
237
|
+
// Ensure upload-1 is complete before final update
|
|
238
|
+
await uploadPromise1
|
|
239
|
+
|
|
240
|
+
// 5. Restore iframe visibility
|
|
204
241
|
iframe.style.visibility = savedVisibility || ''
|
|
205
242
|
|
|
206
|
-
if (Object.keys(updates).length > 0) {
|
|
243
|
+
if (gen === captureGeneration.current && Object.keys(updates).length > 0) {
|
|
207
244
|
onUpdate?.(updates)
|
|
208
245
|
}
|
|
209
246
|
return updates
|
|
@@ -215,7 +252,7 @@ export function useSnapshotCapture({
|
|
|
215
252
|
} finally {
|
|
216
253
|
capturingRef.current = false
|
|
217
254
|
}
|
|
218
|
-
}, [onUpdate, iframeRef, widgetId, canvasTheme])
|
|
255
|
+
}, [onUpdate, iframeRef, widgetId, canvasTheme])
|
|
219
256
|
|
|
220
257
|
return { iframeReady, requestCapture }
|
|
221
258
|
}
|
|
@@ -146,4 +146,80 @@ describe('useSnapshotCapture', () => {
|
|
|
146
146
|
})
|
|
147
147
|
)
|
|
148
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
|
+
})
|
|
149
225
|
})
|