@dfosco/storyboard-react 4.2.0-beta.2 → 4.2.0-beta.21

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 (85) hide show
  1. package/package.json +9 -4
  2. package/src/AuthModal/AuthModal.jsx +6 -2
  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 +478 -186
  8. package/src/CommandPalette/command-palette.css +142 -78
  9. package/src/Icon.jsx +157 -58
  10. package/src/Viewfinder.jsx +561 -191
  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 +10 -6
  15. package/src/canvas/CanvasPage.jsx +738 -216
  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 -153
  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/connectorGeometry.js +132 -0
  23. package/src/canvas/hotPoolDevLogs.js +25 -0
  24. package/src/canvas/useCanvas.js +1 -1
  25. package/src/canvas/useMarqueeSelect.js +30 -4
  26. package/src/canvas/widgets/CodePenEmbed.jsx +1 -0
  27. package/src/canvas/widgets/ComponentSetWidget.jsx +199 -0
  28. package/src/canvas/widgets/ComponentSetWidget.module.css +89 -0
  29. package/src/canvas/widgets/ComponentWidget.jsx +1 -0
  30. package/src/canvas/widgets/CropOverlay.jsx +219 -0
  31. package/src/canvas/widgets/CropOverlay.module.css +118 -0
  32. package/src/canvas/widgets/ExpandedPane.jsx +472 -0
  33. package/src/canvas/widgets/ExpandedPane.module.css +179 -0
  34. package/src/canvas/widgets/ExpandedPane.test.jsx +240 -0
  35. package/src/canvas/widgets/ExpandedPaneTopBar.jsx +111 -0
  36. package/src/canvas/widgets/ExpandedPaneTopBar.module.css +59 -0
  37. package/src/canvas/widgets/ExpandedPaneTopBar.test.jsx +45 -0
  38. package/src/canvas/widgets/FigmaEmbed.jsx +62 -47
  39. package/src/canvas/widgets/FigmaEmbed.module.css +61 -0
  40. package/src/canvas/widgets/ImageWidget.jsx +130 -9
  41. package/src/canvas/widgets/ImageWidget.module.css +30 -0
  42. package/src/canvas/widgets/LinkPreview.jsx +112 -4
  43. package/src/canvas/widgets/LinkPreview.module.css +127 -0
  44. package/src/canvas/widgets/MarkdownBlock.jsx +164 -17
  45. package/src/canvas/widgets/MarkdownBlock.module.css +148 -0
  46. package/src/canvas/widgets/PromptWidget.jsx +414 -0
  47. package/src/canvas/widgets/PromptWidget.module.css +273 -0
  48. package/src/canvas/widgets/PrototypeEmbed.jsx +77 -38
  49. package/src/canvas/widgets/PrototypeEmbed.module.css +117 -0
  50. package/src/canvas/widgets/PrototypeEmbed.test.jsx +2 -2
  51. package/src/canvas/widgets/ResizeHandle.jsx +17 -6
  52. package/src/canvas/widgets/StoryWidget.jsx +72 -15
  53. package/src/canvas/widgets/TerminalReadWidget.jsx +146 -0
  54. package/src/canvas/widgets/TerminalReadWidget.module.css +94 -0
  55. package/src/canvas/widgets/TerminalWidget.jsx +496 -69
  56. package/src/canvas/widgets/TerminalWidget.module.css +271 -8
  57. package/src/canvas/widgets/TilesWidget.jsx +302 -0
  58. package/src/canvas/widgets/TilesWidget.module.css +133 -0
  59. package/src/canvas/widgets/WidgetChrome.jsx +73 -153
  60. package/src/canvas/widgets/WidgetChrome.module.css +30 -1
  61. package/src/canvas/widgets/embedInteraction.test.jsx +24 -26
  62. package/src/canvas/widgets/expandUtils.js +557 -0
  63. package/src/canvas/widgets/expandUtils.test.js +155 -0
  64. package/src/canvas/widgets/index.js +9 -0
  65. package/src/canvas/widgets/snapshotDisplay.test.jsx +23 -71
  66. package/src/canvas/widgets/tilePool.js +23 -0
  67. package/src/canvas/widgets/tiles/diagonal-bl.png +0 -0
  68. package/src/canvas/widgets/tiles/diagonal-br.png +0 -0
  69. package/src/canvas/widgets/tiles/diagonal-tl.png +0 -0
  70. package/src/canvas/widgets/tiles/leaf.png +0 -0
  71. package/src/canvas/widgets/tiles/quarter-tl.png +0 -0
  72. package/src/canvas/widgets/tiles/quarter-tr.png +0 -0
  73. package/src/canvas/widgets/tiles/solid-a.png +0 -0
  74. package/src/canvas/widgets/tiles/solid-b.png +0 -0
  75. package/src/canvas/widgets/widgetConfig.js +55 -4
  76. package/src/canvas/widgets/widgetIcons.jsx +190 -0
  77. package/src/canvas/widgets/widgetProps.js +1 -0
  78. package/src/context.jsx +47 -19
  79. package/src/hooks/useConfig.js +14 -0
  80. package/src/hooks/usePrototypeReloadGuard.js +64 -0
  81. package/src/index.js +8 -2
  82. package/src/story/ComponentSetPage.jsx +186 -0
  83. package/src/story/ComponentSetPage.module.css +121 -0
  84. package/src/story/StoryPage.jsx +32 -2
  85. package/src/vite/data-plugin.js +324 -30
