@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
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { render, screen } from '@testing-library/react'
|
|
2
|
+
import { describe, expect, it, vi } from 'vitest'
|
|
3
|
+
import LinkPreview from './LinkPreview.jsx'
|
|
4
|
+
|
|
5
|
+
describe('LinkPreview', () => {
|
|
6
|
+
it('renders GitHub issue card with markdown body and author byline', () => {
|
|
7
|
+
const { container } = render(
|
|
8
|
+
<LinkPreview
|
|
9
|
+
id="link-1"
|
|
10
|
+
props={{
|
|
11
|
+
url: 'https://github.com/dfosco/storyboard/issues/42',
|
|
12
|
+
title: '#42 Ship GitHub embeds',
|
|
13
|
+
github: {
|
|
14
|
+
context: 'GitHub · dfosco/storyboard · Issue #42',
|
|
15
|
+
body: '## Summary\n\nThis is a **bold** point.\n\n- Item one\n- Item two',
|
|
16
|
+
authors: ['dfosco'],
|
|
17
|
+
createdAt: '2026-01-01T00:00:00Z',
|
|
18
|
+
},
|
|
19
|
+
}}
|
|
20
|
+
/>,
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
// Title split: text + muted number
|
|
24
|
+
expect(screen.getByText('Ship GitHub embeds')).toBeInTheDocument()
|
|
25
|
+
expect(screen.getByText('#42')).toBeInTheDocument()
|
|
26
|
+
|
|
27
|
+
// Markdown body renders headings, bold, lists
|
|
28
|
+
const headings = container.querySelectorAll('h2')
|
|
29
|
+
expect(headings.length).toBeGreaterThanOrEqual(1)
|
|
30
|
+
// Find the body heading (not the title)
|
|
31
|
+
const summaryHeading = Array.from(headings).find(h => h.textContent === 'Summary')
|
|
32
|
+
expect(summaryHeading).toBeTruthy()
|
|
33
|
+
expect(container.querySelectorAll('li')).toHaveLength(2)
|
|
34
|
+
|
|
35
|
+
// Author byline
|
|
36
|
+
expect(screen.getByText('dfosco')).toBeInTheDocument()
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
it('does not render GitHub layout for non-GitHub links', () => {
|
|
40
|
+
render(
|
|
41
|
+
<LinkPreview
|
|
42
|
+
id="link-2"
|
|
43
|
+
props={{
|
|
44
|
+
url: 'https://example.com/docs',
|
|
45
|
+
title: 'Example docs',
|
|
46
|
+
}}
|
|
47
|
+
/>,
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
expect(screen.getByText('Example docs')).toBeInTheDocument()
|
|
51
|
+
expect(screen.getByText('example.com')).toBeInTheDocument()
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
it('renders plain link-preview without github data', () => {
|
|
55
|
+
const { container } = render(
|
|
56
|
+
<LinkPreview
|
|
57
|
+
id="link-3"
|
|
58
|
+
props={{
|
|
59
|
+
url: 'https://figma.com/design/abc',
|
|
60
|
+
title: 'My design',
|
|
61
|
+
width: 320,
|
|
62
|
+
height: 120,
|
|
63
|
+
}}
|
|
64
|
+
/>,
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
expect(screen.getByText('My design')).toBeInTheDocument()
|
|
68
|
+
// No issue card rendered
|
|
69
|
+
expect(container.querySelector('header')).toBeNull()
|
|
70
|
+
})
|
|
71
|
+
})
|
|
@@ -19,7 +19,8 @@ function renderMarkdown(text) {
|
|
|
19
19
|
.use(remarkGfm)
|
|
20
20
|
.use(remarkHtml, { sanitize: false })
|
|
21
21
|
.processSync(text)
|
|
22
|
-
|
|
22
|
+
// Open all links in new tabs
|
|
23
|
+
return String(result).replace(/<a\s/g, '<a target="_blank" rel="noopener noreferrer" ')
|
|
23
24
|
}
|
|
24
25
|
|
|
25
26
|
/**
|
|
@@ -24,6 +24,38 @@
|
|
|
24
24
|
pointer-events: none;
|
|
25
25
|
}
|
|
26
26
|
|
|
27
|
+
.preview a {
|
|
28
|
+
color: var(--sb--markdown-accent);
|
|
29
|
+
text-decoration: none;
|
|
30
|
+
pointer-events: auto;
|
|
31
|
+
cursor: pointer;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
.preview a:hover {
|
|
35
|
+
text-decoration: underline;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
.preview img {
|
|
39
|
+
max-width: 100%;
|
|
40
|
+
height: auto;
|
|
41
|
+
border-radius: 6px;
|
|
42
|
+
border: 1px solid var(--borderColor-default, #d0d7de);
|
|
43
|
+
margin: 8px 0;
|
|
44
|
+
display: block;
|
|
45
|
+
pointer-events: auto;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
.preview video {
|
|
49
|
+
max-width: 100%;
|
|
50
|
+
height: auto;
|
|
51
|
+
border-radius: 6px;
|
|
52
|
+
border: 1px solid var(--borderColor-default, #d0d7de);
|
|
53
|
+
margin: 8px 0;
|
|
54
|
+
display: block;
|
|
55
|
+
pointer-events: auto;
|
|
56
|
+
background: var(--bgColor-muted, #f6f8fa);
|
|
57
|
+
}
|
|
58
|
+
|
|
27
59
|
.preview h1 {
|
|
28
60
|
font-size: 20px;
|
|
29
61
|
font-weight: 700;
|
|
@@ -75,10 +107,11 @@
|
|
|
75
107
|
list-style-type: decimal;
|
|
76
108
|
}
|
|
77
109
|
|
|
78
|
-
/* GFM: Task lists */
|
|
110
|
+
/* GFM: Task lists — accent-colored checkboxes */
|
|
79
111
|
.preview input[type="checkbox"] {
|
|
80
112
|
margin-right: 6px;
|
|
81
113
|
pointer-events: none;
|
|
114
|
+
accent-color: var(--sb--markdown-accent);
|
|
82
115
|
}
|
|
83
116
|
|
|
84
117
|
.preview li:has(input[type="checkbox"]) {
|
|
@@ -112,16 +145,6 @@
|
|
|
112
145
|
font-weight: 600;
|
|
113
146
|
}
|
|
114
147
|
|
|
115
|
-
/* GFM: Autolinks */
|
|
116
|
-
.preview a {
|
|
117
|
-
color: var(--sb--markdown-accent);
|
|
118
|
-
text-decoration: none;
|
|
119
|
-
}
|
|
120
|
-
|
|
121
|
-
.preview a:hover {
|
|
122
|
-
text-decoration: underline;
|
|
123
|
-
}
|
|
124
|
-
|
|
125
148
|
/* Code blocks */
|
|
126
149
|
.preview pre {
|
|
127
150
|
padding: 12px 16px;
|
|
@@ -3,9 +3,10 @@ import { createPortal } from 'react-dom'
|
|
|
3
3
|
import { buildPrototypeIndex } from '@dfosco/storyboard-core'
|
|
4
4
|
import WidgetWrapper from './WidgetWrapper.jsx'
|
|
5
5
|
import { readProp, prototypeEmbedSchema } from './widgetProps.js'
|
|
6
|
-
import { getEmbedChromeVars,
|
|
6
|
+
import { getEmbedChromeVars, subscribeCanvasTheme } from './embedTheme.js'
|
|
7
7
|
import { useIframeDevLogs } from './iframeDevLogs.js'
|
|
8
8
|
import { useSnapshotCapture } from './useSnapshotCapture.js'
|
|
9
|
+
import { enqueueRefresh, cancelRefresh, REVEAL_INTERVAL } from './refreshQueue.js'
|
|
9
10
|
import styles from './PrototypeEmbed.module.css'
|
|
10
11
|
import overlayStyles from './embedOverlay.module.css'
|
|
11
12
|
|
|
@@ -66,8 +67,7 @@ export default forwardRef(function PrototypeEmbed({ id: widgetId, props, onUpdat
|
|
|
66
67
|
const height = readProp(props, 'height', prototypeEmbedSchema)
|
|
67
68
|
const zoom = readProp(props, 'zoom', prototypeEmbedSchema)
|
|
68
69
|
const label = readProp(props, 'label', prototypeEmbedSchema) || src
|
|
69
|
-
const
|
|
70
|
-
const snapshotDark = props?.snapshotDark || ''
|
|
70
|
+
const snapshot = props?.snapshot || props?.snapshotLight || props?.snapshotDark || ''
|
|
71
71
|
|
|
72
72
|
const basePath = (import.meta.env.BASE_URL || '/').replace(/\/$/, '')
|
|
73
73
|
const baseSegment = basePath.replace(/^\//, '')
|
|
@@ -88,7 +88,7 @@ export default forwardRef(function PrototypeEmbed({ id: widgetId, props, onUpdat
|
|
|
88
88
|
const [iframeLoaded, setIframeLoaded] = useState(false)
|
|
89
89
|
const [expanded, setExpanded] = useState(false)
|
|
90
90
|
const [filter, setFilter] = useState('')
|
|
91
|
-
const [canvasTheme, setCanvasTheme] = useState(
|
|
91
|
+
const [canvasTheme, setCanvasTheme] = useState('light')
|
|
92
92
|
const [brokenSnaps, setBrokenSnaps] = useState({})
|
|
93
93
|
|
|
94
94
|
const inputRef = useRef(null)
|
|
@@ -97,6 +97,7 @@ export default forwardRef(function PrototypeEmbed({ id: widgetId, props, onUpdat
|
|
|
97
97
|
const iframeRef = useRef(null)
|
|
98
98
|
const captureOnReadyRef = useRef(false)
|
|
99
99
|
const exitSessionRef = useRef(0)
|
|
100
|
+
const teardownTimerRef = useRef(null)
|
|
100
101
|
const inlineContainerRef = useRef(null)
|
|
101
102
|
const modalContainerRef = useRef(null)
|
|
102
103
|
const resizeTimerRef = useRef(null)
|
|
@@ -108,14 +109,11 @@ export default forwardRef(function PrototypeEmbed({ id: widgetId, props, onUpdat
|
|
|
108
109
|
iframeRef,
|
|
109
110
|
widgetId,
|
|
110
111
|
onUpdate: isExternal ? null : onUpdate,
|
|
111
|
-
|
|
112
|
+
showIframe,
|
|
112
113
|
})
|
|
113
114
|
|
|
114
|
-
//
|
|
115
|
-
const
|
|
116
|
-
const hasLightSnap = !isExternal && !!(snapshotLight && snapshotLight.includes(widgetId) && !brokenSnaps[snapshotLight])
|
|
117
|
-
const hasDarkSnap = !isExternal && !!(snapshotDark && snapshotDark.includes(widgetId) && !brokenSnaps[snapshotDark])
|
|
118
|
-
const hasAnySnap = hasLightSnap || hasDarkSnap
|
|
115
|
+
// Single snapshot — backward compat reads snapshotLight/snapshotDark if snapshot is missing
|
|
116
|
+
const hasSnap = !isExternal && !!(snapshot && snapshot.includes(widgetId) && !brokenSnaps[snapshot])
|
|
119
117
|
|
|
120
118
|
const iframeSrc = useMemo(() => {
|
|
121
119
|
if (!rawSrc) return ''
|
|
@@ -125,8 +123,8 @@ export default forwardRef(function PrototypeEmbed({ id: widgetId, props, onUpdat
|
|
|
125
123
|
const base = hashIdx >= 0 ? rawSrc.slice(0, hashIdx) : rawSrc
|
|
126
124
|
const hash = hashIdx >= 0 ? rawSrc.slice(hashIdx) : ''
|
|
127
125
|
const sep = base.includes('?') ? '&' : '?'
|
|
128
|
-
return `${base}${sep}_sb_embed&_sb_theme_target=prototype
|
|
129
|
-
}, [rawSrc
|
|
126
|
+
return `${base}${sep}_sb_embed&_sb_theme_target=prototype${hash}`
|
|
127
|
+
}, [rawSrc])
|
|
130
128
|
|
|
131
129
|
useIframeDevLogs({
|
|
132
130
|
widget: 'PrototypeEmbed',
|
|
@@ -260,13 +258,36 @@ export default forwardRef(function PrototypeEmbed({ id: widgetId, props, onUpdat
|
|
|
260
258
|
|
|
261
259
|
setInteractive(false)
|
|
262
260
|
if (onUpdate && !isExternal && iframeLoaded && iframeRef.current?.contentWindow) {
|
|
263
|
-
// Keep iframe mounted but hidden for background capture
|
|
264
261
|
if (iframeRef.current) iframeRef.current.style.visibility = 'hidden'
|
|
265
262
|
const session = ++exitSessionRef.current
|
|
266
|
-
|
|
263
|
+
setTimeout(() => {
|
|
264
|
+
if (exitSessionRef.current !== session) return
|
|
265
|
+
requestCapture({ force: true }).then((updates) => {
|
|
266
|
+
if (exitSessionRef.current !== session) return
|
|
267
|
+
const snap = updates?.snapshot
|
|
268
|
+
if (snap) {
|
|
269
|
+
const img = new Image()
|
|
270
|
+
const done = () => {
|
|
271
|
+
if (exitSessionRef.current === session) setShowIframe(false)
|
|
272
|
+
}
|
|
273
|
+
img.onload = done
|
|
274
|
+
img.onerror = done
|
|
275
|
+
img.src = snap
|
|
276
|
+
setTimeout(done, 2000)
|
|
277
|
+
} else {
|
|
278
|
+
setShowIframe(false)
|
|
279
|
+
}
|
|
280
|
+
})
|
|
281
|
+
}, 0)
|
|
282
|
+
} else if (isExternal && showIframe) {
|
|
283
|
+
// External embeds (e.g. Figma) are slow to reload — keep the
|
|
284
|
+
// iframe mounted for 2 min so re-entering is instant.
|
|
285
|
+
const session = ++exitSessionRef.current
|
|
286
|
+
clearTimeout(teardownTimerRef.current)
|
|
287
|
+
teardownTimerRef.current = setTimeout(() => {
|
|
267
288
|
if (exitSessionRef.current !== session) return
|
|
268
289
|
setShowIframe(false)
|
|
269
|
-
})
|
|
290
|
+
}, 2 * 60 * 1000)
|
|
270
291
|
} else {
|
|
271
292
|
setShowIframe(false)
|
|
272
293
|
}
|
|
@@ -276,17 +297,34 @@ export default forwardRef(function PrototypeEmbed({ id: widgetId, props, onUpdat
|
|
|
276
297
|
return () => document.removeEventListener('pointerdown', handlePointerDown)
|
|
277
298
|
}, [interactive, expanded, onUpdate, isExternal, iframeLoaded, requestCapture])
|
|
278
299
|
|
|
300
|
+
useEffect(() => subscribeCanvasTheme({
|
|
301
|
+
anchorRef: embedRef,
|
|
302
|
+
onTheme: setCanvasTheme,
|
|
303
|
+
}), [])
|
|
304
|
+
|
|
305
|
+
// On canvas theme change, enqueue a background snapshot refresh.
|
|
306
|
+
// Skips the initial render (canvasThemeInitRef tracks first value).
|
|
307
|
+
const canvasThemeInitRef = useRef(true)
|
|
308
|
+
const refreshMetaRef = useRef(null)
|
|
279
309
|
useEffect(() => {
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
310
|
+
if (canvasThemeInitRef.current) { canvasThemeInitRef.current = false; return }
|
|
311
|
+
if (isExternal || !onUpdate || interactive) return
|
|
312
|
+
const rect = embedRef.current?.getBoundingClientRect()
|
|
313
|
+
enqueueRefresh(widgetId, ({ revealOrder, batchStart }) => {
|
|
314
|
+
return new Promise((resolve) => {
|
|
315
|
+
refreshMetaRef.current = { revealOrder, batchStart, resolve }
|
|
316
|
+
captureOnReadyRef.current = true
|
|
317
|
+
setShowIframe(true)
|
|
318
|
+
// Safety timeout — report failure so retry pass picks it up
|
|
319
|
+
setTimeout(() => { refreshMetaRef.current = null; resolve(false) }, 10000)
|
|
320
|
+
})
|
|
321
|
+
}, rect ? { x: rect.left, y: rect.top } : undefined)
|
|
322
|
+
}, [canvasTheme]) // eslint-disable-line react-hooks/exhaustive-deps
|
|
285
323
|
|
|
286
324
|
// Capture snapshot on first iframe ready (when no existing snapshot)
|
|
287
325
|
useEffect(() => {
|
|
288
326
|
if (!iframeReady || !onUpdate || isExternal) return
|
|
289
|
-
if (!
|
|
327
|
+
if (!hasSnap) {
|
|
290
328
|
requestCapture()
|
|
291
329
|
}
|
|
292
330
|
}, [iframeReady]) // eslint-disable-line react-hooks/exhaustive-deps
|
|
@@ -295,12 +333,39 @@ export default forwardRef(function PrototypeEmbed({ id: widgetId, props, onUpdat
|
|
|
295
333
|
useEffect(() => {
|
|
296
334
|
if (iframeReady && captureOnReadyRef.current) {
|
|
297
335
|
captureOnReadyRef.current = false
|
|
298
|
-
requestCapture()
|
|
336
|
+
requestCapture().then((updates) => {
|
|
337
|
+
const meta = refreshMetaRef.current
|
|
338
|
+
if (meta) {
|
|
339
|
+
refreshMetaRef.current = null
|
|
340
|
+
const snap = updates?.snapshot
|
|
341
|
+
const reveal = () => {
|
|
342
|
+
if (snap) {
|
|
343
|
+
const img = new Image()
|
|
344
|
+
const done = () => setShowIframe(false)
|
|
345
|
+
img.onload = done
|
|
346
|
+
img.onerror = done
|
|
347
|
+
img.src = snap
|
|
348
|
+
setTimeout(done, 2000)
|
|
349
|
+
} else {
|
|
350
|
+
setShowIframe(false)
|
|
351
|
+
}
|
|
352
|
+
meta.resolve(!!snap)
|
|
353
|
+
}
|
|
354
|
+
// Wait for our reveal slot in the wave
|
|
355
|
+
const elapsed = Date.now() - meta.batchStart
|
|
356
|
+
const targetTime = meta.revealOrder * REVEAL_INTERVAL
|
|
357
|
+
const wait = Math.max(0, targetTime - elapsed)
|
|
358
|
+
setTimeout(reveal, wait)
|
|
359
|
+
}
|
|
360
|
+
})
|
|
299
361
|
}
|
|
300
362
|
}, [iframeReady, requestCapture])
|
|
301
363
|
|
|
302
|
-
// Cleanup
|
|
303
|
-
useEffect(() => () =>
|
|
364
|
+
// Cleanup timers on unmount
|
|
365
|
+
useEffect(() => () => {
|
|
366
|
+
clearTimeout(resizeTimerRef.current)
|
|
367
|
+
clearTimeout(teardownTimerRef.current)
|
|
368
|
+
}, [])
|
|
304
369
|
|
|
305
370
|
// Close expanded modal on Escape
|
|
306
371
|
useEffect(() => {
|
|
@@ -374,9 +439,11 @@ export default forwardRef(function PrototypeEmbed({ id: widgetId, props, onUpdat
|
|
|
374
439
|
|
|
375
440
|
const enterInteractive = useCallback(() => {
|
|
376
441
|
exitSessionRef.current++
|
|
442
|
+
clearTimeout(teardownTimerRef.current)
|
|
443
|
+
cancelRefresh(widgetId)
|
|
377
444
|
setShowIframe(true)
|
|
378
445
|
setInteractive(true)
|
|
379
|
-
}, [])
|
|
446
|
+
}, [widgetId])
|
|
380
447
|
|
|
381
448
|
// Expose imperative action handlers for WidgetChrome
|
|
382
449
|
useImperativeHandle(ref, () => ({
|
|
@@ -518,25 +585,14 @@ export default forwardRef(function PrototypeEmbed({ id: widgetId, props, onUpdat
|
|
|
518
585
|
className={styles.iframeContainer}
|
|
519
586
|
style={expanded ? { visibility: 'hidden' } : undefined}
|
|
520
587
|
>
|
|
521
|
-
{/* Snapshot layer —
|
|
522
|
-
{
|
|
523
|
-
<img
|
|
524
|
-
src={snapshotLight}
|
|
525
|
-
className={styles.snapshotImage}
|
|
526
|
-
style={(isDark && hasDarkSnap) ? { visibility: 'hidden' } : undefined}
|
|
527
|
-
alt={`${prototypeTitle} snapshot`}
|
|
528
|
-
draggable={false}
|
|
529
|
-
onError={() => setBrokenSnaps(prev => ({ ...prev, [snapshotLight]: true }))}
|
|
530
|
-
/>
|
|
531
|
-
)}
|
|
532
|
-
{hasDarkSnap && (
|
|
588
|
+
{/* Snapshot layer — single image */}
|
|
589
|
+
{hasSnap && (
|
|
533
590
|
<img
|
|
534
|
-
src={
|
|
591
|
+
src={snapshot}
|
|
535
592
|
className={styles.snapshotImage}
|
|
536
|
-
style={(!isDark && hasLightSnap) ? { visibility: 'hidden' } : undefined}
|
|
537
593
|
alt={`${prototypeTitle} snapshot`}
|
|
538
594
|
draggable={false}
|
|
539
|
-
onError={() => setBrokenSnaps(prev => ({ ...prev, [
|
|
595
|
+
onError={() => setBrokenSnaps(prev => ({ ...prev, [snapshot]: true }))}
|
|
540
596
|
/>
|
|
541
597
|
)}
|
|
542
598
|
|
|
@@ -551,6 +607,7 @@ export default forwardRef(function PrototypeEmbed({ id: widgetId, props, onUpdat
|
|
|
551
607
|
height: height / scale,
|
|
552
608
|
transform: `scale(${scale})`,
|
|
553
609
|
transformOrigin: '0 0',
|
|
610
|
+
transition: 'opacity 150ms ease',
|
|
554
611
|
...(iframeLoaded ? {} : { opacity: 0 }),
|
|
555
612
|
}}
|
|
556
613
|
onLoad={() => setIframeLoaded(true)}
|
|
@@ -559,8 +616,8 @@ export default forwardRef(function PrototypeEmbed({ id: widgetId, props, onUpdat
|
|
|
559
616
|
/>
|
|
560
617
|
)}
|
|
561
618
|
|
|
562
|
-
{/* Placeholder — only when no
|
|
563
|
-
{!
|
|
619
|
+
{/* Placeholder — only when no snapshot and no iframe */}
|
|
620
|
+
{!hasSnap && !showIframe && (
|
|
564
621
|
<div className={styles.placeholder}>
|
|
565
622
|
<CollageFrameIcon size={36} />
|
|
566
623
|
<span className={styles.placeholderLabel}>{`${prototypeTitle} prototype`}</span>
|
|
@@ -584,9 +641,9 @@ export default forwardRef(function PrototypeEmbed({ id: widgetId, props, onUpdat
|
|
|
584
641
|
enterInteractive()
|
|
585
642
|
}
|
|
586
643
|
}}
|
|
587
|
-
aria-label=
|
|
644
|
+
aria-label={hasSnap ? 'Click to interact with prototype' : 'Click to open prototype'}
|
|
588
645
|
>
|
|
589
|
-
<span className={overlayStyles.interactHint}>Click to interact</span>
|
|
646
|
+
<span className={overlayStyles.interactHint}>{hasSnap ? 'Click to interact' : 'Click to open'}</span>
|
|
590
647
|
</div>
|
|
591
648
|
)}
|
|
592
649
|
</>
|
|
@@ -18,7 +18,8 @@ import WidgetWrapper from './WidgetWrapper.jsx'
|
|
|
18
18
|
import ResizeHandle from './ResizeHandle.jsx'
|
|
19
19
|
import { useIframeDevLogs } from './iframeDevLogs.js'
|
|
20
20
|
import { useSnapshotCapture } from './useSnapshotCapture.js'
|
|
21
|
-
import {
|
|
21
|
+
import { subscribeCanvasTheme } from './embedTheme.js'
|
|
22
|
+
import { enqueueRefresh, cancelRefresh, REVEAL_INTERVAL } from './refreshQueue.js'
|
|
22
23
|
import styles from './StoryWidget.module.css'
|
|
23
24
|
import overlayStyles from './embedOverlay.module.css'
|
|
24
25
|
|
|
@@ -39,7 +40,7 @@ function resolveStoryUrl(storyId, exportName) {
|
|
|
39
40
|
|
|
40
41
|
const base = (import.meta.env.BASE_URL || '/').replace(/\/$/, '')
|
|
41
42
|
const route = story._route
|
|
42
|
-
const params = new URLSearchParams({ _sb_embed: '1' })
|
|
43
|
+
const params = new URLSearchParams({ _sb_embed: '1', _sb_theme_target: 'prototype' })
|
|
43
44
|
if (exportName) params.set('export', exportName)
|
|
44
45
|
|
|
45
46
|
return `${base}${route}?${params}`
|
|
@@ -86,14 +87,14 @@ export default forwardRef(function StoryWidget({ id: widgetId, props, onUpdate,
|
|
|
86
87
|
const exportName = props?.exportName || ''
|
|
87
88
|
const width = props?.width
|
|
88
89
|
const height = props?.height
|
|
89
|
-
const
|
|
90
|
-
const snapshotDark = props?.snapshotDark || ''
|
|
90
|
+
const snapshot = props?.snapshot || props?.snapshotLight || props?.snapshotDark || ''
|
|
91
91
|
|
|
92
92
|
const containerRef = useRef(null)
|
|
93
93
|
const iframeRef = useRef(null)
|
|
94
94
|
const resizeTimerRef = useRef(null)
|
|
95
95
|
const captureOnReadyRef = useRef(false)
|
|
96
96
|
const exitSessionRef = useRef(0)
|
|
97
|
+
const refreshMetaRef = useRef(null)
|
|
97
98
|
const [interactive, setInteractive] = useState(false)
|
|
98
99
|
const [showIframe, setShowIframe] = useState(false)
|
|
99
100
|
const [iframeLoaded, setIframeLoaded] = useState(false)
|
|
@@ -105,27 +106,39 @@ export default forwardRef(function StoryWidget({ id: widgetId, props, onUpdate,
|
|
|
105
106
|
const [brokenSnaps, setBrokenSnaps] = useState({})
|
|
106
107
|
|
|
107
108
|
// Resolve canvas theme — reactive to theme changes
|
|
108
|
-
const [canvasTheme, setCanvasTheme] = useState(
|
|
109
|
+
const [canvasTheme, setCanvasTheme] = useState('light')
|
|
109
110
|
|
|
111
|
+
useEffect(() => subscribeCanvasTheme({
|
|
112
|
+
anchorRef: containerRef,
|
|
113
|
+
onTheme: setCanvasTheme,
|
|
114
|
+
}), [])
|
|
115
|
+
|
|
116
|
+
// On canvas theme change, enqueue a background snapshot refresh
|
|
117
|
+
const canvasThemeInitRef = useRef(true)
|
|
110
118
|
useEffect(() => {
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
119
|
+
if (canvasThemeInitRef.current) { canvasThemeInitRef.current = false; return }
|
|
120
|
+
if (!onUpdate || interactive) return
|
|
121
|
+
const rect = containerRef.current?.getBoundingClientRect()
|
|
122
|
+
enqueueRefresh(widgetId, ({ revealOrder, batchStart }) => {
|
|
123
|
+
return new Promise((resolve) => {
|
|
124
|
+
refreshMetaRef.current = { revealOrder, batchStart, resolve }
|
|
125
|
+
captureOnReadyRef.current = true
|
|
126
|
+
setShowIframe(true)
|
|
127
|
+
setTimeout(() => { refreshMetaRef.current = null; resolve(false) }, 10000)
|
|
128
|
+
})
|
|
129
|
+
}, rect ? { x: rect.left, y: rect.top } : undefined)
|
|
130
|
+
}, [canvasTheme]) // eslint-disable-line react-hooks/exhaustive-deps
|
|
115
131
|
|
|
116
132
|
// Snapshot capture hook
|
|
117
133
|
const { iframeReady, requestCapture } = useSnapshotCapture({
|
|
118
134
|
iframeRef,
|
|
119
135
|
widgetId,
|
|
120
136
|
onUpdate,
|
|
121
|
-
|
|
137
|
+
showIframe,
|
|
122
138
|
})
|
|
123
139
|
|
|
124
|
-
//
|
|
125
|
-
const
|
|
126
|
-
const hasLightSnap = !!(snapshotLight && snapshotLight.includes(widgetId) && !brokenSnaps[snapshotLight])
|
|
127
|
-
const hasDarkSnap = !!(snapshotDark && snapshotDark.includes(widgetId) && !brokenSnaps[snapshotDark])
|
|
128
|
-
const hasAnySnap = hasLightSnap || hasDarkSnap
|
|
140
|
+
// Single snapshot
|
|
141
|
+
const hasSnap = !!(snapshot && snapshot.includes(widgetId) && !brokenSnaps[snapshot])
|
|
129
142
|
|
|
130
143
|
// Re-resolve story URL when the story index is live-patched (new story added)
|
|
131
144
|
useEffect(() => {
|
|
@@ -147,9 +160,10 @@ export default forwardRef(function StoryWidget({ id: widgetId, props, onUpdate,
|
|
|
147
160
|
|
|
148
161
|
const enterInteractive = useCallback(() => {
|
|
149
162
|
exitSessionRef.current++
|
|
163
|
+
cancelRefresh(widgetId)
|
|
150
164
|
setShowIframe(true)
|
|
151
165
|
setInteractive(true)
|
|
152
|
-
}, [])
|
|
166
|
+
}, [widgetId])
|
|
153
167
|
|
|
154
168
|
useEffect(() => {
|
|
155
169
|
if (!showIframe) setIframeLoaded(false)
|
|
@@ -167,13 +181,27 @@ export default forwardRef(function StoryWidget({ id: widgetId, props, onUpdate,
|
|
|
167
181
|
|
|
168
182
|
setInteractive(false)
|
|
169
183
|
if (onUpdate && iframeLoaded && iframeRef.current?.contentWindow) {
|
|
170
|
-
// Keep iframe mounted but hidden for background capture
|
|
171
184
|
if (iframeRef.current) iframeRef.current.style.visibility = 'hidden'
|
|
172
185
|
const session = ++exitSessionRef.current
|
|
173
|
-
|
|
186
|
+
setTimeout(() => {
|
|
174
187
|
if (exitSessionRef.current !== session) return
|
|
175
|
-
|
|
176
|
-
|
|
188
|
+
requestCapture({ force: true }).then((updates) => {
|
|
189
|
+
if (exitSessionRef.current !== session) return
|
|
190
|
+
const snap = updates?.snapshot
|
|
191
|
+
if (snap) {
|
|
192
|
+
const img = new Image()
|
|
193
|
+
const done = () => {
|
|
194
|
+
if (exitSessionRef.current === session) setShowIframe(false)
|
|
195
|
+
}
|
|
196
|
+
img.onload = done
|
|
197
|
+
img.onerror = done
|
|
198
|
+
img.src = snap
|
|
199
|
+
setTimeout(done, 2000)
|
|
200
|
+
} else {
|
|
201
|
+
setShowIframe(false)
|
|
202
|
+
}
|
|
203
|
+
})
|
|
204
|
+
}, 0)
|
|
177
205
|
} else {
|
|
178
206
|
setShowIframe(false)
|
|
179
207
|
}
|
|
@@ -193,7 +221,7 @@ export default forwardRef(function StoryWidget({ id: widgetId, props, onUpdate,
|
|
|
193
221
|
// Capture snapshot on first iframe ready (when no existing snapshot)
|
|
194
222
|
useEffect(() => {
|
|
195
223
|
if (!iframeReady || !onUpdate) return
|
|
196
|
-
if (!
|
|
224
|
+
if (!hasSnap) {
|
|
197
225
|
requestCapture()
|
|
198
226
|
}
|
|
199
227
|
}, [iframeReady]) // eslint-disable-line react-hooks/exhaustive-deps
|
|
@@ -202,7 +230,31 @@ export default forwardRef(function StoryWidget({ id: widgetId, props, onUpdate,
|
|
|
202
230
|
useEffect(() => {
|
|
203
231
|
if (iframeReady && captureOnReadyRef.current) {
|
|
204
232
|
captureOnReadyRef.current = false
|
|
205
|
-
requestCapture()
|
|
233
|
+
requestCapture().then((updates) => {
|
|
234
|
+
const meta = refreshMetaRef.current
|
|
235
|
+
if (meta) {
|
|
236
|
+
refreshMetaRef.current = null
|
|
237
|
+
const snap = updates?.snapshot
|
|
238
|
+
const reveal = () => {
|
|
239
|
+
if (snap) {
|
|
240
|
+
const img = new Image()
|
|
241
|
+
const done = () => setShowIframe(false)
|
|
242
|
+
img.onload = done
|
|
243
|
+
img.onerror = done
|
|
244
|
+
img.src = snap
|
|
245
|
+
setTimeout(done, 2000)
|
|
246
|
+
} else {
|
|
247
|
+
setShowIframe(false)
|
|
248
|
+
}
|
|
249
|
+
meta.resolve(!!snap)
|
|
250
|
+
}
|
|
251
|
+
// Wait for our reveal slot in the wave
|
|
252
|
+
const elapsed = Date.now() - meta.batchStart
|
|
253
|
+
const targetTime = meta.revealOrder * REVEAL_INTERVAL
|
|
254
|
+
const wait = Math.max(0, targetTime - elapsed)
|
|
255
|
+
setTimeout(reveal, wait)
|
|
256
|
+
}
|
|
257
|
+
})
|
|
206
258
|
}
|
|
207
259
|
}, [iframeReady, requestCapture])
|
|
208
260
|
|
|
@@ -388,25 +440,14 @@ export default forwardRef(function StoryWidget({ id: widgetId, props, onUpdate,
|
|
|
388
440
|
) : (
|
|
389
441
|
<>
|
|
390
442
|
<div className={styles.content}>
|
|
391
|
-
{/* Snapshot layer —
|
|
392
|
-
{
|
|
393
|
-
<img
|
|
394
|
-
src={snapshotLight}
|
|
395
|
-
className={styles.snapshotImage}
|
|
396
|
-
style={(isDark && hasDarkSnap) ? { visibility: 'hidden' } : undefined}
|
|
397
|
-
alt={`${displayName} snapshot`}
|
|
398
|
-
draggable={false}
|
|
399
|
-
onError={() => setBrokenSnaps(prev => ({ ...prev, [snapshotLight]: true }))}
|
|
400
|
-
/>
|
|
401
|
-
)}
|
|
402
|
-
{hasDarkSnap && (
|
|
443
|
+
{/* Snapshot layer — single image */}
|
|
444
|
+
{hasSnap && (
|
|
403
445
|
<img
|
|
404
|
-
src={
|
|
446
|
+
src={snapshot}
|
|
405
447
|
className={styles.snapshotImage}
|
|
406
|
-
style={(!isDark && hasLightSnap) ? { visibility: 'hidden' } : undefined}
|
|
407
448
|
alt={`${displayName} snapshot`}
|
|
408
449
|
draggable={false}
|
|
409
|
-
onError={() => setBrokenSnaps(prev => ({ ...prev, [
|
|
450
|
+
onError={() => setBrokenSnaps(prev => ({ ...prev, [snapshot]: true }))}
|
|
410
451
|
/>
|
|
411
452
|
)}
|
|
412
453
|
|
|
@@ -416,14 +457,17 @@ export default forwardRef(function StoryWidget({ id: widgetId, props, onUpdate,
|
|
|
416
457
|
ref={iframeRef}
|
|
417
458
|
src={iframeSrc}
|
|
418
459
|
className={styles.iframe}
|
|
419
|
-
style={
|
|
460
|
+
style={{
|
|
461
|
+
...(iframeLoaded ? undefined : { opacity: 0 }),
|
|
462
|
+
transition: 'opacity 150ms ease',
|
|
463
|
+
}}
|
|
420
464
|
onLoad={() => setIframeLoaded(true)}
|
|
421
465
|
title={displayName}
|
|
422
466
|
/>
|
|
423
467
|
)}
|
|
424
468
|
|
|
425
|
-
{/* Placeholder — only when no
|
|
426
|
-
{!
|
|
469
|
+
{/* Placeholder — only when no snapshot and no iframe */}
|
|
470
|
+
{!hasSnap && !showIframe && (
|
|
427
471
|
<div className={styles.placeholder}>
|
|
428
472
|
<ComponentIcon size={36} />
|
|
429
473
|
<span className={styles.placeholderLabel}>{displayName}</span>
|
|
@@ -447,9 +491,9 @@ export default forwardRef(function StoryWidget({ id: widgetId, props, onUpdate,
|
|
|
447
491
|
enterInteractive()
|
|
448
492
|
}
|
|
449
493
|
}}
|
|
450
|
-
aria-label=
|
|
494
|
+
aria-label={hasSnap ? 'Click to interact with story component' : 'Click to open story component'}
|
|
451
495
|
>
|
|
452
|
-
<span className={overlayStyles.interactHint}>Click to interact</span>
|
|
496
|
+
<span className={overlayStyles.interactHint}>{hasSnap ? 'Click to interact' : 'Click to open'}</span>
|
|
453
497
|
</div>
|
|
454
498
|
)}
|
|
455
499
|
</>
|