@dfosco/storyboard-react 4.0.0-beta.13 → 4.0.0-beta.15
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/CanvasControls.jsx +51 -2
- package/src/canvas/CanvasControls.module.css +31 -0
- package/src/canvas/CanvasPage.jsx +77 -109
- package/src/canvas/CanvasPage.module.css +3 -47
- package/src/canvas/PageSelector.jsx +102 -0
- package/src/canvas/PageSelector.module.css +93 -0
- package/src/canvas/PageSelector.test.jsx +104 -0
- package/src/canvas/componentIsolate.jsx +3 -3
- package/src/canvas/widgets/FigmaEmbed.jsx +6 -1
- package/src/canvas/widgets/MarkdownBlock.jsx +84 -4
- package/src/canvas/widgets/MarkdownBlock.module.css +30 -4
- package/src/canvas/widgets/PrototypeEmbed.jsx +177 -38
- package/src/canvas/widgets/PrototypeEmbed.module.css +34 -0
- package/src/canvas/widgets/StickyNote.module.css +5 -0
- package/src/canvas/widgets/StoryWidget.jsx +438 -0
- package/src/canvas/widgets/StoryWidget.module.css +200 -0
- package/src/canvas/widgets/WidgetChrome.jsx +30 -3
- package/src/canvas/widgets/index.js +2 -0
- package/src/canvas/widgets/pasteRules.js +295 -0
- package/src/canvas/widgets/pasteRules.test.js +474 -0
- package/src/canvas/widgets/widgetConfig.js +1 -1
- package/src/canvas/widgets/widgetConfig.test.js +4 -1
- package/src/context.jsx +138 -13
- package/src/story/StoryPage.jsx +152 -0
- package/src/story/StoryPage.module.css +73 -0
- package/src/vite/data-plugin.js +234 -27
- package/src/vite/data-plugin.test.js +179 -4
|
@@ -4,6 +4,7 @@ import { buildPrototypeIndex } from '@dfosco/storyboard-core'
|
|
|
4
4
|
import WidgetWrapper from './WidgetWrapper.jsx'
|
|
5
5
|
import { readProp, prototypeEmbedSchema } from './widgetProps.js'
|
|
6
6
|
import { getEmbedChromeVars } from './embedTheme.js'
|
|
7
|
+
import { uploadImage } from '../canvasApi.js'
|
|
7
8
|
import styles from './PrototypeEmbed.module.css'
|
|
8
9
|
import overlayStyles from './embedOverlay.module.css'
|
|
9
10
|
|
|
@@ -30,25 +31,29 @@ function resolveCanvasThemeFromStorage() {
|
|
|
30
31
|
return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'
|
|
31
32
|
}
|
|
32
33
|
|
|
33
|
-
export default forwardRef(function PrototypeEmbed({ props, onUpdate, resizable }, ref) {
|
|
34
|
+
export default forwardRef(function PrototypeEmbed({ id: widgetId, props, onUpdate, resizable }, ref) {
|
|
34
35
|
const src = readProp(props, 'src', prototypeEmbedSchema)
|
|
35
36
|
const width = readProp(props, 'width', prototypeEmbedSchema)
|
|
36
37
|
const height = readProp(props, 'height', prototypeEmbedSchema)
|
|
37
38
|
const zoom = readProp(props, 'zoom', prototypeEmbedSchema)
|
|
38
39
|
const label = readProp(props, 'label', prototypeEmbedSchema) || src
|
|
39
40
|
|
|
41
|
+
// Snapshot props for lazy loading
|
|
42
|
+
const snapshotLight = props?.snapshotLight || null
|
|
43
|
+
const snapshotDark = props?.snapshotDark || null
|
|
44
|
+
|
|
40
45
|
const basePath = (import.meta.env.BASE_URL || '/').replace(/\/$/, '')
|
|
41
46
|
const baseSegment = basePath.replace(/^\//, '')
|
|
42
47
|
const rawSrc = useMemo(() => {
|
|
43
48
|
if (!src) return ''
|
|
44
49
|
if (/^https?:\/\//.test(src)) return src
|
|
45
|
-
// Strip stale branch prefixes from stored src (e.g. /branch--old-feat/Page)
|
|
46
50
|
const cleaned = src.replace(/^\/branch--[^/]+/, '')
|
|
47
51
|
if (baseSegment && cleaned.startsWith(basePath)) return cleaned
|
|
48
52
|
if (baseSegment && cleaned.startsWith(baseSegment)) return `/${cleaned}`
|
|
49
53
|
return `${basePath}${cleaned}`
|
|
50
54
|
}, [src, basePath, baseSegment])
|
|
51
55
|
|
|
56
|
+
const isExternal = /^https?:\/\//.test(rawSrc)
|
|
52
57
|
const scale = zoom / 100
|
|
53
58
|
|
|
54
59
|
const [editing, setEditing] = useState(false)
|
|
@@ -56,6 +61,28 @@ export default forwardRef(function PrototypeEmbed({ props, onUpdate, resizable }
|
|
|
56
61
|
const [expanded, setExpanded] = useState(false)
|
|
57
62
|
const [filter, setFilter] = useState('')
|
|
58
63
|
const [canvasTheme, setCanvasTheme] = useState(() => resolveCanvasThemeFromStorage())
|
|
64
|
+
|
|
65
|
+
// Lazy loading state — only use snapshots that match this widget's ID
|
|
66
|
+
const snapshotMatchesWidget = (url) => url && widgetId && url.includes(widgetId)
|
|
67
|
+
const validSnapshotLight = snapshotMatchesWidget(snapshotLight) ? snapshotLight : null
|
|
68
|
+
const validSnapshotDark = snapshotMatchesWidget(snapshotDark) ? snapshotDark : null
|
|
69
|
+
const currentSnapshot = canvasTheme?.startsWith('dark') ? validSnapshotDark : validSnapshotLight
|
|
70
|
+
const hasSnapshot = !!currentSnapshot
|
|
71
|
+
const [preloadIframe, setPreloadIframe] = useState(!hasSnapshot || isExternal)
|
|
72
|
+
const [iframeLoaded, setIframeLoaded] = useState(false)
|
|
73
|
+
const [showIframe, setShowIframe] = useState(!hasSnapshot || isExternal)
|
|
74
|
+
const [showSpinner, setShowSpinner] = useState(false)
|
|
75
|
+
const capturingRef = useRef(false)
|
|
76
|
+
|
|
77
|
+
// Show spinner only after 500ms of loading
|
|
78
|
+
useEffect(() => {
|
|
79
|
+
if (showIframe && !iframeLoaded && hasSnapshot) {
|
|
80
|
+
const timer = setTimeout(() => setShowSpinner(true), 500)
|
|
81
|
+
return () => clearTimeout(timer)
|
|
82
|
+
}
|
|
83
|
+
setShowSpinner(false)
|
|
84
|
+
}, [showIframe, iframeLoaded, hasSnapshot])
|
|
85
|
+
|
|
59
86
|
const inputRef = useRef(null)
|
|
60
87
|
const filterRef = useRef(null)
|
|
61
88
|
const embedRef = useRef(null)
|
|
@@ -233,20 +260,100 @@ export default forwardRef(function PrototypeEmbed({ props, onUpdate, resizable }
|
|
|
233
260
|
}
|
|
234
261
|
}, [expanded])
|
|
235
262
|
|
|
236
|
-
// Listen for
|
|
263
|
+
// Listen for messages from the embedded prototype iframe
|
|
237
264
|
useEffect(() => {
|
|
238
265
|
function handleMessage(e) {
|
|
239
|
-
if (
|
|
240
|
-
if (e.
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
266
|
+
if (!iframeRef.current?.contentWindow) return
|
|
267
|
+
if (e.source !== iframeRef.current.contentWindow) return
|
|
268
|
+
|
|
269
|
+
// Navigation events
|
|
270
|
+
if (e.data?.type === 'storyboard:embed:navigate') {
|
|
271
|
+
const newSrc = e.data.src
|
|
272
|
+
if (newSrc && newSrc !== src) {
|
|
273
|
+
const originalSrc = readProp(props, 'originalSrc', prototypeEmbedSchema)
|
|
274
|
+
onUpdate?.({ src: newSrc, originalSrc: originalSrc || src })
|
|
275
|
+
}
|
|
276
|
+
return
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
// Snapshot capture responses
|
|
280
|
+
if (e.data?.type === 'storyboard:embed:snapshot') {
|
|
281
|
+
if (e.data.error) {
|
|
282
|
+
console.warn('[canvas] Snapshot capture failed:', e.data.error)
|
|
283
|
+
return
|
|
284
|
+
}
|
|
285
|
+
handleSnapshotResult(e.data.requestId, e.data.dataUrl)
|
|
286
|
+
return
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
// Snapshot-ready signal — iframe content has fully rendered
|
|
290
|
+
if (e.data?.type === 'storyboard:embed:snapshot-ready') {
|
|
291
|
+
setIframeLoaded(true)
|
|
292
|
+
if (onUpdate && !isExternal) requestSnapshotCapture()
|
|
245
293
|
}
|
|
246
294
|
}
|
|
247
295
|
window.addEventListener('message', handleMessage)
|
|
248
296
|
return () => window.removeEventListener('message', handleMessage)
|
|
249
|
-
}, [src, props, onUpdate])
|
|
297
|
+
}, [src, props, onUpdate, isExternal])
|
|
298
|
+
|
|
299
|
+
// Request a snapshot capture from the iframe
|
|
300
|
+
const requestSnapshotCapture = useCallback(() => {
|
|
301
|
+
if (!iframeRef.current?.contentWindow || capturingRef.current || isExternal) return
|
|
302
|
+
capturingRef.current = true
|
|
303
|
+
const requestId = `snap-${Date.now()}`
|
|
304
|
+
iframeRef.current.contentWindow.postMessage({
|
|
305
|
+
type: 'storyboard:embed:capture',
|
|
306
|
+
requestId,
|
|
307
|
+
}, '*')
|
|
308
|
+
}, [isExternal])
|
|
309
|
+
|
|
310
|
+
// Handle a completed snapshot — upload and persist as widget prop
|
|
311
|
+
const handleSnapshotResult = useCallback(async (requestId, dataUrl) => {
|
|
312
|
+
if (!dataUrl || !onUpdate || !widgetId) return
|
|
313
|
+
capturingRef.current = false
|
|
314
|
+
try {
|
|
315
|
+
const result = await uploadImage(dataUrl, `snapshot-${widgetId}`)
|
|
316
|
+
if (!result?.success || !result?.filename) return
|
|
317
|
+
const imageUrl = `/_storyboard/canvas/images/${result.filename}`
|
|
318
|
+
const themeKey = canvasTheme?.startsWith('dark') ? 'snapshotDark' : 'snapshotLight'
|
|
319
|
+
onUpdate?.({ [themeKey]: imageUrl })
|
|
320
|
+
} catch (err) {
|
|
321
|
+
console.warn('[canvas] Failed to upload snapshot:', err)
|
|
322
|
+
}
|
|
323
|
+
}, [onUpdate, canvasTheme, widgetId])
|
|
324
|
+
|
|
325
|
+
// Re-capture snapshots after resize (debounced)
|
|
326
|
+
const resizeCaptureTimer = useRef(null)
|
|
327
|
+
const triggerResizeCapture = useCallback(() => {
|
|
328
|
+
if (!onUpdate || isExternal) return
|
|
329
|
+
clearTimeout(resizeCaptureTimer.current)
|
|
330
|
+
resizeCaptureTimer.current = setTimeout(() => {
|
|
331
|
+
requestSnapshotCapture()
|
|
332
|
+
}, 2000)
|
|
333
|
+
}, [requestSnapshotCapture, isExternal, onUpdate])
|
|
334
|
+
|
|
335
|
+
// Re-capture when src changes (new prototype selected)
|
|
336
|
+
const prevSrcRef = useRef(src)
|
|
337
|
+
useEffect(() => {
|
|
338
|
+
if (src && src !== prevSrcRef.current && onUpdate && !isExternal && showIframe) {
|
|
339
|
+
prevSrcRef.current = src
|
|
340
|
+
// Wait for the new page to render
|
|
341
|
+
const timer = setTimeout(() => requestSnapshotCapture(), 4000)
|
|
342
|
+
return () => clearTimeout(timer)
|
|
343
|
+
}
|
|
344
|
+
prevSrcRef.current = src
|
|
345
|
+
}, [src, onUpdate, isExternal, showIframe, requestSnapshotCapture])
|
|
346
|
+
|
|
347
|
+
// Re-capture for the alternate theme variant when theme changes
|
|
348
|
+
const prevThemeRef = useRef(canvasTheme)
|
|
349
|
+
useEffect(() => {
|
|
350
|
+
if (canvasTheme !== prevThemeRef.current && onUpdate && !isExternal && showIframe) {
|
|
351
|
+
prevThemeRef.current = canvasTheme
|
|
352
|
+
const timer = setTimeout(() => requestSnapshotCapture(), 3000)
|
|
353
|
+
return () => clearTimeout(timer)
|
|
354
|
+
}
|
|
355
|
+
prevThemeRef.current = canvasTheme
|
|
356
|
+
}, [canvasTheme, onUpdate, isExternal, showIframe, requestSnapshotCapture])
|
|
250
357
|
|
|
251
358
|
const chromeVars = useMemo(() => getEmbedChromeVars(canvasTheme), [canvasTheme])
|
|
252
359
|
|
|
@@ -261,15 +368,9 @@ export default forwardRef(function PrototypeEmbed({ props, onUpdate, resizable }
|
|
|
261
368
|
setExpanded(true)
|
|
262
369
|
} else if (actionId === 'open-external') {
|
|
263
370
|
if (rawSrc) window.open(rawSrc, '_blank', 'noopener')
|
|
264
|
-
} else if (actionId === 'zoom-in') {
|
|
265
|
-
const step = zoom < 75 ? 5 : 25
|
|
266
|
-
onUpdate?.({ zoom: Math.min(200, zoom + step) })
|
|
267
|
-
} else if (actionId === 'zoom-out') {
|
|
268
|
-
const step = zoom <= 75 ? 5 : 25
|
|
269
|
-
onUpdate?.({ zoom: Math.max(25, zoom - step) })
|
|
270
371
|
}
|
|
271
372
|
},
|
|
272
|
-
}), [rawSrc
|
|
373
|
+
}), [rawSrc])
|
|
273
374
|
|
|
274
375
|
function handlePickRoute(route) {
|
|
275
376
|
onUpdate?.({ src: route })
|
|
@@ -381,31 +482,61 @@ export default forwardRef(function PrototypeEmbed({ props, onUpdate, resizable }
|
|
|
381
482
|
</div>
|
|
382
483
|
) : iframeSrc ? (
|
|
383
484
|
<>
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
className={styles.iframeContainer}
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
}
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
485
|
+
{/* Snapshot image — shown until iframe is fully loaded */}
|
|
486
|
+
{hasSnapshot && !(showIframe && iframeLoaded) && (
|
|
487
|
+
<div className={styles.iframeContainer}>
|
|
488
|
+
<img
|
|
489
|
+
src={basePath + currentSnapshot}
|
|
490
|
+
alt={label || 'Prototype preview'}
|
|
491
|
+
className={styles.snapshotImage}
|
|
492
|
+
style={{ width, height }}
|
|
493
|
+
draggable={false}
|
|
494
|
+
/>
|
|
495
|
+
{showIframe && !iframeLoaded && showSpinner && (
|
|
496
|
+
<div className={styles.snapshotSpinner}>
|
|
497
|
+
<div className={styles.spinner} />
|
|
498
|
+
</div>
|
|
499
|
+
)}
|
|
500
|
+
</div>
|
|
501
|
+
)}
|
|
502
|
+
|
|
503
|
+
{/* Iframe — preloaded on hover, revealed after load */}
|
|
504
|
+
{(preloadIframe || showIframe) && (
|
|
505
|
+
<div
|
|
506
|
+
ref={inlineContainerRef}
|
|
507
|
+
className={styles.iframeContainer}
|
|
508
|
+
style={
|
|
509
|
+
expanded ? { visibility: 'hidden' }
|
|
510
|
+
: (hasSnapshot && !(showIframe && iframeLoaded)) ? { position: 'absolute', top: 0, left: 0, opacity: 0, pointerEvents: 'none' }
|
|
511
|
+
: undefined
|
|
512
|
+
}
|
|
513
|
+
>
|
|
514
|
+
<iframe
|
|
515
|
+
ref={iframeRef}
|
|
516
|
+
src={iframeSrc}
|
|
517
|
+
className={styles.iframe}
|
|
518
|
+
style={{
|
|
519
|
+
width: width / scale,
|
|
520
|
+
height: height / scale,
|
|
521
|
+
transform: `scale(${scale})`,
|
|
522
|
+
transformOrigin: '0 0',
|
|
523
|
+
}}
|
|
524
|
+
title={label || 'Prototype embed'}
|
|
525
|
+
sandbox="allow-same-origin allow-scripts allow-forms allow-popups"
|
|
526
|
+
/>
|
|
527
|
+
</div>
|
|
528
|
+
)}
|
|
529
|
+
|
|
403
530
|
{!interactive && !expanded && (
|
|
404
531
|
<div
|
|
405
532
|
className={overlayStyles.interactOverlay}
|
|
533
|
+
onPointerEnter={() => {
|
|
534
|
+
if (!preloadIframe) setPreloadIframe(true)
|
|
535
|
+
}}
|
|
406
536
|
onClick={(e) => {
|
|
407
|
-
// Don't enter interactive mode for modifier clicks (shift/meta/ctrl for multi-select)
|
|
408
537
|
if (e.shiftKey || e.metaKey || e.ctrlKey || e.altKey) return
|
|
538
|
+
setShowIframe(true)
|
|
539
|
+
setPreloadIframe(true)
|
|
409
540
|
enterInteractive()
|
|
410
541
|
}}
|
|
411
542
|
role="button"
|
|
@@ -414,6 +545,8 @@ export default forwardRef(function PrototypeEmbed({ props, onUpdate, resizable }
|
|
|
414
545
|
if (e.key === 'Enter' || e.key === ' ') {
|
|
415
546
|
e.preventDefault()
|
|
416
547
|
e.stopPropagation()
|
|
548
|
+
setShowIframe(true)
|
|
549
|
+
setPreloadIframe(true)
|
|
417
550
|
enterInteractive()
|
|
418
551
|
}
|
|
419
552
|
}}
|
|
@@ -453,6 +586,7 @@ export default forwardRef(function PrototypeEmbed({ props, onUpdate, resizable }
|
|
|
453
586
|
function onUp() {
|
|
454
587
|
document.removeEventListener('mousemove', onMove)
|
|
455
588
|
document.removeEventListener('mouseup', onUp)
|
|
589
|
+
triggerResizeCapture()
|
|
456
590
|
}
|
|
457
591
|
document.addEventListener('mousemove', onMove)
|
|
458
592
|
document.addEventListener('mouseup', onUp)
|
|
@@ -467,8 +601,13 @@ export default forwardRef(function PrototypeEmbed({ props, onUpdate, resizable }
|
|
|
467
601
|
style={expanded ? undefined : { display: 'none' }}
|
|
468
602
|
onClick={() => setExpanded(false)}
|
|
469
603
|
onPointerDown={(e) => e.stopPropagation()}
|
|
470
|
-
onKeyDown={(e) =>
|
|
604
|
+
onKeyDown={(e) => {
|
|
605
|
+
e.stopPropagation()
|
|
606
|
+
if (e.key === 'Escape') setExpanded(false)
|
|
607
|
+
}}
|
|
471
608
|
onWheel={(e) => e.stopPropagation()}
|
|
609
|
+
tabIndex={-1}
|
|
610
|
+
ref={(el) => { if (el && expanded) el.focus() }}
|
|
472
611
|
>
|
|
473
612
|
<div
|
|
474
613
|
ref={modalContainerRef}
|
|
@@ -18,6 +18,40 @@
|
|
|
18
18
|
display: block;
|
|
19
19
|
}
|
|
20
20
|
|
|
21
|
+
.snapshotImage {
|
|
22
|
+
display: block;
|
|
23
|
+
object-fit: cover;
|
|
24
|
+
object-position: top left;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
.snapshotSpinner {
|
|
28
|
+
position: absolute;
|
|
29
|
+
inset: 0;
|
|
30
|
+
display: flex;
|
|
31
|
+
align-items: center;
|
|
32
|
+
justify-content: center;
|
|
33
|
+
background: rgba(0, 0, 0, 0.08);
|
|
34
|
+
animation: fadeIn 150ms ease;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
.spinner {
|
|
38
|
+
width: 24px;
|
|
39
|
+
height: 24px;
|
|
40
|
+
border: 2.5px solid var(--borderColor-default, #d0d7de);
|
|
41
|
+
border-top-color: var(--fgColor-accent, #0969da);
|
|
42
|
+
border-radius: 50%;
|
|
43
|
+
animation: spin 0.6s linear infinite;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
@keyframes spin {
|
|
47
|
+
to { transform: rotate(360deg); }
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
@keyframes fadeIn {
|
|
51
|
+
from { opacity: 0; }
|
|
52
|
+
to { opacity: 1; }
|
|
53
|
+
}
|
|
54
|
+
|
|
21
55
|
.empty {
|
|
22
56
|
display: flex;
|
|
23
57
|
align-items: center;
|
|
@@ -20,6 +20,11 @@
|
|
|
20
20
|
box-shadow: 2px 3px 10px rgba(0, 0, 0, 0.35);
|
|
21
21
|
}
|
|
22
22
|
|
|
23
|
+
/* Hide own border when parent widget slot is selected (avoid double focus ring) */
|
|
24
|
+
:global([data-widget-selected]) .sticky {
|
|
25
|
+
border-color: transparent;
|
|
26
|
+
}
|
|
27
|
+
|
|
23
28
|
.text {
|
|
24
29
|
padding: 16px 20px;
|
|
25
30
|
margin: 0;
|