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

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 (48) hide show
  1. package/package.json +5 -4
  2. package/src/AuthModal/AuthModal.jsx +6 -2
  3. package/src/BranchBar/BranchBar.jsx +17 -5
  4. package/src/BranchBar/BranchBar.module.css +11 -2
  5. package/src/CommandPalette/CommandPalette.jsx +267 -164
  6. package/src/CommandPalette/command-palette.css +130 -78
  7. package/src/Icon.jsx +112 -48
  8. package/src/Viewfinder.jsx +511 -61
  9. package/src/Viewfinder.module.css +414 -2
  10. package/src/canvas/CanvasPage.bridge.test.jsx +14 -6
  11. package/src/canvas/CanvasPage.dragdrop.test.jsx +10 -6
  12. package/src/canvas/CanvasPage.jsx +157 -174
  13. package/src/canvas/CanvasPage.module.css +0 -15
  14. package/src/canvas/CanvasPage.multiselect.test.jsx +10 -6
  15. package/src/canvas/ConnectorLayer.jsx +5 -5
  16. package/src/canvas/PageSelector.test.jsx +15 -6
  17. package/src/canvas/useCanvas.js +1 -1
  18. package/src/canvas/widgets/ActionWidget.jsx +200 -0
  19. package/src/canvas/widgets/ActionWidget.module.css +122 -0
  20. package/src/canvas/widgets/FigmaEmbed.jsx +97 -29
  21. package/src/canvas/widgets/FigmaEmbed.module.css +61 -0
  22. package/src/canvas/widgets/ImageWidget.jsx +1 -1
  23. package/src/canvas/widgets/LinkPreview.jsx +64 -5
  24. package/src/canvas/widgets/LinkPreview.module.css +127 -0
  25. package/src/canvas/widgets/MarkdownBlock.jsx +39 -17
  26. package/src/canvas/widgets/MarkdownBlock.module.css +123 -0
  27. package/src/canvas/widgets/PrototypeEmbed.jsx +183 -20
  28. package/src/canvas/widgets/PrototypeEmbed.module.css +117 -0
  29. package/src/canvas/widgets/PrototypeEmbed.test.jsx +2 -2
  30. package/src/canvas/widgets/SplitExpandModal.jsx +234 -0
  31. package/src/canvas/widgets/SplitExpandModal.module.css +335 -0
  32. package/src/canvas/widgets/SplitScreenTopBar.jsx +30 -0
  33. package/src/canvas/widgets/SplitScreenTopBar.module.css +58 -0
  34. package/src/canvas/widgets/StoryWidget.jsx +7 -4
  35. package/src/canvas/widgets/TerminalReadWidget.jsx +140 -0
  36. package/src/canvas/widgets/TerminalReadWidget.module.css +92 -0
  37. package/src/canvas/widgets/TerminalWidget.jsx +299 -49
  38. package/src/canvas/widgets/TerminalWidget.module.css +155 -1
  39. package/src/canvas/widgets/WidgetChrome.jsx +19 -14
  40. package/src/canvas/widgets/WidgetChrome.module.css +10 -0
  41. package/src/canvas/widgets/embedInteraction.test.jsx +24 -26
  42. package/src/canvas/widgets/expandUtils.js +188 -0
  43. package/src/canvas/widgets/index.js +5 -0
  44. package/src/canvas/widgets/snapshotDisplay.test.jsx +23 -71
  45. package/src/canvas/widgets/widgetConfig.js +19 -1
  46. package/src/hooks/useConfig.js +14 -0
  47. package/src/index.js +4 -0
  48. package/src/vite/data-plugin.js +264 -14
@@ -6,6 +6,8 @@ import ResizeHandle from './ResizeHandle.jsx'
6
6
  import { readProp, prototypeEmbedSchema } from './widgetProps.js'
7
7
  import { getEmbedChromeVars } from './embedTheme.js'
8
8
  import { useIframeDevLogs } from './iframeDevLogs.js'
9
+ import { findConnectedSplitTarget, getPaneOrder, buildSecondaryIframeUrl, reparentTerminalInto, getSplitPaneLabel, getWidgetX } from './expandUtils.js'
10
+ import SplitScreenTopBar from './SplitScreenTopBar.jsx'
9
11
  import styles from './PrototypeEmbed.module.css'
10
12
  import overlayStyles from './embedOverlay.module.css'
11
13
 
