@dfosco/storyboard-react 4.2.0-beta.17 → 4.2.0-beta.18

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 (79) hide show
  1. package/package.json +3 -3
  2. package/src/BranchBar/BranchBar.jsx +3 -1
  3. package/src/BranchBar/BranchBar.module.css +2 -2
  4. package/src/BranchBar/useBranches.js +20 -6
  5. package/src/BranchBar/useBranches.test.js +68 -0
  6. package/src/CommandPalette/CommandPalette.jsx +250 -61
  7. package/src/CommandPalette/command-palette.css +12 -0
  8. package/src/Icon.jsx +46 -11
  9. package/src/Viewfinder.jsx +53 -133
  10. package/src/Viewfinder.module.css +20 -91
  11. package/src/Workspace.jsx +7 -0
  12. package/src/canvas/CanvasPage.jsx +601 -62
  13. package/src/canvas/CanvasPage.module.css +15 -2
  14. package/src/canvas/CanvasPage.multiselect.test.jsx +7 -0
  15. package/src/canvas/ConnectorLayer.jsx +120 -152
  16. package/src/canvas/ConnectorLayer.module.css +69 -0
  17. package/src/canvas/canvasApi.js +68 -2
  18. package/src/canvas/connectorGeometry.js +132 -0
  19. package/src/canvas/hotPoolDevLogs.js +25 -0
  20. package/src/canvas/useMarqueeSelect.js +30 -4
  21. package/src/canvas/widgets/CodePenEmbed.jsx +1 -0
  22. package/src/canvas/widgets/ComponentSetWidget.jsx +199 -0
  23. package/src/canvas/widgets/ComponentSetWidget.module.css +89 -0
  24. package/src/canvas/widgets/ComponentWidget.jsx +1 -0
  25. package/src/canvas/widgets/CropOverlay.jsx +219 -0
  26. package/src/canvas/widgets/CropOverlay.module.css +118 -0
  27. package/src/canvas/widgets/ExpandedPane.jsx +472 -0
  28. package/src/canvas/widgets/ExpandedPane.module.css +179 -0
  29. package/src/canvas/widgets/ExpandedPane.test.jsx +240 -0
  30. package/src/canvas/widgets/ExpandedPaneTopBar.jsx +111 -0
  31. package/src/canvas/widgets/ExpandedPaneTopBar.module.css +59 -0
  32. package/src/canvas/widgets/ExpandedPaneTopBar.test.jsx +45 -0
  33. package/src/canvas/widgets/FigmaEmbed.jsx +49 -102
  34. package/src/canvas/widgets/ImageWidget.jsx +129 -8
  35. package/src/canvas/widgets/ImageWidget.module.css +30 -0
  36. package/src/canvas/widgets/LinkPreview.jsx +93 -44
  37. package/src/canvas/widgets/MarkdownBlock.jsx +141 -16
  38. package/src/canvas/widgets/MarkdownBlock.module.css +25 -0
  39. package/src/canvas/widgets/PromptWidget.jsx +414 -0
  40. package/src/canvas/widgets/PromptWidget.module.css +273 -0
  41. package/src/canvas/widgets/PrototypeEmbed.jsx +46 -170
  42. package/src/canvas/widgets/ResizeHandle.jsx +17 -6
  43. package/src/canvas/widgets/StoryWidget.jsx +65 -11
  44. package/src/canvas/widgets/TerminalReadWidget.jsx +11 -5
  45. package/src/canvas/widgets/TerminalReadWidget.module.css +3 -1
  46. package/src/canvas/widgets/TerminalWidget.jsx +301 -124
  47. package/src/canvas/widgets/TerminalWidget.module.css +121 -12
  48. package/src/canvas/widgets/TilesWidget.jsx +302 -0
  49. package/src/canvas/widgets/TilesWidget.module.css +133 -0
  50. package/src/canvas/widgets/WidgetChrome.jsx +67 -152
  51. package/src/canvas/widgets/WidgetChrome.module.css +20 -1
  52. package/src/canvas/widgets/expandUtils.js +385 -16
  53. package/src/canvas/widgets/expandUtils.test.js +155 -0
  54. package/src/canvas/widgets/index.js +6 -2
  55. package/src/canvas/widgets/tilePool.js +23 -0
  56. package/src/canvas/widgets/tiles/diagonal-bl.png +0 -0
  57. package/src/canvas/widgets/tiles/diagonal-br.png +0 -0
  58. package/src/canvas/widgets/tiles/diagonal-tl.png +0 -0
  59. package/src/canvas/widgets/tiles/leaf.png +0 -0
  60. package/src/canvas/widgets/tiles/quarter-tl.png +0 -0
  61. package/src/canvas/widgets/tiles/quarter-tr.png +0 -0
  62. package/src/canvas/widgets/tiles/solid-a.png +0 -0
  63. package/src/canvas/widgets/tiles/solid-b.png +0 -0
  64. package/src/canvas/widgets/widgetConfig.js +37 -4
  65. package/src/canvas/widgets/widgetIcons.jsx +190 -0
  66. package/src/canvas/widgets/widgetProps.js +1 -0
  67. package/src/context.jsx +47 -19
  68. package/src/hooks/usePrototypeReloadGuard.js +64 -0
  69. package/src/index.js +4 -2
  70. package/src/story/ComponentSetPage.jsx +186 -0
  71. package/src/story/ComponentSetPage.module.css +121 -0
  72. package/src/story/StoryPage.jsx +32 -2
  73. package/src/vite/data-plugin.js +79 -35
  74. package/src/canvas/widgets/ActionWidget.jsx +0 -200
  75. package/src/canvas/widgets/ActionWidget.module.css +0 -122
  76. package/src/canvas/widgets/SplitExpandModal.jsx +0 -234
  77. package/src/canvas/widgets/SplitExpandModal.module.css +0 -335
  78. package/src/canvas/widgets/SplitScreenTopBar.jsx +0 -30
  79. package/src/canvas/widgets/SplitScreenTopBar.module.css +0 -58
