@dfosco/storyboard-react 4.2.0-beta.4 → 4.2.1

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 (89) hide show
  1. package/package.json +10 -11
  2. package/src/AuthModal/AuthModal.jsx +6 -8
  3. package/src/BranchBar/BranchBar.jsx +20 -6
  4. package/src/BranchBar/BranchBar.module.css +13 -4
  5. package/src/BranchBar/useBranches.js +20 -6
  6. package/src/BranchBar/useBranches.test.js +68 -0
  7. package/src/CommandPalette/CommandPalette.jsx +480 -187
  8. package/src/CommandPalette/command-palette.css +142 -78
  9. package/src/Icon.jsx +157 -58
  10. package/src/Viewfinder.jsx +562 -207
  11. package/src/Viewfinder.module.css +434 -93
  12. package/src/Workspace.jsx +7 -0
  13. package/src/canvas/CanvasPage.bridge.test.jsx +14 -6
  14. package/src/canvas/CanvasPage.dragdrop.test.jsx +11 -7
  15. package/src/canvas/CanvasPage.jsx +739 -219
  16. package/src/canvas/CanvasPage.module.css +13 -15
  17. package/src/canvas/CanvasPage.multiselect.test.jsx +17 -6
  18. package/src/canvas/ConnectorLayer.jsx +121 -165
  19. package/src/canvas/ConnectorLayer.module.css +69 -0
  20. package/src/canvas/PageSelector.test.jsx +15 -6
  21. package/src/canvas/canvasApi.js +68 -2
  22. package/src/canvas/canvasReloadGuard.test.js +1 -1
  23. package/src/canvas/connectorGeometry.js +132 -0
  24. package/src/canvas/hotPoolDevLogs.js +25 -0
  25. package/src/canvas/useCanvas.js +1 -1
  26. package/src/canvas/useMarqueeSelect.js +30 -4
  27. package/src/canvas/widgets/CodePenEmbed.jsx +1 -0
  28. package/src/canvas/widgets/ComponentSetWidget.jsx +199 -0
  29. package/src/canvas/widgets/ComponentSetWidget.module.css +89 -0
  30. package/src/canvas/widgets/ComponentWidget.jsx +1 -0
  31. package/src/canvas/widgets/CropOverlay.jsx +219 -0
  32. package/src/canvas/widgets/CropOverlay.module.css +118 -0
  33. package/src/canvas/widgets/ExpandedPane.jsx +474 -0
  34. package/src/canvas/widgets/ExpandedPane.module.css +179 -0
  35. package/src/canvas/widgets/ExpandedPane.test.jsx +240 -0
  36. package/src/canvas/widgets/ExpandedPaneTopBar.jsx +111 -0
  37. package/src/canvas/widgets/ExpandedPaneTopBar.module.css +59 -0
  38. package/src/canvas/widgets/ExpandedPaneTopBar.test.jsx +45 -0
  39. package/src/canvas/widgets/FigmaEmbed.jsx +62 -47
  40. package/src/canvas/widgets/FigmaEmbed.module.css +61 -0
  41. package/src/canvas/widgets/ImageWidget.jsx +130 -9
  42. package/src/canvas/widgets/ImageWidget.module.css +30 -0
  43. package/src/canvas/widgets/LinkPreview.jsx +113 -5
  44. package/src/canvas/widgets/LinkPreview.module.css +127 -0
  45. package/src/canvas/widgets/MarkdownBlock.jsx +167 -17
  46. package/src/canvas/widgets/MarkdownBlock.module.css +148 -0
  47. package/src/canvas/widgets/PromptWidget.jsx +414 -0
  48. package/src/canvas/widgets/PromptWidget.module.css +273 -0
  49. package/src/canvas/widgets/PrototypeEmbed.jsx +77 -39
  50. package/src/canvas/widgets/PrototypeEmbed.module.css +117 -0
  51. package/src/canvas/widgets/PrototypeEmbed.test.jsx +2 -2
  52. package/src/canvas/widgets/ResizeHandle.jsx +17 -6
  53. package/src/canvas/widgets/StoryWidget.jsx +73 -15
  54. package/src/canvas/widgets/TerminalReadWidget.jsx +146 -0
  55. package/src/canvas/widgets/TerminalReadWidget.module.css +94 -0
  56. package/src/canvas/widgets/TerminalWidget.jsx +445 -67
  57. package/src/canvas/widgets/TerminalWidget.module.css +271 -8
  58. package/src/canvas/widgets/TilesWidget.jsx +300 -0
  59. package/src/canvas/widgets/TilesWidget.module.css +133 -0
  60. package/src/canvas/widgets/WidgetChrome.jsx +74 -153
  61. package/src/canvas/widgets/WidgetChrome.module.css +30 -1
  62. package/src/canvas/widgets/embedInteraction.test.jsx +24 -26
  63. package/src/canvas/widgets/expandUtils.js +560 -0
  64. package/src/canvas/widgets/expandUtils.test.js +155 -0
  65. package/src/canvas/widgets/index.js +9 -0
  66. package/src/canvas/widgets/snapshotDisplay.test.jsx +23 -71
  67. package/src/canvas/widgets/tilePool.js +23 -0
  68. package/src/canvas/widgets/tiles/diagonal-bl.png +0 -0
  69. package/src/canvas/widgets/tiles/diagonal-br.png +0 -0
  70. package/src/canvas/widgets/tiles/diagonal-tl.png +0 -0
  71. package/src/canvas/widgets/tiles/leaf.png +0 -0
  72. package/src/canvas/widgets/tiles/quarter-tl.png +0 -0
  73. package/src/canvas/widgets/tiles/quarter-tr.png +0 -0
  74. package/src/canvas/widgets/tiles/solid-a.png +0 -0
  75. package/src/canvas/widgets/tiles/solid-b.png +0 -0
  76. package/src/canvas/widgets/widgetConfig.js +55 -4
  77. package/src/canvas/widgets/widgetIcons.jsx +190 -0
  78. package/src/canvas/widgets/widgetProps.js +1 -0
  79. package/src/context.jsx +48 -20
  80. package/src/hooks/useConfig.js +14 -0
  81. package/src/hooks/usePrototypeReloadGuard.js +64 -0
  82. package/src/hooks/useSceneData.js +1 -0
  83. package/src/hooks/useThemeState.test.js +1 -1
  84. package/src/index.js +8 -2
  85. package/src/story/ComponentSetPage.jsx +186 -0
  86. package/src/story/ComponentSetPage.module.css +121 -0
  87. package/src/story/StoryPage.jsx +32 -2
  88. package/src/vite/data-plugin.js +407 -67
  89. package/src/vite/data-plugin.test.js +1 -1
