@dfosco/storyboard-react 4.0.0-beta.2 → 4.0.0-beta.20

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.
Files changed (44) hide show
  1. package/package.json +7 -4
  2. package/src/canvas/CanvasControls.jsx +51 -2
  3. package/src/canvas/CanvasControls.module.css +31 -0
  4. package/src/canvas/CanvasPage.dragdrop.test.jsx +346 -0
  5. package/src/canvas/CanvasPage.jsx +512 -235
  6. package/src/canvas/CanvasPage.module.css +9 -47
  7. package/src/canvas/ComponentErrorBoundary.jsx +50 -0
  8. package/src/canvas/PageSelector.jsx +102 -0
  9. package/src/canvas/PageSelector.module.css +93 -0
  10. package/src/canvas/PageSelector.test.jsx +104 -0
  11. package/src/canvas/canvasApi.js +4 -0
  12. package/src/canvas/canvasReloadGuard.js +37 -0
  13. package/src/canvas/canvasReloadGuard.test.js +27 -0
  14. package/src/canvas/componentIsolate.jsx +135 -0
  15. package/src/canvas/useCanvas.js +6 -2
  16. package/src/canvas/widgets/ComponentWidget.jsx +67 -9
  17. package/src/canvas/widgets/ComponentWidget.module.css +9 -6
  18. package/src/canvas/widgets/FigmaEmbed.jsx +26 -4
  19. package/src/canvas/widgets/FigmaEmbed.module.css +0 -7
  20. package/src/canvas/widgets/MarkdownBlock.jsx +94 -21
  21. package/src/canvas/widgets/MarkdownBlock.module.css +110 -2
  22. package/src/canvas/widgets/MarkdownBlock.test.jsx +39 -0
  23. package/src/canvas/widgets/PrototypeEmbed.jsx +196 -40
  24. package/src/canvas/widgets/PrototypeEmbed.module.css +30 -3
  25. package/src/canvas/widgets/StickyNote.module.css +5 -0
  26. package/src/canvas/widgets/StickyNote.test.jsx +9 -9
  27. package/src/canvas/widgets/StoryWidget.jsx +471 -0
  28. package/src/canvas/widgets/StoryWidget.module.css +200 -0
  29. package/src/canvas/widgets/WidgetChrome.jsx +54 -18
  30. package/src/canvas/widgets/WidgetChrome.module.css +4 -7
  31. package/src/canvas/widgets/WidgetWrapper.module.css +2 -0
  32. package/src/canvas/widgets/embedInteraction.test.jsx +155 -0
  33. package/src/canvas/widgets/embedOverlay.module.css +35 -0
  34. package/src/canvas/widgets/index.js +2 -0
  35. package/src/canvas/widgets/pasteRules.js +295 -0
  36. package/src/canvas/widgets/pasteRules.test.js +474 -0
  37. package/src/canvas/widgets/widgetConfig.js +16 -5
  38. package/src/canvas/widgets/widgetConfig.test.js +31 -9
  39. package/src/context.jsx +138 -13
  40. package/src/hooks/useSceneData.js +4 -2
  41. package/src/story/StoryPage.jsx +152 -0
  42. package/src/story/StoryPage.module.css +73 -0
  43. package/src/vite/data-plugin.js +441 -58
  44. package/src/vite/data-plugin.test.js +405 -5
@@ -4,7 +4,9 @@ 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'
9
+ import overlayStyles from './embedOverlay.module.css'
8
10
 
