@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.
- package/package.json +5 -4
- package/src/AuthModal/AuthModal.jsx +6 -2
- package/src/BranchBar/BranchBar.jsx +17 -5
- package/src/BranchBar/BranchBar.module.css +11 -2
- package/src/CommandPalette/CommandPalette.jsx +267 -164
- package/src/CommandPalette/command-palette.css +130 -78
- package/src/Icon.jsx +112 -48
- package/src/Viewfinder.jsx +511 -61
- package/src/Viewfinder.module.css +414 -2
- package/src/canvas/CanvasPage.bridge.test.jsx +14 -6
- package/src/canvas/CanvasPage.dragdrop.test.jsx +10 -6
- package/src/canvas/CanvasPage.jsx +157 -174
- package/src/canvas/CanvasPage.module.css +0 -15
- package/src/canvas/CanvasPage.multiselect.test.jsx +10 -6
- package/src/canvas/ConnectorLayer.jsx +5 -5
- package/src/canvas/PageSelector.test.jsx +15 -6
- package/src/canvas/useCanvas.js +1 -1
- package/src/canvas/widgets/ActionWidget.jsx +200 -0
- package/src/canvas/widgets/ActionWidget.module.css +122 -0
- package/src/canvas/widgets/FigmaEmbed.jsx +97 -29
- package/src/canvas/widgets/FigmaEmbed.module.css +61 -0
- package/src/canvas/widgets/ImageWidget.jsx +1 -1
- package/src/canvas/widgets/LinkPreview.jsx +64 -5
- package/src/canvas/widgets/LinkPreview.module.css +127 -0
- package/src/canvas/widgets/MarkdownBlock.jsx +39 -17
- package/src/canvas/widgets/MarkdownBlock.module.css +123 -0
- package/src/canvas/widgets/PrototypeEmbed.jsx +183 -20
- package/src/canvas/widgets/PrototypeEmbed.module.css +117 -0
- package/src/canvas/widgets/PrototypeEmbed.test.jsx +2 -2
- package/src/canvas/widgets/SplitExpandModal.jsx +234 -0
- package/src/canvas/widgets/SplitExpandModal.module.css +335 -0
- package/src/canvas/widgets/SplitScreenTopBar.jsx +30 -0
- package/src/canvas/widgets/SplitScreenTopBar.module.css +58 -0
- package/src/canvas/widgets/StoryWidget.jsx +7 -4
- package/src/canvas/widgets/TerminalReadWidget.jsx +140 -0
- package/src/canvas/widgets/TerminalReadWidget.module.css +92 -0
- package/src/canvas/widgets/TerminalWidget.jsx +299 -49
- package/src/canvas/widgets/TerminalWidget.module.css +155 -1
- package/src/canvas/widgets/WidgetChrome.jsx +19 -14
- package/src/canvas/widgets/WidgetChrome.module.css +10 -0
- package/src/canvas/widgets/embedInteraction.test.jsx +24 -26
- package/src/canvas/widgets/expandUtils.js +188 -0
- package/src/canvas/widgets/index.js +5 -0
- package/src/canvas/widgets/snapshotDisplay.test.jsx +23 -71
- package/src/canvas/widgets/widgetConfig.js +19 -1
- package/src/hooks/useConfig.js +14 -0
- package/src/index.js +4 -0
- 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(
|
|
157
|
-
src:
|
|
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
|
-
|
|
216
|
-
|
|
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
|
-
|
|
226
|
-
|
|
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={
|
|
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
|
-
<
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
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('#
|
|
8
|
-
expect(getEmbedChromeVars('dark_dimmed')['--bgColor-default']).toBe('#
|
|
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
|
+
}
|