@@ -1,11 +1,12 @@
1
1
  import { useState, useRef, useEffect, useCallback, useMemo, forwardRef, useImperativeHandle } from 'react'
2
- import { createPortal } from 'react-dom'
3
2
  import { buildPrototypeIndex } from '@dfosco/storyboard-core'
4
3
  import WidgetWrapper from './WidgetWrapper.jsx'
5
4
  import ResizeHandle from './ResizeHandle.jsx'
6
5
  import { readProp, prototypeEmbedSchema } from './widgetProps.js'
7
6
  import { getEmbedChromeVars } from './embedTheme.js'
8
7
  import { useIframeDevLogs } from './iframeDevLogs.js'
8
+ import { findAllConnectedSplitTargets, getSplitPaneLabel, buildPaneForWidget, buildSplitLayout } from './expandUtils.js'
9
+ import ExpandedPane from './ExpandedPane.jsx'
9
10
  import styles from './PrototypeEmbed.module.css'
10
11
  import overlayStyles from './embedOverlay.module.css'
11
12
 
@@ -60,11 +61,11 @@ export default forwardRef(function PrototypeEmbed({ id: widgetId, props, onUpdat
60
61
  }, [src, basePath, baseSegment])
61
62
 
62
63
  const scale = zoom / 100
63
- const isExternal = /^https?:\/\//.test(src || '')
64
64
 
65
65
  const [editing, setEditing] = useState(false)
66
66
  const [interactive, setInteractive] = useState(false)
67
- const [expanded, setExpanded] = useState(false)
67
+ const [expandMode, setExpandMode] = useState(null)
68
+ const expanded = expandMode !== null
68
69
  const [filter, setFilter] = useState('')
69
70
  const [canvasTheme, setCanvasTheme] = useState(() => resolveCanvasThemeFromStorage())
70
71
  const inputRef = useRef(null)
@@ -84,6 +85,8 @@ export default forwardRef(function PrototypeEmbed({ id: widgetId, props, onUpdat
84
85
  return `${base}${sep}_sb_embed&_sb_hide_branch_bar&_sb_theme_target=prototype&_sb_canvas_theme=${canvasTheme}${hash}`
85
86
  }, [rawSrc, canvasTheme])
86
87
 
88
+ const effectiveSrc = iframeSrc
89
+
87
90
  const prototypeIndex = useMemo(() => {
88
91
  try { return buildPrototypeIndex() }
89
92
  catch { return { folders: [], prototypes: [], globalFlows: [], sorted: { title: { prototypes: [], folders: [] } } } }
@@ -153,8 +156,8 @@ export default forwardRef(function PrototypeEmbed({ id: widgetId, props, onUpdat
153
156
 
154
157
  useIframeDevLogs({
155
158
  widget: 'PrototypeEmbed',
156
- loaded: Boolean(iframeSrc && interactive),
157
- src: iframeSrc,
159
+ loaded: Boolean(effectiveSrc && interactive),
160
+ src: effectiveSrc,
158
161
  })
159
162
 
160
163
  useEffect(() => {
@@ -189,19 +192,6 @@ export default forwardRef(function PrototypeEmbed({ id: widgetId, props, onUpdat
189
192
  return () => document.removeEventListener('storyboard:theme:changed', readToolbarTheme)
190
193
  }, [])
191
194
 
192
- // Close expanded modal on Escape
193
- useEffect(() => {
194
- if (!expanded) return
195
- function handleKeyDown(e) {
196
- if (e.key === 'Escape') {
197
- e.stopPropagation()
198
- setExpanded(false)
199
- }
200
- }
201
- document.addEventListener('keydown', handleKeyDown, true)
202
- return () => document.removeEventListener('keydown', handleKeyDown, true)
203
- }, [expanded])
204
-
205
195
  // Reparent iframe between inline and modal
206
196
  useEffect(() => {
207
197
  const iframe = iframeRef.current
@@ -212,8 +202,12 @@ export default forwardRef(function PrototypeEmbed({ id: widgetId, props, onUpdat
212
202
  iframe.className = styles.expandIframe
213
203
  iframe.removeAttribute('style')
214
204
  const target = modalContainerRef.current
215
- if (target.moveBefore) target.moveBefore(iframe, target.firstChild)
216
- else target.prepend(iframe)
205
+ try {
206
+ if (target.moveBefore) target.moveBefore(iframe, target.firstChild)
207
+ else target.prepend(iframe)
208
+ } catch {
209
+ target.prepend(iframe)
210
+ }
217
211
  } else if (!expanded && inlineContainerRef.current) {
218
212
  if (iframe._savedClassName !== undefined) {
219
213
  iframe.className = iframe._savedClassName
@@ -222,8 +216,12 @@ export default forwardRef(function PrototypeEmbed({ id: widgetId, props, onUpdat
222
216
  delete iframe._savedStyle
223
217
  }
224
218
  const target = inlineContainerRef.current
225
- if (target.moveBefore) target.moveBefore(iframe, null)
226
- else target.appendChild(iframe)
219
+ try {
220
+ if (target.moveBefore) target.moveBefore(iframe, null)
221
+ else target.appendChild(iframe)
222
+ } catch {
223
+ target.appendChild(iframe)
224
+ }
227
225
  }
228
226
  }, [expanded])
229
227
 
@@ -250,8 +248,10 @@ export default forwardRef(function PrototypeEmbed({ id: widgetId, props, onUpdat
250
248
  handleAction(actionId) {
251
249
  if (actionId === 'edit') {
252
250
  setEditing(true)
253
- } else if (actionId === 'expand') {
254
- setExpanded(true)
251
+ } else if (actionId === 'expand' || actionId === 'expand-single') {
252
+ setExpandMode('single')
253
+ } else if (actionId === 'split-screen') {
254
+ setExpandMode('split')
255
255
  } else if (actionId === 'open-external') {
256
256
  if (rawSrc) window.open(rawSrc, '_blank', 'noopener')
257
257
  } else if (actionId === 'zoom-in') {
@@ -362,7 +362,7 @@ export default forwardRef(function PrototypeEmbed({ id: widgetId, props, onUpdat
362
362
  >
363
363
  <iframe
364
364
  ref={iframeRef}
365
- src={iframeSrc}
365
+ src={effectiveSrc}
366
366
  className={styles.iframe}
367
367
  style={{
368
368
  width: width / scale,
@@ -372,6 +372,7 @@ export default forwardRef(function PrototypeEmbed({ id: widgetId, props, onUpdat
372
372
  }}
373
373
  title={`${prototypeTitle} prototype`}
374
374
  sandbox="allow-same-origin allow-scripts allow-forms allow-popups"
375
+ onLoad={(e) => e.target.blur()}
375
376
  />
376
377
  </div>
377
378
  {!interactive && !expanded && (
@@ -405,21 +406,58 @@ export default forwardRef(function PrototypeEmbed({ id: widgetId, props, onUpdat
405
406
  </div>
406
407
  {resizable && <ResizeHandle targetRef={embedRef} width={width} height={height} onResize={handleResize} />}
407
408
  </WidgetWrapper>
408
- {createPortal(
409
- <div
410
- className={styles.expandBackdrop}
411
- style={expanded ? undefined : { display: 'none' }}
412
- onClick={() => setExpanded(false)}
413
- onPointerDown={(e) => e.stopPropagation()}
414
- onKeyDown={(e) => e.stopPropagation()}
415
- onWheel={(e) => e.stopPropagation()}
416
- >
417
- <div ref={modalContainerRef} className={styles.expandContainer} onClick={(e) => e.stopPropagation()}>
418
- <button className={styles.expandClose} onClick={() => setExpanded(false)} aria-label="Close expanded view" autoFocus>✕</button>
419
- </div>
420
- </div>,
421
- document.body
409
+ {expanded && (
410
+ <PrototypeExpandPane
411
+ widgetId={widgetId}
412
+ modalContainerRef={modalContainerRef}
413
+ splitMode={expandMode === 'split'}
414
+ onClose={() => setExpandMode(null)}
415
+ />
422
416
  )}
423
417
  </>
424
418
  )
425
419
  })
420
+
421
+ /**
422
+ * Builds pane configs and renders ExpandedPane for an expanded prototype widget.
423
+ * The primary pane is an external pane that receives the iframe via reparenting.
424
+ */
425
+ function PrototypeExpandPane({ widgetId, modalContainerRef, splitMode, onClose }) {
426
+ const connectedWidgets = useMemo(
427
+ () => splitMode ? findAllConnectedSplitTargets(widgetId) : [],
428
+ [widgetId, splitMode],
429
+ )
430
+ const primaryWidget = useMemo(() => {
431
+ const bridge = window.__storyboardCanvasBridgeState
432
+ return bridge?.widgets?.find((w) => w.id === widgetId) || { id: widgetId, type: 'prototype', position: { x: 0, y: 0 }, props: {} }
433
+ }, [widgetId])
434
+
435
+ const buildPaneFn = useCallback((widget) => {
436
+ if (widget.id === widgetId) {
437
+ return {
438
+ id: widgetId,
439
+ label: getSplitPaneLabel(primaryWidget),
440
+ widgetType: 'prototype',
441
+ kind: 'external',
442
+ attach: (container) => {
443
+ modalContainerRef.current = container
444
+ return () => { modalContainerRef.current = null }
445
+ },
446
+ }
447
+ }
448
+ return buildPaneForWidget(widget)
449
+ }, [widgetId, primaryWidget, modalContainerRef])
450
+
451
+ const layout = useMemo(
452
+ () => buildSplitLayout(primaryWidget, connectedWidgets, buildPaneFn),
453
+ [primaryWidget, connectedWidgets, buildPaneFn],
454
+ )
455
+
456
+ return (
457
+ <ExpandedPane
458
+ initialLayout={layout}
459
+ variant={layout.flat().length <= 1 ? 'modal' : 'full'}
460
+ onClose={onClose}
461
+ />
462
+ )
463
+ }
@@ -460,3 +460,120 @@
460
460
  .expandClose:hover {
461
461
  background: rgba(0, 0, 0, 0.7);
462
462
  }
463
+
464
+ /* ── Split-screen layout ──────────────────────────────────────────── */
465
+
466
+ .expandContainerSplit {
467
+ flex: 1;
468
+ min-width: 0;
469
+ height: 100%;
470
+ position: relative;
471
+ overflow: hidden;
472
+ background: var(--bgColor-default, #ffffff);
473
+ }
474
+
475
+ .expandContainerSplit .expandIframe {
476
+ border: none;
477
+ display: block;
478
+ width: 100%;
479
+ height: 100%;
480
+ }
481
+
482
+ .expandSplitBody {
483
+ position: fixed;
484
+ inset: 0;
485
+ display: flex;
486
+ flex-direction: column;
487
+ overflow: hidden;
488
+ animation: expandScaleIn 0.2s ease;
489
+ }
490
+
491
+ .expandSplitPanes {
492
+ flex: 1;
493
+ min-height: 0;
494
+ display: flex;
495
+ }
496
+
497
+ .expandSplitLeft {
498
+ flex: 1;
499
+ min-width: 0;
500
+ height: 100%;
501
+ overflow: hidden;
502
+ border-right: 1px solid var(--borderColor-muted, #d8dee4);
503
+ }
504
+
505
+ .expandSplitRight {
506
+ flex: 1;
507
+ min-width: 0;
508
+ height: 100%;
509
+ overflow: hidden;
510
+ }
511
+
512
+ .expandSecondary {
513
+ width: 100%;
514
+ height: 100%;
515
+ overflow: auto;
516
+ background: var(--bgColor-default, #ffffff);
517
+ }
518
+
519
+ .expandSecondary iframe {
520
+ border: none;
521
+ width: 100%;
522
+ height: 100%;
523
+ display: block;
524
+ }
525
+
526
+ .expandTerminal {
527
+ width: 100%;
528
+ height: 100%;
529
+ background: #0d1117;
530
+ }
531
+
532
+ .expandMarkdownContent {
533
+ padding: 32px 40px;
534
+ font-size: 15px;
535
+ line-height: 1.7;
536
+ color: var(--fgColor-default, #1f2328);
537
+ max-width: 800px;
538
+ margin: 0 auto;
539
+ }
540
+
541
+ .expandMarkdownContent * { pointer-events: auto; }
542
+ .expandMarkdownContent a { color: var(--fgColor-accent, #0969da); text-decoration: none; }
543
+ .expandMarkdownContent a:hover { text-decoration: underline; }
544
+ .expandMarkdownContent img { max-width: 100%; height: auto; border-radius: 6px; margin: 8px 0; display: block; }
545
+
546
+ .expandGithubCard {
547
+ height: 100%;
548
+ display: flex;
549
+ flex-direction: column;
550
+ background: var(--bgColor-default, #ffffff);
551
+ }
552
+
553
+ .expandGithubHeader {
554
+ padding: 16px 24px;
555
+ font-size: 18px;
556
+ font-weight: 600;
557
+ border-bottom: 1px solid var(--borderColor-muted, #d8dee4);
558
+ }
559
+
560
+ .expandGithubBody {
561
+ flex: 1;
562
+ overflow: auto;
563
+ padding: 24px;
564
+ font-size: 15px;
565
+ line-height: 1.7;
566
+ }
567
+
568
+ .expandGithubBody * { pointer-events: auto; }
569
+ .expandGithubBody a { color: var(--fgColor-accent, #0969da); }
570
+
571
+ .expandLinkCard {
572
+ padding: 32px 40px;
573
+ }
574
+
575
+ .expandLinkTitle {
576
+ font-size: 18px;
577
+ font-weight: 600;
578
+ margin: 0 0 8px;
579
+ }
@@ -4,7 +4,7 @@ import { getEmbedChromeVars } from './embedTheme.js'
4
4
  describe('getEmbedChromeVars', () => {
5
5
  it('follows toolbar theme variants for embed edit chrome', () => {
6
6
  expect(getEmbedChromeVars('light')['--bgColor-default']).toBe('#ffffff')
7
- expect(getEmbedChromeVars('dark')['--bgColor-default']).toBe('#161b22')
8
- expect(getEmbedChromeVars('dark_dimmed')['--bgColor-default']).toBe('#22272e')
7
+ expect(getEmbedChromeVars('dark')['--bgColor-default']).toBe('#0d1117')
8
+ expect(getEmbedChromeVars('dark_dimmed')['--bgColor-default']).toBe('#212830')
9
9
  })
10
10
  })
@@ -13,9 +13,12 @@ import styles from './ResizeHandle.module.css'
13
13
  * @param {React.RefObject} props.targetRef - ref to the element being resized (reads offsetWidth/Height)
14
14
  * @param {number} [props.minWidth=180] - minimum allowed width
15
15
  * @param {number} [props.minHeight=60] - minimum allowed height
16
+ * @param {'both'|'vertical'|'horizontal'} [props.axis='both'] - constrain resize to a single axis
16
17
  * @param {Function} props.onResize - callback: (width, height) => void
18
+ * @param {Function} [props.onResizeStart] - called when drag begins
19
+ * @param {Function} [props.onResizeEnd] - called with final (width, height) on drag end
17
20
  */
18
- export default function ResizeHandle({ targetRef, minWidth = 180, minHeight = 60, onResize }) {
21
+ export default function ResizeHandle({ targetRef, minWidth = 180, minHeight = 60, axis = 'both', onResize, onResizeStart, onResizeEnd }) {
19
22
  const handleMouseDown = useCallback((e) => {
20
23
  e.stopPropagation()
21
24
  e.preventDefault()
@@ -27,29 +30,37 @@ export default function ResizeHandle({ targetRef, minWidth = 180, minHeight = 60
27
30
  const startY = e.clientY
28
31
  const startW = el.offsetWidth
29
32
  const startH = el.offsetHeight
33
+ let lastW = startW
34
+ let lastH = startH
35
+
36
+ onResizeStart?.()
30
37
 
31
38
  function onMove(ev) {
32
- const newW = Math.max(minWidth, startW + ev.clientX - startX)
33
- const newH = Math.max(minHeight, startH + ev.clientY - startY)
34
- onResize?.(newW, newH)
39
+ lastW = axis === 'vertical' ? startW : Math.max(minWidth, startW + ev.clientX - startX)
40
+ lastH = axis === 'horizontal' ? startH : Math.max(minHeight, startH + ev.clientY - startY)
41
+ onResize?.(lastW, lastH)
35
42
  }
36
43
 
37
44
  function onUp() {
38
45
  document.removeEventListener('mousemove', onMove)
39
46
  document.removeEventListener('mouseup', onUp)
47
+ onResizeEnd?.(lastW, lastH)
40
48
  }
41
49
 
42
50
  document.addEventListener('mousemove', onMove)
43
51
  document.addEventListener('mouseup', onUp)
44
- }, [targetRef, minWidth, minHeight, onResize])
52
+ }, [targetRef, minWidth, minHeight, axis, onResize, onResizeStart, onResizeEnd])
53
+
54
+ const cursor = axis === 'vertical' ? 'ns-resize' : axis === 'horizontal' ? 'ew-resize' : 'nwse-resize'
45
55
 
46
56
  return (
47
57
  <div
48
58
  className={styles.handle}
59
+ style={axis !== 'both' ? { cursor } : undefined}
49
60
  onMouseDown={handleMouseDown}
50
61
  onPointerDown={(e) => e.stopPropagation()}
51
62
  role="separator"
52
- aria-orientation="horizontal"
63
+ aria-orientation={axis === 'vertical' ? 'vertical' : 'horizontal'}
53
64
  aria-label="Resize"
54
65
  />
55
66
  )
@@ -11,21 +11,17 @@
11
11
  import { forwardRef, useImperativeHandle, useRef, useCallback, useState, useEffect, useMemo } from 'react'
12
12
  import { getStoryData } from '@dfosco/storyboard-core'
13
13
  import { createInspectorHighlighter } from '@dfosco/storyboard-core/inspector/highlighter'
14
+ import Icon from '../../Icon.jsx'
14
15
  import WidgetWrapper from './WidgetWrapper.jsx'
15
16
  import ResizeHandle from './ResizeHandle.jsx'
16
17
  import { useIframeDevLogs } from './iframeDevLogs.js'
18
+ import { findAllConnectedSplitTargets, getSplitPaneLabel, buildPaneForWidget, buildSplitLayout, buildSecondaryIframeUrl } from './expandUtils.js'
19
+ import ExpandedPane from './ExpandedPane.jsx'
17
20
  import styles from './StoryWidget.module.css'
18
21
  import overlayStyles from './embedOverlay.module.css'
19
22
 
20
23
  function ComponentIcon({ size = 36 }) {
21
- return (
22
- <svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" aria-hidden="true">
23
- <path d="M5.21173 15.1113L2.52473 12.4243C2.29041 12.1899 2.29041 11.8101 2.52473 11.5757L5.21173 8.88873C5.44605 8.65442 5.82595 8.65442 6.06026 8.88873L8.74727 11.5757C8.98158 11.8101 8.98158 12.1899 8.74727 12.4243L6.06026 15.1113C5.82595 15.3456 5.44605 15.3456 5.21173 15.1113Z" />
24
- <path d="M11.5757 21.475L8.88874 18.788C8.65443 18.5537 8.65443 18.1738 8.88874 17.9395L11.5757 15.2525C11.8101 15.0182 12.19 15.0182 12.4243 15.2525L15.1113 17.9395C15.3456 18.1738 15.3456 18.5537 15.1113 18.788L12.4243 21.475C12.19 21.7094 11.8101 21.7094 11.5757 21.475Z" />
25
- <path d="M17.9395 15.1113L15.2525 12.4243C15.0182 12.1899 15.0182 11.8101 15.2525 11.5757L17.9395 8.88873C18.1738 8.65442 18.5537 8.65442 18.788 8.88873L21.475 11.5757C21.7094 11.8101 21.7094 12.1899 21.475 12.4243L18.788 15.1113C18.5537 15.3456 18.1738 15.3456 17.9395 15.1113Z" />
26
- <path d="M11.5757 8.74727L8.88874 6.06026C8.65443 5.82595 8.65443 5.44605 8.88874 5.21173L11.5757 2.52473C11.8101 2.29041 12.19 2.29041 12.4243 2.52473L15.1113 5.21173C15.3456 5.44605 15.3456 5.82595 15.1113 6.06026L12.4243 8.74727C12.19 8.98158 11.8101 8.98158 11.5757 8.74727Z" />
27
- </svg>
28
- )
24
+ return <Icon name="iconoir/keyframe" size={size} />
29
25
  }
30
26
 
31
27
  function resolveStoryUrl(storyId, exportName) {
@@ -43,9 +39,10 @@ const _storySourcesCache = {}
43
39
 
44
40
  async function fetchStorySource(modulePath) {
45
41
  if (modulePath in _storySourcesCache) return _storySourcesCache[modulePath]
46
- const url = modulePath.startsWith('/') ? modulePath : `/${modulePath}`
47
- const res = await fetch(`${url}?raw`)
48
- if (!res.ok) throw new Error(`Failed to fetch ${url}`)
42
+ const base = (import.meta.env.BASE_URL || '/').replace(/\/$/, '')
43
+ const path = modulePath.startsWith('/') ? modulePath : `/${modulePath}`
44
+ const res = await fetch(`${base}${path}?raw`)
45
+ if (!res.ok) throw new Error(`Failed to fetch ${base}${path}`)
49
46
  const code = await res.text()
50
47
  _storySourcesCache[modulePath] = code
51
48
  return code
@@ -58,6 +55,8 @@ export default forwardRef(function StoryWidget({ id: widgetId, props, onUpdate,
58
55
  const height = props?.height
59
56
 
60
57
  const containerRef = useRef(null)
58
+ const [expandMode, setExpandMode] = useState(null)
59
+ const expanded = expandMode !== null
61
60
  const iframeRef = useRef(null)
62
61
  const [interactive, setInteractive] = useState(false)
63
62
  const [showCode, setShowCode] = useState(!!props?.showCode)
@@ -106,6 +105,7 @@ export default forwardRef(function StoryWidget({ id: widgetId, props, onUpdate,
106
105
  if (!showCode || sourceCode !== null) return
107
106
  const story = getStoryData(storyId)
108
107
  if (!story?._storyModule) {
108
+ // eslint-disable-next-line react-hooks/set-state-in-effect
109
109
  setSourceCode('// Source not available')
110
110
  return
111
111
  }
@@ -156,6 +156,8 @@ export default forwardRef(function StoryWidget({ id: widgetId, props, onUpdate,
156
156
  handleAction(actionId) {
157
157
  if (actionId === 'show-code') toggleShowCode()
158
158
  else if (actionId === 'copy-code') copyCode()
159
+ else if (actionId === 'expand' || actionId === 'expand-single') setExpandMode('single')
160
+ else if (actionId === 'split-screen') setExpandMode('split')
159
161
  else if (actionId === 'open-external') {
160
162
  const story = getStoryData(storyId)
161
163
  if (story?._route) {
@@ -171,10 +173,13 @@ export default forwardRef(function StoryWidget({ id: widgetId, props, onUpdate,
171
173
  [storyId, exportName, storyIndexKey],
172
174
  )
173
175
 
176
+ // When paused and not interactive, freeze the iframe src to prevent reloads
177
+ const effectiveSrc = iframeSrc
178
+
174
179
  useIframeDevLogs({
175
180
  widget: 'StoryWidget',
176
- loaded: interactive && !showCode && Boolean(iframeSrc),
177
- src: iframeSrc,
181
+ loaded: interactive && !showCode && Boolean(effectiveSrc),
182
+ src: effectiveSrc,
178
183
  })
179
184
 
180
185
  const displayName = exportName ? `${storyId} / ${exportName}` : storyId
@@ -192,7 +197,7 @@ export default forwardRef(function StoryWidget({ id: widgetId, props, onUpdate,
192
197
  )
193
198
  }
194
199
 
195
- if (!iframeSrc) {
200
+ if (!effectiveSrc) {
196
201
  return (
197
202
  <WidgetWrapper>
198
203
  <div className={styles.container} ref={containerRef}>
@@ -210,6 +215,7 @@ export default forwardRef(function StoryWidget({ id: widgetId, props, onUpdate,
210
215
  if (typeof height === 'number') sizeStyle.height = `${height}px`
211
216
 
212
217
  return (
218
+ <>
213
219
  <WidgetWrapper>
214
220
  <div ref={containerRef} className={styles.container} style={sizeStyle}>
215
221
  <div className={styles.header}>
@@ -241,9 +247,10 @@ export default forwardRef(function StoryWidget({ id: widgetId, props, onUpdate,
241
247
  <div className={styles.content}>
242
248
  <iframe
243
249
  ref={iframeRef}
244
- src={iframeSrc}
250
+ src={effectiveSrc}
245
251
  className={styles.iframe}
246
252
  title={displayName}
253
+ onLoad={(e) => e.target.blur()}
247
254
  />
248
255
  </div>
249
256
 
@@ -273,5 +280,56 @@ export default forwardRef(function StoryWidget({ id: widgetId, props, onUpdate,
273
280
  </div>
274
281
  {resizable && <ResizeHandle targetRef={containerRef} width={width} height={height} onResize={handleResize} />}
275
282
  </WidgetWrapper>
283
+ {expanded && (
284
+ <StoryExpandPane
285
+ widgetId={widgetId}
286
+ storyId={storyId}
287
+ exportName={exportName}
288
+ splitMode={expandMode === 'split'}
289
+ onClose={() => setExpandMode(null)}
290
+ />
291
+ )}
292
+ </>
276
293
  )
277
294
  })
295
+
296
+ function StoryExpandPane({ widgetId, storyId, exportName, splitMode, onClose }) {
297
+ const connectedWidgets = useMemo(
298
+ () => splitMode ? findAllConnectedSplitTargets(widgetId) : [],
299
+ [widgetId, splitMode],
300
+ )
301
+
302
+ const primaryWidget = useMemo(() => {
303
+ const bridge = window.__storyboardCanvasBridgeState
304
+ return bridge?.widgets?.find((w) => w.id === widgetId) || { id: widgetId, type: 'story', position: { x: 0, y: 0 }, props: { storyId, exportName } }
305
+ }, [widgetId, storyId, exportName])
306
+
307
+ const buildPaneFn = useCallback((widget) => {
308
+ if (widget.id === widgetId) {
309
+ const url = buildSecondaryIframeUrl({ type: 'story', props: { storyId, exportName } })
310
+ return {
311
+ id: widgetId,
312
+ label: getSplitPaneLabel({ type: 'story', props: { storyId, exportName } }),
313
+ widgetType: 'story',
314
+ kind: 'react',
315
+ render: () => url
316
+ ? <iframe src={url} style={{ border: 'none', width: '100%', height: '100%', display: 'block' }} title={storyId} onLoad={(e) => e.target.blur()} />
317
+ : <div style={{ padding: 32, color: 'var(--fgColor-muted)' }}>Story &quot;{storyId}&quot; not found</div>,
318
+ }
319
+ }
320
+ return buildPaneForWidget(widget)
321
+ }, [widgetId, storyId, exportName])
322
+
323
+ const layout = useMemo(
324
+ () => buildSplitLayout(primaryWidget, connectedWidgets, buildPaneFn),
325
+ [primaryWidget, connectedWidgets, buildPaneFn],
326
+ )
327
+
328
+ return (
329
+ <ExpandedPane
330
+ initialLayout={layout}
331
+ variant={layout.flat().length <= 1 ? 'modal' : 'full'}
332
+ onClose={onClose}
333
+ />
334
+ )
335
+ }