@@ -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(() => {
@@ -212,8 +216,12 @@ export default forwardRef(function PrototypeEmbed({ id: widgetId, props, onUpdat
212
216
  iframe.className = styles.expandIframe
213
217
  iframe.removeAttribute('style')
214
218
  const target = modalContainerRef.current
215
- if (target.moveBefore) target.moveBefore(iframe, target.firstChild)
216
- else target.prepend(iframe)
219
+ try {
220
+ if (target.moveBefore) target.moveBefore(iframe, target.firstChild)
221
+ else target.prepend(iframe)
222
+ } catch {
223
+ target.prepend(iframe)
224
+ }
217
225
  } else if (!expanded && inlineContainerRef.current) {
218
226
  if (iframe._savedClassName !== undefined) {
219
227
  iframe.className = iframe._savedClassName
@@ -222,8 +230,12 @@ export default forwardRef(function PrototypeEmbed({ id: widgetId, props, onUpdat
222
230
  delete iframe._savedStyle
223
231
  }
224
232
  const target = inlineContainerRef.current
225
- if (target.moveBefore) target.moveBefore(iframe, null)
226
- else target.appendChild(iframe)
233
+ try {
234
+ if (target.moveBefore) target.moveBefore(iframe, null)
235
+ else target.appendChild(iframe)
236
+ } catch {
237
+ target.appendChild(iframe)
238
+ }
227
239
  }
228
240
  }, [expanded])
229
241
 
@@ -250,7 +262,7 @@ export default forwardRef(function PrototypeEmbed({ id: widgetId, props, onUpdat
250
262
  handleAction(actionId) {
251
263
  if (actionId === 'edit') {
252
264
  setEditing(true)
253
- } else if (actionId === 'expand') {
265
+ } else if (actionId === 'expand' || actionId === 'split-screen') {
254
266
  setExpanded(true)
255
267
  } else if (actionId === 'open-external') {
256
268
  if (rawSrc) window.open(rawSrc, '_blank', 'noopener')
@@ -362,7 +374,7 @@ export default forwardRef(function PrototypeEmbed({ id: widgetId, props, onUpdat
362
374
  >
363
375
  <iframe
364
376
  ref={iframeRef}
365
- src={iframeSrc}
377
+ src={effectiveSrc}
366
378
  className={styles.iframe}
367
379
  style={{
368
380
  width: width / scale,
@@ -406,20 +418,171 @@ export default forwardRef(function PrototypeEmbed({ id: widgetId, props, onUpdat
406
418
  {resizable && <ResizeHandle targetRef={embedRef} width={width} height={height} onResize={handleResize} />}
407
419
  </WidgetWrapper>
408
420
  {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
+ <PrototypeExpandModal
422
+ expanded={expanded}
423
+ onClose={() => setExpanded(false)}
424
+ modalContainerRef={modalContainerRef}
425
+ widgetId={widgetId}
426
+ />,
421
427
  document.body
422
428
  )}
423
429
  </>
424
430
  )
425
431
  })
432
+
433
+ /**
434
+ * PrototypeExpandModal — the existing expand modal with split-screen support.
435
+ * Keeps iframe reparenting in the primary pane, adds a secondary pane if connected.
436
+ */
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],
446
+ )
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
+ const primaryWidget = useMemo(() => {
455
+ 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
+ )
512
+ }
513
+ }
514
+
515
+ const leftPane = paneOrder.primaryIsLeft ? primaryPane : secondaryPane
516
+ const rightPane = paneOrder.primaryIsLeft ? secondaryPane : primaryPane
517
+
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>
544
+ )
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
+
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
+ 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>
587
+ )
588
+ }
@@ -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
  })
@@ -0,0 +1,234 @@
1
+ /**
2
+ * SplitExpandModal — reusable full-screen modal for expandable widgets.
3
+ *
4
+ * When a connected split-screen-capable widget exists, renders a 50/50
5
+ * split (ordered by x-coordinate). Otherwise renders single-pane.
6
+ */
7
+ import { useState, useEffect, useRef, useMemo, useCallback } from 'react'
8
+ import { createPortal } from 'react-dom'
9
+ import { ScreenNormalIcon, MarkGithubIcon } from '@primer/octicons-react'
10
+ import { findConnectedSplitTarget, getPaneOrder, buildSecondaryIframeUrl, reparentTerminalInto, getSplitPaneLabel } from './expandUtils.js'
11
+ import SplitScreenTopBar from './SplitScreenTopBar.jsx'
12
+ import styles from './SplitExpandModal.module.css'
13
+
14
+ /**
15
+ * Renders the content for a secondary pane based on widget type.
16
+ */
17
+ function SecondaryPane({ widget }) {
18
+ const iframeUrl = useMemo(() => buildSecondaryIframeUrl(widget), [widget])
19
+ const terminalRef = useRef(null)
20
+ const cleanupRef = useRef(null)
21
+
22
+ // Reparent terminal DOM into the pane
23
+ useEffect(() => {
24
+ if ((widget.type !== 'terminal' && widget.type !== 'terminal-read' && widget.type !== 'agent') || !terminalRef.current) return
25
+ cleanupRef.current = reparentTerminalInto(widget.id, terminalRef.current)
26
+ return () => {
27
+ cleanupRef.current?.()
28
+ cleanupRef.current = null
29
+ }
30
+ }, [widget.id, widget.type])
31
+
32
+ // iframe-embeddable types
33
+ if (iframeUrl) {
34
+ return (
35
+ <div className={styles.secondaryPane}>
36
+ <iframe src={iframeUrl} className={styles.secondaryIframe} title="Connected widget" />
37
+ </div>
38
+ )
39
+ }
40
+
41
+ // Terminal: reparent its DOM
42
+ if (widget.type === 'terminal' || widget.type === 'terminal-read' || widget.type === 'agent') {
43
+ return (
44
+ <div className={styles.secondaryPane}>
45
+ <div ref={terminalRef} className={styles.terminalContainer} />
46
+ </div>
47
+ )
48
+ }
49
+
50
+ // Markdown: render content from props
51
+ if (widget.type === 'markdown') {
52
+ // Import remark inline to avoid making it a hard dep of this module
53
+ const content = widget.props?.content || ''
54
+ return (
55
+ <div className={styles.secondaryPane}>
56
+ <MarkdownSecondary content={content} />
57
+ </div>
58
+ )
59
+ }
60
+
61
+ // Link-preview (GitHub card or plain)
62
+ if (widget.type === 'link-preview') {
63
+ return (
64
+ <div className={styles.secondaryPane}>
65
+ <LinkPreviewSecondary widget={widget} />
66
+ </div>
67
+ )
68
+ }
69
+
70
+ return null
71
+ }
72
+
73
+ /**
74
+ * Renders markdown as HTML for the secondary pane.
75
+ * Uses dynamic import to avoid bundling remark in this module eagerly.
76
+ */
77
+ function MarkdownSecondary({ content }) {
78
+ const ref = useRef(null)
79
+
80
+ useEffect(() => {
81
+ if (!ref.current || !content) return
82
+ let cancelled = false
83
+ ;(async () => {
84
+ const { remark } = await import('remark')
85
+ const remarkGfm = (await import('remark-gfm')).default
86
+ const remarkHtml = (await import('remark-html')).default
87
+ if (cancelled) return
88
+ const result = remark().use(remarkGfm).use(remarkHtml, { sanitize: false }).processSync(content)
89
+ let html = String(result).replace(/<a\s/g, '<a target="_blank" rel="noopener noreferrer" ')
90
+ if (ref.current) ref.current.innerHTML = html
91
+ })()
92
+ return () => { cancelled = true }
93
+ }, [content])
94
+
95
+ return <div ref={ref} className={styles.markdownContent} />
96
+ }
97
+
98
+ /**
99
+ * Renders a link-preview widget's content for the secondary pane.
100
+ */
101
+ function LinkPreviewSecondary({ widget }) {
102
+ const { url, title, github } = widget.props || {}
103
+ const bodyRef = useRef(null)
104
+
105
+ useEffect(() => {
106
+ if (!bodyRef.current) return
107
+ const bodyHtml = github?.bodyHtml || ''
108
+ if (bodyHtml) bodyRef.current.innerHTML = bodyHtml
109
+ }, [github?.bodyHtml])
110
+
111
+ if (github) {
112
+ return (
113
+ <div className={styles.githubCard}>
114
+ <div className={styles.githubHeader}>
115
+ <MarkGithubIcon size={16} />
116
+ <span className={styles.githubTitle}>{title || url || 'GitHub'}</span>
117
+ </div>
118
+ {github.bodyHtml && <div ref={bodyRef} className={styles.githubBody} />}
119
+ </div>
120
+ )
121
+ }
122
+
123
+ return (
124
+ <div className={styles.linkCard}>
125
+ <p className={styles.linkTitle}>{title || url || 'Link'}</p>
126
+ {url && (
127
+ <a href={url} target="_blank" rel="noopener noreferrer" className={styles.linkUrl}>
128
+ {url}
129
+ </a>
130
+ )}
131
+ </div>
132
+ )
133
+ }
134
+
135
+ /**
136
+ * @param {Object} props
137
+ * @param {boolean} props.expanded — whether the modal is visible
138
+ * @param {() => void} props.onClose — callback to close the modal
139
+ * @param {string} props.widgetId — the primary widget's ID
140
+ * @param {string} [props.title] — optional title for the top bar
141
+ * @param {React.ReactNode} props.children — primary pane content
142
+ */
143
+ export default function SplitExpandModal({ expanded, onClose, widgetId, title, children }) {
144
+ const connectedWidget = useMemo(
145
+ () => (expanded ? findConnectedSplitTarget(widgetId) : null),
146
+ [expanded, widgetId],
147
+ )
148
+ const hasSplit = Boolean(connectedWidget)
149
+ const paneOrder = useMemo(
150
+ () => (hasSplit ? getPaneOrder(widgetId, connectedWidget) : { primaryIsLeft: true }),
151
+ [hasSplit, widgetId, connectedWidget],
152
+ )
153
+ const [activePane, setActivePane] = useState('left')
154
+
155
+ const primaryWidget = useMemo(() => {
156
+ const bridge = window.__storyboardCanvasBridgeState
157
+ return bridge?.widgets?.find((w) => w.id === widgetId) || null
158
+ }, [widgetId, expanded])
159
+
160
+ const primaryLabel = useMemo(() => getSplitPaneLabel(primaryWidget) || title || '', [primaryWidget, title])
161
+ const secondaryLabel = useMemo(() => getSplitPaneLabel(connectedWidget), [connectedWidget])
162
+ const leftLabel = paneOrder.primaryIsLeft ? primaryLabel : secondaryLabel
163
+ const rightLabel = paneOrder.primaryIsLeft ? secondaryLabel : primaryLabel
164
+
165
+ // Close on Escape
166
+ useEffect(() => {
167
+ if (!expanded) return
168
+ function handleKeyDown(e) {
169
+ if (e.key === 'Escape') {
170
+ e.stopPropagation()
171
+ onClose()
172
+ }
173
+ }
174
+ document.addEventListener('keydown', handleKeyDown, true)
175
+ return () => document.removeEventListener('keydown', handleKeyDown, true)
176
+ }, [expanded, onClose])
177
+
178
+ if (!expanded) return null
179
+
180
+ const primarySide = paneOrder.primaryIsLeft ? 'left' : 'right'
181
+ const secondarySide = paneOrder.primaryIsLeft ? 'right' : 'left'
182
+ const primaryPane = <div className={styles.primaryPane} onPointerDown={() => setActivePane(primarySide)}>{children}</div>
183
+ const secondaryPane = hasSplit ? <div onPointerDown={() => setActivePane(secondarySide)}><SecondaryPane widget={connectedWidget} /></div> : null
184
+
185
+ const leftPane = paneOrder.primaryIsLeft ? primaryPane : secondaryPane
186
+ const rightPane = paneOrder.primaryIsLeft ? secondaryPane : primaryPane
187
+
188
+ return createPortal(
189
+ <div
190
+ className={styles.backdrop}
191
+ onClick={onClose}
192
+ onPointerDown={(e) => e.stopPropagation()}
193
+ onKeyDown={(e) => e.stopPropagation()}
194
+ onWheel={(e) => e.stopPropagation()}
195
+ >
196
+ <div className={hasSplit ? styles.modalFullscreen : styles.modal} onClick={(e) => e.stopPropagation()}>
197
+ {hasSplit ? (
198
+ <>
199
+ <SplitScreenTopBar
200
+ leftLabel={leftLabel}
201
+ rightLabel={rightLabel}
202
+ activePane={activePane}
203
+ onClose={onClose}
204
+ />
205
+ <div className={styles.bodySplit}>
206
+ <div className={styles.splitLeft}>{leftPane}</div>
207
+ <div className={styles.splitRight}>{rightPane}</div>
208
+ </div>
209
+ </>
210
+ ) : (
211
+ <>
212
+ {title && (
213
+ <div className={styles.topBar}>
214
+ <span className={styles.topBarTitle}>{title}</span>
215
+ <button className={styles.closeBtn} onClick={onClose} aria-label="Close expanded view" autoFocus>
216
+ <ScreenNormalIcon size={16} />
217
+ </button>
218
+ </div>
219
+ )}
220
+ <div className={styles.body}>
221
+ {primaryPane}
222
+ </div>
223
+ {!title && (
224
+ <button className={styles.closeBtnFloat} onClick={onClose} aria-label="Close expanded view" autoFocus>
225
+
226
+ </button>
227
+ )}
228
+ </>
229
+ )}
230
+ </div>
231
+ </div>,
232
+ document.body,
233
+ )
234
+ }