9
11
  function formatName(name) {
10
12
  return name
@@ -29,25 +31,29 @@ function resolveCanvasThemeFromStorage() {
29
31
  return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'
30
32
  }
31
33
 
32
- export default forwardRef(function PrototypeEmbed({ props, onUpdate, resizable }, ref) {
34
+ export default forwardRef(function PrototypeEmbed({ id: widgetId, props, onUpdate, resizable }, ref) {
33
35
  const src = readProp(props, 'src', prototypeEmbedSchema)
34
36
  const width = readProp(props, 'width', prototypeEmbedSchema)
35
37
  const height = readProp(props, 'height', prototypeEmbedSchema)
36
38
  const zoom = readProp(props, 'zoom', prototypeEmbedSchema)
37
39
  const label = readProp(props, 'label', prototypeEmbedSchema) || src
38
40
 
41
+ // Snapshot props for lazy loading
42
+ const snapshotLight = props?.snapshotLight || null
43
+ const snapshotDark = props?.snapshotDark || null
44
+
39
45
  const basePath = (import.meta.env.BASE_URL || '/').replace(/\/$/, '')
40
46
  const baseSegment = basePath.replace(/^\//, '')
41
47
  const rawSrc = useMemo(() => {
42
48
  if (!src) return ''
43
49
  if (/^https?:\/\//.test(src)) return src
44
- // Strip stale branch prefixes from stored src (e.g. /branch--old-feat/Page)
45
50
  const cleaned = src.replace(/^\/branch--[^/]+/, '')
46
51
  if (baseSegment && cleaned.startsWith(basePath)) return cleaned
47
52
  if (baseSegment && cleaned.startsWith(baseSegment)) return `/${cleaned}`
48
53
  return `${basePath}${cleaned}`
49
54
  }, [src, basePath, baseSegment])
50
55
 
56
+ const isExternal = /^https?:\/\//.test(rawSrc)
51
57
  const scale = zoom / 100
52
58
 
53
59
  const [editing, setEditing] = useState(false)
@@ -55,6 +61,28 @@ export default forwardRef(function PrototypeEmbed({ props, onUpdate, resizable }
55
61
  const [expanded, setExpanded] = useState(false)
56
62
  const [filter, setFilter] = useState('')
57
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
+
58
86
  const inputRef = useRef(null)
59
87
  const filterRef = useRef(null)
60
88
  const embedRef = useRef(null)
@@ -232,20 +260,100 @@ export default forwardRef(function PrototypeEmbed({ props, onUpdate, resizable }
232
260
  }
233
261
  }, [expanded])
234
262
 
235
- // Listen for navigation events from the embedded prototype iframe
263
+ // Listen for messages from the embedded prototype iframe
236
264
  useEffect(() => {
237
265
  function handleMessage(e) {
238
- if (e.source !== iframeRef.current?.contentWindow) return
239
- if (e.data?.type !== 'storyboard:embed:navigate') return
240
- const newSrc = e.data.src
241
- if (newSrc && newSrc !== src) {
242
- const originalSrc = readProp(props, 'originalSrc', prototypeEmbedSchema)
243
- onUpdate?.({ src: newSrc, originalSrc: originalSrc || src })
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()
244
293
  }
245
294
  }
246
295
  window.addEventListener('message', handleMessage)
247
296
  return () => window.removeEventListener('message', handleMessage)
248
- }, [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])
249
357
 
250
358
  const chromeVars = useMemo(() => getEmbedChromeVars(canvasTheme), [canvasTheme])
251
359
 
@@ -260,15 +368,9 @@ export default forwardRef(function PrototypeEmbed({ props, onUpdate, resizable }
260
368
  setExpanded(true)
261
369
  } else if (actionId === 'open-external') {
262
370
  if (rawSrc) window.open(rawSrc, '_blank', 'noopener')
263
- } else if (actionId === 'zoom-in') {
264
- const step = zoom < 75 ? 5 : 25
265
- onUpdate?.({ zoom: Math.min(200, zoom + step) })
266
- } else if (actionId === 'zoom-out') {
267
- const step = zoom <= 75 ? 5 : 25
268
- onUpdate?.({ zoom: Math.max(25, zoom - step) })
269
371
  }
270
372
  },
271
- }), [rawSrc, zoom, onUpdate])
373
+ }), [rawSrc])
272
374
 
273
375
  function handlePickRoute(route) {
274
376
  onUpdate?.({ src: route })
@@ -380,30 +482,78 @@ export default forwardRef(function PrototypeEmbed({ props, onUpdate, resizable }
380
482
  </div>
381
483
  ) : iframeSrc ? (
382
484
  <>
383
- <div
384
- ref={inlineContainerRef}
385
- className={styles.iframeContainer}
386
- style={expanded ? { visibility: 'hidden' } : undefined}
387
- >
388
- <iframe
389
- ref={iframeRef}
390
- src={iframeSrc}
391
- className={styles.iframe}
392
- style={{
393
- width: width / scale,
394
- height: height / scale,
395
- transform: `scale(${scale})`,
396
- transformOrigin: '0 0',
397
- }}
398
- title={label || 'Prototype embed'}
399
- sandbox="allow-same-origin allow-scripts allow-forms allow-popups"
400
- />
401
- </div>
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
+
402
530
  {!interactive && !expanded && (
403
531
  <div
404
- className={styles.dragOverlay}
405
- onDoubleClick={enterInteractive}
406
- />
532
+ className={overlayStyles.interactOverlay}
533
+ onPointerEnter={() => {
534
+ if (!preloadIframe) setPreloadIframe(true)
535
+ }}
536
+ onClick={(e) => {
537
+ if (e.shiftKey || e.metaKey || e.ctrlKey || e.altKey) return
538
+ setShowIframe(true)
539
+ setPreloadIframe(true)
540
+ enterInteractive()
541
+ }}
542
+ role="button"
543
+ tabIndex={0}
544
+ onKeyDown={(e) => {
545
+ if (e.key === 'Enter' || e.key === ' ') {
546
+ e.preventDefault()
547
+ e.stopPropagation()
548
+ setShowIframe(true)
549
+ setPreloadIframe(true)
550
+ enterInteractive()
551
+ }
552
+ }}
553
+ aria-label="Click to interact with prototype"
554
+ >
555
+ <span className={overlayStyles.interactHint}>Click to interact</span>
556
+ </div>
407
557
  )}
408
558
  </>
409
559
  ) : (
@@ -436,6 +586,7 @@ export default forwardRef(function PrototypeEmbed({ props, onUpdate, resizable }
436
586
  function onUp() {
437
587
  document.removeEventListener('mousemove', onMove)
438
588
  document.removeEventListener('mouseup', onUp)
589
+ triggerResizeCapture()
439
590
  }
440
591
  document.addEventListener('mousemove', onMove)
441
592
  document.addEventListener('mouseup', onUp)
@@ -450,8 +601,13 @@ export default forwardRef(function PrototypeEmbed({ props, onUpdate, resizable }
450
601
  style={expanded ? undefined : { display: 'none' }}
451
602
  onClick={() => setExpanded(false)}
452
603
  onPointerDown={(e) => e.stopPropagation()}
453
- onKeyDown={(e) => e.stopPropagation()}
604
+ onKeyDown={(e) => {
605
+ e.stopPropagation()
606
+ if (e.key === 'Escape') setExpanded(false)
607
+ }}
454
608
  onWheel={(e) => e.stopPropagation()}
609
+ tabIndex={-1}
610
+ ref={(el) => { if (el && expanded) el.focus() }}
455
611
  >
456
612
  <div
457
613
  ref={modalContainerRef}
@@ -18,11 +18,38 @@
18
18
  display: block;
19
19
  }
20
20
 
21
- .dragOverlay {
21
+ .snapshotImage {
22
+ display: block;
23
+ object-fit: cover;
24
+ object-position: top left;
25
+ }
26
+
27
+ .snapshotSpinner {
22
28
  position: absolute;
23
29
  inset: 0;
24
- z-index: 1;
25
- cursor: grab;
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; }
26
53
  }
27
54
 
28
55
  .empty {
@@ -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;
@@ -13,16 +13,16 @@ describe('stickyNoteSchema', () => {
13
13
  )
14
14
  })
15
15
 
16
- it('does not include default values for width/height so new widgets size naturally', () => {
16
+ it('includes default values for width/height from config', () => {
17
17
  const defaults = getDefaults(stickyNoteSchema)
18
- expect(defaults).not.toHaveProperty('width')
19
- expect(defaults).not.toHaveProperty('height')
18
+ expect(defaults).toHaveProperty('width', 270)
19
+ expect(defaults).toHaveProperty('height', 170)
20
20
  })
21
21
 
22
- it('returns null when width/height are not saved in props', () => {
22
+ it('returns default value when width/height are not saved in props', () => {
23
23
  const props = { text: 'hello', color: 'yellow' }
24
- expect(readProp(props, 'width', stickyNoteSchema)).toBeNull()
25
- expect(readProp(props, 'height', stickyNoteSchema)).toBeNull()
24
+ expect(readProp(props, 'width', stickyNoteSchema)).toBe(270)
25
+ expect(readProp(props, 'height', stickyNoteSchema)).toBe(170)
26
26
  })
27
27
 
28
28
  it('returns saved width/height when present in props', () => {
@@ -33,11 +33,11 @@ describe('stickyNoteSchema', () => {
33
33
  })
34
34
 
35
35
  describe('StickyNote', () => {
36
- it('renders without explicit dimensions when width/height are not saved', () => {
36
+ it('applies default dimensions as inline styles when not saved in props', () => {
37
37
  const { container } = render(<StickyNote props={{ text: 'Hi' }} onUpdate={vi.fn()} />)
38
38
  const sticky = container.querySelector('article')
39
- expect(sticky.style.width).toBe('')
40
- expect(sticky.style.height).toBe('')
39
+ expect(sticky.style.width).toBe('270px')
40
+ expect(sticky.style.height).toBe('170px')
41
41
  })
42
42
 
43
43
  it('applies saved dimensions as inline styles', () => {