@@ -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
 
@@ -64,7 +65,8 @@ export default forwardRef(function PrototypeEmbed({ id: widgetId, props, onUpdat
64
65
 
65
66
  const [editing, setEditing] = useState(false)
66
67
  const [interactive, setInteractive] = useState(false)
67
- const [expanded, setExpanded] = useState(false)
68
+ const [expandMode, setExpandMode] = useState(null)
69
+ const expanded = expandMode !== null
68
70
  const [filter, setFilter] = useState('')
69
71
  const [canvasTheme, setCanvasTheme] = useState(() => resolveCanvasThemeFromStorage())
70
72
  const inputRef = useRef(null)
@@ -84,6 +86,8 @@ export default forwardRef(function PrototypeEmbed({ id: widgetId, props, onUpdat
84
86
  return `${base}${sep}_sb_embed&_sb_hide_branch_bar&_sb_theme_target=prototype&_sb_canvas_theme=${canvasTheme}${hash}`
85
87
  }, [rawSrc, canvasTheme])
86
88
 
89
+ const effectiveSrc = iframeSrc
90
+
87
91
  const prototypeIndex = useMemo(() => {
88
92
  try { return buildPrototypeIndex() }
89
93
  catch { return { folders: [], prototypes: [], globalFlows: [], sorted: { title: { prototypes: [], folders: [] } } } }
@@ -153,8 +157,8 @@ export default forwardRef(function PrototypeEmbed({ id: widgetId, props, onUpdat
153
157
 
154
158
  useIframeDevLogs({
155
159
  widget: 'PrototypeEmbed',
156
- loaded: Boolean(iframeSrc && interactive),
157
- src: iframeSrc,
160
+ loaded: Boolean(effectiveSrc && interactive),
161
+ src: effectiveSrc,
158
162
  })
159
163
 
160
164
  useEffect(() => {
@@ -189,19 +193,6 @@ export default forwardRef(function PrototypeEmbed({ id: widgetId, props, onUpdat
189
193
  return () => document.removeEventListener('storyboard:theme:changed', readToolbarTheme)
190
194
  }, [])
191
195
 
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
196
  // Reparent iframe between inline and modal
206
197
  useEffect(() => {
207
198
  const iframe = iframeRef.current
@@ -212,8 +203,12 @@ export default forwardRef(function PrototypeEmbed({ id: widgetId, props, onUpdat
212
203
  iframe.className = styles.expandIframe
213
204
  iframe.removeAttribute('style')
214
205
  const target = modalContainerRef.current
215
- if (target.moveBefore) target.moveBefore(iframe, target.firstChild)
216
- else target.prepend(iframe)
206
+ try {
207
+ if (target.moveBefore) target.moveBefore(iframe, target.firstChild)
208
+ else target.prepend(iframe)
209
+ } catch {
210
+ target.prepend(iframe)
211
+ }
217
212
  } else if (!expanded && inlineContainerRef.current) {
218
213
  if (iframe._savedClassName !== undefined) {
219
214
  iframe.className = iframe._savedClassName
@@ -222,8 +217,12 @@ export default forwardRef(function PrototypeEmbed({ id: widgetId, props, onUpdat
222
217
  delete iframe._savedStyle
223
218
  }
224
219
  const target = inlineContainerRef.current
225
- if (target.moveBefore) target.moveBefore(iframe, null)
226
- else target.appendChild(iframe)
220
+ try {
221
+ if (target.moveBefore) target.moveBefore(iframe, null)
222
+ else target.appendChild(iframe)
223
+ } catch {
224
+ target.appendChild(iframe)
225
+ }
227
226
  }
228
227
  }, [expanded])
229
228
 
@@ -250,8 +249,10 @@ export default forwardRef(function PrototypeEmbed({ id: widgetId, props, onUpdat
250
249
  handleAction(actionId) {
251
250
  if (actionId === 'edit') {
252
251
  setEditing(true)
253
- } else if (actionId === 'expand') {
254
- setExpanded(true)
252
+ } else if (actionId === 'expand' || actionId === 'expand-single') {
253
+ setExpandMode('single')
254
+ } else if (actionId === 'split-screen') {
255
+ setExpandMode('split')
255
256
  } else if (actionId === 'open-external') {
256
257
  if (rawSrc) window.open(rawSrc, '_blank', 'noopener')
257
258
  } else if (actionId === 'zoom-in') {
@@ -362,7 +363,7 @@ export default forwardRef(function PrototypeEmbed({ id: widgetId, props, onUpdat
362
363
  >
363
364
  <iframe
364
365
  ref={iframeRef}
365
- src={iframeSrc}
366
+ src={effectiveSrc}
366
367
  className={styles.iframe}
367
368
  style={{
368
369
  width: width / scale,
@@ -372,6 +373,7 @@ export default forwardRef(function PrototypeEmbed({ id: widgetId, props, onUpdat
372
373
  }}
373
374
  title={`${prototypeTitle} prototype`}
374
375
  sandbox="allow-same-origin allow-scripts allow-forms allow-popups"
376
+ onLoad={(e) => e.target.blur()}
375
377
  />
376
378
  </div>
377
379
  {!interactive && !expanded && (
@@ -405,21 +407,58 @@ export default forwardRef(function PrototypeEmbed({ id: widgetId, props, onUpdat
405
407
  </div>
406
408
  {resizable && <ResizeHandle targetRef={embedRef} width={width} height={height} onResize={handleResize} />}
407
409
  </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
410
+ {expanded && (
411
+ <PrototypeExpandPane
412
+ widgetId={widgetId}
413
+ modalContainerRef={modalContainerRef}
414
+ splitMode={expandMode === 'split'}
415
+ onClose={() => setExpandMode(null)}
416
+ />
422
417
  )}
423
418
  </>
424
419
  )
425
420
  })
421
+
422
+ /**
423
+ * Builds pane configs and renders ExpandedPane for an expanded prototype widget.
424
+ * The primary pane is an external pane that receives the iframe via reparenting.
425
+ */
426
+ function PrototypeExpandPane({ widgetId, modalContainerRef, splitMode, onClose }) {
427
+ const connectedWidgets = useMemo(
428
+ () => splitMode ? findAllConnectedSplitTargets(widgetId) : [],
429
+ [widgetId, splitMode],
430
+ )
431
+ const primaryWidget = useMemo(() => {
432
+ const bridge = window.__storyboardCanvasBridgeState
433
+ return bridge?.widgets?.find((w) => w.id === widgetId) || { id: widgetId, type: 'prototype', position: { x: 0, y: 0 }, props: {} }
434
+ }, [widgetId])
435
+
436
+ const buildPaneFn = useCallback((widget) => {
437
+ if (widget.id === widgetId) {
438
+ return {
439
+ id: widgetId,
440
+ label: getSplitPaneLabel(primaryWidget),
441
+ widgetType: 'prototype',
442
+ kind: 'external',
443
+ attach: (container) => {
444
+ modalContainerRef.current = container
445
+ return () => { modalContainerRef.current = null }
446
+ },
447
+ }
448
+ }
449
+ return buildPaneForWidget(widget)
450
+ }, [widgetId, primaryWidget, modalContainerRef])
451
+
452
+ const layout = useMemo(
453
+ () => buildSplitLayout(primaryWidget, connectedWidgets, buildPaneFn),
454
+ [primaryWidget, connectedWidgets, buildPaneFn],
455
+ )
456
+
457
+ return (
458
+ <ExpandedPane
459
+ initialLayout={layout}
460
+ variant={layout.flat().length <= 1 ? 'modal' : 'full'}
461
+ onClose={onClose}
462
+ />
463
+ )
464
+ }
@@ -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)
@@ -156,6 +155,8 @@ export default forwardRef(function StoryWidget({ id: widgetId, props, onUpdate,
156
155
  handleAction(actionId) {
157
156
  if (actionId === 'show-code') toggleShowCode()
158
157
  else if (actionId === 'copy-code') copyCode()
158
+ else if (actionId === 'expand' || actionId === 'expand-single') setExpandMode('single')
159
+ else if (actionId === 'split-screen') setExpandMode('split')
159
160
  else if (actionId === 'open-external') {
160
161
  const story = getStoryData(storyId)
161
162
  if (story?._route) {
@@ -171,10 +172,13 @@ export default forwardRef(function StoryWidget({ id: widgetId, props, onUpdate,
171
172
  [storyId, exportName, storyIndexKey],
172
173
  )
173
174
 
175
+ // When paused and not interactive, freeze the iframe src to prevent reloads
176
+ const effectiveSrc = iframeSrc
177
+
174
178
  useIframeDevLogs({
175
179
  widget: 'StoryWidget',
176
- loaded: interactive && !showCode && Boolean(iframeSrc),
177
- src: iframeSrc,
180
+ loaded: interactive && !showCode && Boolean(effectiveSrc),
181
+ src: effectiveSrc,
178
182
  })
179
183
 
180
184
  const displayName = exportName ? `${storyId} / ${exportName}` : storyId
@@ -192,7 +196,7 @@ export default forwardRef(function StoryWidget({ id: widgetId, props, onUpdate,
192
196
  )
193
197
  }
194
198
 
195
- if (!iframeSrc) {
199
+ if (!effectiveSrc) {
196
200
  return (
197
201
  <WidgetWrapper>
198
202
  <div className={styles.container} ref={containerRef}>
@@ -210,6 +214,7 @@ export default forwardRef(function StoryWidget({ id: widgetId, props, onUpdate,
210
214
  if (typeof height === 'number') sizeStyle.height = `${height}px`
211
215
 
212
216
  return (
217
+ <>
213
218
  <WidgetWrapper>
214
219
  <div ref={containerRef} className={styles.container} style={sizeStyle}>
215
220
  <div className={styles.header}>
@@ -241,9 +246,10 @@ export default forwardRef(function StoryWidget({ id: widgetId, props, onUpdate,
241
246
  <div className={styles.content}>
242
247
  <iframe
243
248
  ref={iframeRef}
244
- src={iframeSrc}
249
+ src={effectiveSrc}
245
250
  className={styles.iframe}
246
251
  title={displayName}
252
+ onLoad={(e) => e.target.blur()}
247
253
  />
248
254
  </div>
249
255
 
@@ -273,5 +279,56 @@ export default forwardRef(function StoryWidget({ id: widgetId, props, onUpdate,
273
279
  </div>
274
280
  {resizable && <ResizeHandle targetRef={containerRef} width={width} height={height} onResize={handleResize} />}
275
281
  </WidgetWrapper>
282
+ {expanded && (
283
+ <StoryExpandPane
284
+ widgetId={widgetId}
285
+ storyId={storyId}
286
+ exportName={exportName}
287
+ splitMode={expandMode === 'split'}
288
+ onClose={() => setExpandMode(null)}
289
+ />
290
+ )}
291
+ </>
276
292
  )
277
293
  })
294
+
295
+ function StoryExpandPane({ widgetId, storyId, exportName, splitMode, onClose }) {
296
+ const connectedWidgets = useMemo(
297
+ () => splitMode ? findAllConnectedSplitTargets(widgetId) : [],
298
+ [widgetId, splitMode],
299
+ )
300
+
301
+ const primaryWidget = useMemo(() => {
302
+ const bridge = window.__storyboardCanvasBridgeState
303
+ return bridge?.widgets?.find((w) => w.id === widgetId) || { id: widgetId, type: 'story', position: { x: 0, y: 0 }, props: { storyId, exportName } }
304
+ }, [widgetId, storyId, exportName])
305
+
306
+ const buildPaneFn = useCallback((widget) => {
307
+ if (widget.id === widgetId) {
308
+ const url = buildSecondaryIframeUrl({ type: 'story', props: { storyId, exportName } })
309
+ return {
310
+ id: widgetId,
311
+ label: getSplitPaneLabel({ type: 'story', props: { storyId, exportName } }),
312
+ widgetType: 'story',
313
+ kind: 'react',
314
+ render: () => url
315
+ ? <iframe src={url} style={{ border: 'none', width: '100%', height: '100%', display: 'block' }} title={storyId} onLoad={(e) => e.target.blur()} />
316
+ : <div style={{ padding: 32, color: 'var(--fgColor-muted)' }}>Story "{storyId}" not found</div>,
317
+ }
318
+ }
319
+ return buildPaneForWidget(widget)
320
+ }, [widgetId, storyId, exportName])
321
+
322
+ const layout = useMemo(
323
+ () => buildSplitLayout(primaryWidget, connectedWidgets, buildPaneFn),
324
+ [primaryWidget, connectedWidgets, buildPaneFn],
325
+ )
326
+
327
+ return (
328
+ <ExpandedPane
329
+ initialLayout={layout}
330
+ variant={layout.flat().length <= 1 ? 'modal' : 'full'}
331
+ onClose={onClose}
332
+ />
333
+ )
334
+ }
@@ -0,0 +1,146 @@
1
+ import { useRef, useEffect, useState } from 'react'
2
+ import { readProp, schemas } from './widgetProps.js'
3
+ import styles from './TerminalReadWidget.module.css'
4
+
5
+ const terminalSchema = schemas['terminal']
6
+
7
+ let Convert = null
8
+ let ansiLoadAttempted = false
9
+
10
+ async function getConverter() {
11
+ if (Convert) return new Convert({ fg: '#e6edf3', bg: '#0d1117', newline: true })
12
+ if (ansiLoadAttempted) return null
13
+ ansiLoadAttempted = true
14
+ try {
15
+ const mod = await import(/* @vite-ignore */ 'ansi-to-html')
16
+ Convert = mod.default || mod
17
+ return new Convert({ fg: '#e6edf3', bg: '#0d1117', newline: true })
18
+ } catch {
19
+ return null
20
+ }
21
+ }
22
+
23
+ function stripAnsi(text) {
24
+ // eslint-disable-next-line no-control-regex
25
+ return text.replace(/\x1b\[[0-9;]*[a-zA-Z]/g, '')
26
+ }
27
+
28
+ function getBaseUrl() {
29
+ const base = (typeof import.meta !== 'undefined' && import.meta.env?.BASE_URL) || '/'
30
+ return base.endsWith('/') ? base : base + '/'
31
+ }
32
+
33
+ function getCanvasId() {
34
+ return window.__storyboardCanvasBridgeState?.canvasId || null
35
+ }
36
+
37
+ function isProduction() {
38
+ return typeof import.meta !== 'undefined' && import.meta.env?.PROD
39
+ }
40
+
41
+ export default function TerminalReadWidget({ id, props }) {
42
+ const width = readProp(props, 'width', terminalSchema)
43
+ const height = readProp(props, 'height', terminalSchema)
44
+ const prettyName = props?.prettyName || '...'
45
+
46
+ const [content, setContent] = useState(null)
47
+ const [html, setHtml] = useState(null)
48
+ const [failed, setFailed] = useState(false)
49
+ const contentRef = useRef(null)
50
+
51
+ useEffect(() => {
52
+ let cancelled = false
53
+ async function fetchSnapshot() {
54
+ const baseUrl = getBaseUrl()
55
+ const canvasId = getCanvasId()
56
+ if (!canvasId) { setFailed(true); return }
57
+
58
+ const urls = isProduction()
59
+ ? [
60
+ // New flat format: <widgetId>.snapshot.json
61
+ `${baseUrl}_storyboard/terminal-snapshots/${id}.snapshot.json`,
62
+ // Legacy nested format: <canvasDir>/<widgetId>.json
63
+ `${baseUrl}_storyboard/terminal-snapshots/${canvasId.replace(/\//g, '--')}/${id}.json`,
64
+ ]
65
+ : [
66
+ `${baseUrl}_storyboard/canvas/terminal-snapshot/${id}`,
67
+ `${baseUrl}_storyboard/terminal-snapshots/${id}.snapshot.json`,
68
+ `${baseUrl}_storyboard/terminal-snapshots/${canvasId.replace(/\//g, '--')}/${id}.json`,
69
+ ]
70
+
71
+ for (const url of urls) {
72
+ try {
73
+ const res = await fetch(url)
74
+ if (!res.ok) continue
75
+ const data = await res.json()
76
+ if (cancelled) return
77
+ const text = data.paneContent || data.content || data.output || ''
78
+ setContent(text)
79
+
80
+ const converter = await getConverter()
81
+ if (cancelled) return
82
+ if (converter) {
83
+ setHtml(converter.toHtml(text))
84
+ } else {
85
+ setContent(stripAnsi(text))
86
+ }
87
+ return
88
+ } catch {
89
+ continue
90
+ }
91
+ }
92
+ if (!cancelled) setFailed(true)
93
+ }
94
+ fetchSnapshot()
95
+ return () => { cancelled = true }
96
+ }, [id])
97
+
98
+ // Auto-scroll to bottom
99
+ useEffect(() => {
100
+ if (contentRef.current) {
101
+ contentRef.current.scrollTop = contentRef.current.scrollHeight
102
+ }
103
+ }, [html, content])
104
+
105
+ const titleLabel = `terminal · ${prettyName}`
106
+
107
+ return (
108
+ <div className={styles.container}>
109
+ <div className={`tc-drag-handle ${styles.titleBar}`}>
110
+ <span>{titleLabel}</span>
111
+ <span className={styles.readOnlyBadge}>read only</span>
112
+ </div>
113
+ <div
114
+ ref={contentRef}
115
+ className={styles.content}
116
+ style={{
117
+ ...(typeof width === 'number' ? { width: `${width}px` } : undefined),
118
+ ...(typeof height === 'number' ? { height: `${height}px` } : undefined),
119
+ }}
120
+ >
121
+ {failed && (
122
+ <div className={styles.placeholder}>
123
+ <span className={styles.placeholderTitle}>Terminal session · {prettyName}</span>
124
+ <span className={styles.placeholderSub}>No captured output available</span>
125
+ </div>
126
+ )}
127
+ {!failed && content === null && (
128
+ <div className={styles.placeholder}>
129
+ <span className={styles.placeholderSub}>Loading…</span>
130
+ </div>
131
+ )}
132
+ {!failed && html && (
133
+ <pre
134
+ style={{ margin: 0, whiteSpace: 'pre', fontFamily: 'inherit', fontSize: 'inherit', lineHeight: 'inherit' }}
135
+ dangerouslySetInnerHTML={{ __html: html }}
136
+ />
137
+ )}
138
+ {!failed && content !== null && !html && (
139
+ <pre style={{ margin: 0, whiteSpace: 'pre', fontFamily: 'inherit', fontSize: 'inherit', lineHeight: 'inherit' }}>
140
+ {content}
141
+ </pre>
142
+ )}
143
+ </div>
144
+ </div>
145
+ )
146
+ }