@@ -1,13 +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'
9
- import { findConnectedSplitTarget, getPaneOrder, buildSecondaryIframeUrl, reparentTerminalInto, getSplitPaneLabel, getWidgetX } from './expandUtils.js'
10
- import SplitScreenTopBar from './SplitScreenTopBar.jsx'
8
+ import { findAllConnectedSplitTargets, getSplitPaneLabel, buildPaneForWidget, buildSplitLayout } from './expandUtils.js'
9
+ import ExpandedPane from './ExpandedPane.jsx'
11
10
  import styles from './PrototypeEmbed.module.css'
12
11
  import overlayStyles from './embedOverlay.module.css'
13
12
 
@@ -66,7 +65,8 @@ export default forwardRef(function PrototypeEmbed({ id: widgetId, props, onUpdat
66
65
 
67
66
  const [editing, setEditing] = useState(false)
68
67
  const [interactive, setInteractive] = useState(false)
69
- const [expanded, setExpanded] = useState(false)
68
+ const [expandMode, setExpandMode] = useState(null)
69
+ const expanded = expandMode !== null
70
70
  const [filter, setFilter] = useState('')
71
71
  const [canvasTheme, setCanvasTheme] = useState(() => resolveCanvasThemeFromStorage())
72
72
  const inputRef = useRef(null)
@@ -193,19 +193,6 @@ export default forwardRef(function PrototypeEmbed({ id: widgetId, props, onUpdat
193
193
  return () => document.removeEventListener('storyboard:theme:changed', readToolbarTheme)
194
194
  }, [])
195
195
 
196
- // Close expanded modal on Escape
197
- useEffect(() => {
198
- if (!expanded) return
199
- function handleKeyDown(e) {
200
- if (e.key === 'Escape') {
201
- e.stopPropagation()
202
- setExpanded(false)
203
- }
204
- }
205
- document.addEventListener('keydown', handleKeyDown, true)
206
- return () => document.removeEventListener('keydown', handleKeyDown, true)
207
- }, [expanded])
208
-
209
196
  // Reparent iframe between inline and modal
210
197
  useEffect(() => {
211
198
  const iframe = iframeRef.current
@@ -262,8 +249,10 @@ export default forwardRef(function PrototypeEmbed({ id: widgetId, props, onUpdat
262
249
  handleAction(actionId) {
263
250
  if (actionId === 'edit') {
264
251
  setEditing(true)
265
- } else if (actionId === 'expand' || actionId === 'split-screen') {
266
- setExpanded(true)
252
+ } else if (actionId === 'expand' || actionId === 'expand-single') {
253
+ setExpandMode('single')
254
+ } else if (actionId === 'split-screen') {
255
+ setExpandMode('split')
267
256
  } else if (actionId === 'open-external') {
268
257
  if (rawSrc) window.open(rawSrc, '_blank', 'noopener')
269
258
  } else if (actionId === 'zoom-in') {
@@ -384,6 +373,7 @@ export default forwardRef(function PrototypeEmbed({ id: widgetId, props, onUpdat
384
373
  }}
385
374
  title={`${prototypeTitle} prototype`}
386
375
  sandbox="allow-same-origin allow-scripts allow-forms allow-popups"
376
+ onLoad={(e) => e.target.blur()}
387
377
  />
388
378
  </div>
389
379
  {!interactive && !expanded && (
@@ -417,172 +407,58 @@ export default forwardRef(function PrototypeEmbed({ id: widgetId, props, onUpdat
417
407
  </div>
418
408
  {resizable && <ResizeHandle targetRef={embedRef} width={width} height={height} onResize={handleResize} />}
419
409
  </WidgetWrapper>
420
- {createPortal(
421
- <PrototypeExpandModal
422
- expanded={expanded}
423
- onClose={() => setExpanded(false)}
424
- modalContainerRef={modalContainerRef}
410
+ {expanded && (
411
+ <PrototypeExpandPane
425
412
  widgetId={widgetId}
426
- />,
427
- document.body
413
+ modalContainerRef={modalContainerRef}
414
+ splitMode={expandMode === 'split'}
415
+ onClose={() => setExpandMode(null)}
416
+ />
428
417
  )}
429
418
  </>
430
419
  )
431
420
  })
432
421
 
433
422
  /**
434
- * PrototypeExpandModal the existing expand modal with split-screen support.
435
- * Keeps iframe reparenting in the primary pane, adds a secondary pane if connected.
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.
436
425
  */
437
- function PrototypeExpandModal({ expanded, onClose, modalContainerRef, widgetId }) {
438
- const connectedWidget = useMemo(
439
- () => (expanded ? findConnectedSplitTarget(widgetId) : null),
440
- [expanded, widgetId],
441
- )
442
- const hasSplit = Boolean(connectedWidget)
443
- const paneOrder = useMemo(
444
- () => (hasSplit ? getPaneOrder(widgetId, connectedWidget) : { primaryIsLeft: true }),
445
- [hasSplit, widgetId, connectedWidget],
426
+ function PrototypeExpandPane({ widgetId, modalContainerRef, splitMode, onClose }) {
427
+ const connectedWidgets = useMemo(
428
+ () => splitMode ? findAllConnectedSplitTargets(widgetId) : [],
429
+ [widgetId, splitMode],
446
430
  )
447
- const secondaryUrl = useMemo(() => buildSecondaryIframeUrl(connectedWidget), [connectedWidget])
448
- const isTerminalSecondary = connectedWidget?.type === 'terminal' || connectedWidget?.type === 'terminal-read' || connectedWidget?.type === 'agent'
449
- const terminalRef = useRef(null)
450
- const cleanupRef = useRef(null)
451
- const [activePane, setActivePane] = useState('left')
452
-
453
- // Build labels for the top bar
454
431
  const primaryWidget = useMemo(() => {
455
432
  const bridge = window.__storyboardCanvasBridgeState
456
- return bridge?.widgets?.find((w) => w.id === widgetId) || { type: 'prototype', props: {} }
457
- }, [widgetId, expanded])
458
-
459
- const primaryLabel = useMemo(() => getSplitPaneLabel(primaryWidget), [primaryWidget])
460
- const secondaryLabel = useMemo(() => getSplitPaneLabel(connectedWidget), [connectedWidget])
461
- const leftLabel = paneOrder.primaryIsLeft ? primaryLabel : secondaryLabel
462
- const rightLabel = paneOrder.primaryIsLeft ? secondaryLabel : primaryLabel
463
-
464
- // Reparent terminal DOM for split
465
- useEffect(() => {
466
- if (!isTerminalSecondary || !expanded || !terminalRef.current) return
467
- cleanupRef.current = reparentTerminalInto(connectedWidget.id, terminalRef.current)
468
- return () => {
469
- cleanupRef.current?.()
470
- cleanupRef.current = null
471
- }
472
- }, [isTerminalSecondary, expanded, connectedWidget?.id])
473
-
474
- const primaryPane = (
475
- <div
476
- ref={modalContainerRef}
477
- className={hasSplit ? styles.expandContainerSplit : styles.expandContainer}
478
- onClick={(e) => e.stopPropagation()}
479
- onPointerDown={() => setActivePane(paneOrder.primaryIsLeft ? 'left' : 'right')}
480
- >
481
- {!hasSplit && <button className={styles.expandClose} onClick={onClose} aria-label="Close expanded view" autoFocus>✕</button>}
482
- </div>
483
- )
484
-
485
- let secondaryPane = null
486
- const secondarySide = paneOrder.primaryIsLeft ? 'right' : 'left'
487
- if (hasSplit) {
488
- if (secondaryUrl) {
489
- secondaryPane = (
490
- <div className={styles.expandSecondary} onClick={(e) => e.stopPropagation()} onPointerDown={() => setActivePane(secondarySide)}>
491
- <iframe src={secondaryUrl} className={styles.expandIframe} title="Connected widget" />
492
- </div>
493
- )
494
- } else if (isTerminalSecondary) {
495
- secondaryPane = (
496
- <div className={styles.expandSecondary} onClick={(e) => e.stopPropagation()} onPointerDown={() => setActivePane(secondarySide)}>
497
- <div ref={terminalRef} className={styles.expandTerminal} />
498
- </div>
499
- )
500
- } else if (connectedWidget?.type === 'markdown') {
501
- secondaryPane = (
502
- <div className={styles.expandSecondary} onClick={(e) => e.stopPropagation()} onPointerDown={() => setActivePane(secondarySide)}>
503
- <MarkdownSecondaryPane content={connectedWidget.props?.content} />
504
- </div>
505
- )
506
- } else if (connectedWidget?.type === 'link-preview') {
507
- secondaryPane = (
508
- <div className={styles.expandSecondary} onClick={(e) => e.stopPropagation()} onPointerDown={() => setActivePane(secondarySide)}>
509
- <LinkPreviewSecondaryPane widget={connectedWidget} />
510
- </div>
511
- )
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
+ }
512
448
  }
513
- }
514
-
515
- const leftPane = paneOrder.primaryIsLeft ? primaryPane : secondaryPane
516
- const rightPane = paneOrder.primaryIsLeft ? secondaryPane : primaryPane
449
+ return buildPaneForWidget(widget)
450
+ }, [widgetId, primaryWidget, modalContainerRef])
517
451
 
518
- return (
519
- <div
520
- className={styles.expandBackdrop}
521
- style={expanded ? undefined : { display: 'none' }}
522
- onClick={onClose}
523
- onPointerDown={(e) => e.stopPropagation()}
524
- onKeyDown={(e) => e.stopPropagation()}
525
- onWheel={(e) => e.stopPropagation()}
526
- >
527
- {hasSplit ? (
528
- <div className={styles.expandSplitBody}>
529
- <SplitScreenTopBar
530
- leftLabel={leftLabel}
531
- rightLabel={rightLabel}
532
- activePane={activePane}
533
- onClose={onClose}
534
- />
535
- <div className={styles.expandSplitPanes}>
536
- <div className={styles.expandSplitLeft}>{leftPane}</div>
537
- <div className={styles.expandSplitRight}>{rightPane}</div>
538
- </div>
539
- </div>
540
- ) : (
541
- primaryPane
542
- )}
543
- </div>
452
+ const layout = useMemo(
453
+ () => buildSplitLayout(primaryWidget, connectedWidgets, buildPaneFn),
454
+ [primaryWidget, connectedWidgets, buildPaneFn],
544
455
  )
545
- }
546
-
547
- /** Minimal markdown renderer for secondary pane */
548
- function MarkdownSecondaryPane({ content }) {
549
- const ref = useRef(null)
550
- useEffect(() => {
551
- if (!ref.current || !content) return
552
- let cancelled = false
553
- ;(async () => {
554
- const { remark } = await import('remark')
555
- const remarkGfm = (await import('remark-gfm')).default
556
- const remarkHtml = (await import('remark-html')).default
557
- if (cancelled) return
558
- const result = remark().use(remarkGfm).use(remarkHtml, { sanitize: false }).processSync(content)
559
- if (ref.current) ref.current.innerHTML = String(result).replace(/<a\s/g, '<a target="_blank" rel="noopener noreferrer" ')
560
- })()
561
- return () => { cancelled = true }
562
- }, [content])
563
- return <div ref={ref} className={styles.expandMarkdownContent} />
564
- }
565
456
 
566
- /** Link preview secondary pane (GitHub card or plain) */
567
- function LinkPreviewSecondaryPane({ widget }) {
568
- const { url, title, github } = widget.props || {}
569
- const bodyRef = useRef(null)
570
- useEffect(() => {
571
- if (bodyRef.current && github?.bodyHtml) bodyRef.current.innerHTML = github.bodyHtml
572
- }, [github?.bodyHtml])
573
-
574
- if (github) {
575
- return (
576
- <div className={styles.expandGithubCard}>
577
- <div className={styles.expandGithubHeader}>{title || url || 'GitHub'}</div>
578
- {github.bodyHtml && <div ref={bodyRef} className={styles.expandGithubBody} />}
579
- </div>
580
- )
581
- }
582
457
  return (
583
- <div className={styles.expandLinkCard}>
584
- <p className={styles.expandLinkTitle}>{title || url || 'Link'}</p>
585
- {url && <a href={url} target="_blank" rel="noopener noreferrer">{url}</a>}
586
- </div>
458
+ <ExpandedPane
459
+ initialLayout={layout}
460
+ variant={layout.flat().length <= 1 ? 'modal' : 'full'}
461
+ onClose={onClose}
462
+ />
587
463
  )
588
464
  }
@@ -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) {
@@ -213,6 +214,7 @@ export default forwardRef(function StoryWidget({ id: widgetId, props, onUpdate,
213
214
  if (typeof height === 'number') sizeStyle.height = `${height}px`
214
215
 
215
216
  return (
217
+ <>
216
218
  <WidgetWrapper>
217
219
  <div ref={containerRef} className={styles.container} style={sizeStyle}>
218
220
  <div className={styles.header}>
@@ -247,6 +249,7 @@ export default forwardRef(function StoryWidget({ id: widgetId, props, onUpdate,
247
249
  src={effectiveSrc}
248
250
  className={styles.iframe}
249
251
  title={displayName}
252
+ onLoad={(e) => e.target.blur()}
250
253
  />
251
254
  </div>
252
255
 
@@ -276,5 +279,56 @@ export default forwardRef(function StoryWidget({ id: widgetId, props, onUpdate,
276
279
  </div>
277
280
  {resizable && <ResizeHandle targetRef={containerRef} width={width} height={height} onResize={handleResize} />}
278
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
+ </>
279
292
  )
280
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
+ }
@@ -56,10 +56,16 @@ export default function TerminalReadWidget({ id, props }) {
56
56
  if (!canvasId) { setFailed(true); return }
57
57
 
58
58
  const urls = isProduction()
59
- ? [`${baseUrl}_storyboard/terminal-snapshots/${canvasId}/${id}.json`]
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
+ ]
60
65
  : [
61
- `${baseUrl}_storyboard/canvas/${canvasId}/terminal-snapshot/${id}`,
62
- `${baseUrl}_storyboard/terminal-snapshots/${canvasId}/${id}.json`,
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`,
63
69
  ]
64
70
 
65
71
  for (const url of urls) {
@@ -68,7 +74,7 @@ export default function TerminalReadWidget({ id, props }) {
68
74
  if (!res.ok) continue
69
75
  const data = await res.json()
70
76
  if (cancelled) return
71
- const text = data.content || data.output || ''
77
+ const text = data.paneContent || data.content || data.output || ''
72
78
  setContent(text)
73
79
 
74
80
  const converter = await getConverter()
@@ -100,7 +106,7 @@ export default function TerminalReadWidget({ id, props }) {
100
106
 
101
107
  return (
102
108
  <div className={styles.container}>
103
- <div className={styles.titleBar}>
109
+ <div className={`tc-drag-handle ${styles.titleBar}`}>
104
110
  <span>{titleLabel}</span>
105
111
  <span className={styles.readOnlyBadge}>read only</span>
106
112
  </div>
@@ -19,10 +19,12 @@
19
19
  font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
20
20
  font-size: 11px;
21
21
  color: #8b949e;
22
- pointer-events: none;
23
22
  user-select: none;
24
23
  white-space: nowrap;
25
24
  z-index: 2;
25
+ cursor: grab;
26
+ padding: 2px 6px;
27
+ border-radius: 4px;
26
28
  }
27
29
 
28
30
  [data-widget-selected] .titleBar {