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

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 +7 -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,14 +1,17 @@
1
- import { useRef, useCallback, useState, forwardRef, useImperativeHandle } from 'react'
1
+ import { useRef, useCallback, useState, useMemo, forwardRef, useImperativeHandle } from 'react'
2
2
  import WidgetWrapper from './WidgetWrapper.jsx'
3
3
  import ResizeHandle from './ResizeHandle.jsx'
4
+ import ExpandedPane from './ExpandedPane.jsx'
5
+ import CropOverlay from './CropOverlay.jsx'
4
6
  import { readProp } from './widgetProps.js'
5
7
  import { schemas } from './widgetConfig.js'
6
- import { toggleImagePrivacy } from '../canvasApi.js'
8
+ import { toggleImagePrivacy, cropAndUpload } from '../canvasApi.js'
9
+ import { findAllConnectedSplitTargets, getSplitPaneLabel, buildPaneForWidget, buildSplitLayout } from './expandUtils.js'
7
10
  import styles from './ImageWidget.module.css'
8
11
 
9
12
  const imageSchema = schemas['image']
10
13
 
11
- function getImageUrl(src) {
14
+ export function getImageUrl(src) {
12
15
  if (!src) return ''
13
16
  const base = (import.meta.env?.BASE_URL || '/').replace(/\/$/, '')
14
17
  return `${base}/_storyboard/canvas/images/${src}`
@@ -16,11 +19,18 @@ function getImageUrl(src) {
16
19
 
17
20
  /**
18
21
  * Canvas widget that displays a pasted image.
19
- * Supports aspect-ratio locked resize and privacy toggle.
22
+ * Supports aspect-ratio locked resize, privacy toggle, and expand/split-screen.
20
23
  */
21
- const ImageWidget = forwardRef(function ImageWidget({ props, onUpdate, resizable }, ref) {
24
+ const ImageWidget = forwardRef(function ImageWidget({ id, props, onUpdate, resizable }, ref) {
22
25
  const containerRef = useRef(null)
26
+ const imgRef = useRef(null)
23
27
  const [naturalRatio, setNaturalRatio] = useState(null)
28
+ const [naturalSize, setNaturalSize] = useState(null)
29
+ const [expandMode, setExpandMode] = useState(null)
30
+ const expanded = expandMode !== null
31
+ const [cropping, setCropping] = useState(false)
32
+ const [previousSrc, setPreviousSrc] = useState(null)
33
+ const [containerSize, setContainerSize] = useState(null)
24
34
 
25
35
  const src = readProp(props, 'src', imageSchema)
26
36
  const isPrivate = readProp(props, 'private', imageSchema)
@@ -34,6 +44,7 @@ const ImageWidget = forwardRef(function ImageWidget({ props, onUpdate, resizable
34
44
  const img = e.target
35
45
  if (img.naturalWidth && img.naturalHeight) {
36
46
  setNaturalRatio(img.naturalWidth / img.naturalHeight)
47
+ setNaturalSize({ width: img.naturalWidth, height: img.naturalHeight })
37
48
  }
38
49
  }, [])
39
50
 
@@ -43,8 +54,44 @@ const ImageWidget = forwardRef(function ImageWidget({ props, onUpdate, resizable
43
54
  onUpdate?.({ width: newWidth, height: newHeight })
44
55
  }, [naturalRatio, width, height, onUpdate])
45
56
 
57
+ const handleCropSave = useCallback(async (cropRect) => {
58
+ if (!src) return
59
+ const canvasId = window.__storyboardCanvasBridgeState?.canvasId || ''
60
+ try {
61
+ const result = await cropAndUpload(src, cropRect, canvasId)
62
+ if (result.success) {
63
+ setPreviousSrc(src)
64
+ onUpdate?.({ src: result.filename })
65
+ }
66
+ } catch (err) {
67
+ console.error('[canvas] Failed to crop image:', err)
68
+ }
69
+ setCropping(false)
70
+ }, [src, onUpdate])
71
+
72
+ const handleCropCancel = useCallback(() => {
73
+ setCropping(false)
74
+ }, [])
75
+
76
+ const handleCropUndo = useCallback(() => {
77
+ if (previousSrc) {
78
+ onUpdate?.({ src: previousSrc })
79
+ setPreviousSrc(null)
80
+ }
81
+ setCropping(false)
82
+ }, [previousSrc, onUpdate])
83
+
46
84
  useImperativeHandle(ref, () => ({
47
85
  handleAction(actionId) {
86
+ if (actionId === 'expand' || actionId === 'expand-single') { setExpandMode('single'); return true }
87
+ if (actionId === 'split-screen') { setExpandMode('split'); return true }
88
+ if (actionId === 'crop-image') {
89
+ // Measure container at activation time (not during render)
90
+ const el = containerRef.current
91
+ if (el) setContainerSize({ width: el.offsetWidth, height: el.offsetHeight })
92
+ setCropping(true)
93
+ return true
94
+ }
48
95
  if (actionId === 'toggle-private') {
49
96
  if (!src) return
50
97
  toggleImagePrivacy(src).then((result) => {
@@ -86,23 +133,37 @@ const ImageWidget = forwardRef(function ImageWidget({ props, onUpdate, resizable
86
133
  if (typeof width === 'number') sizeStyle.width = `${width}px`
87
134
 
88
135
  return (
136
+ <>
89
137
  <WidgetWrapper className={styles.imageWrapper}>
90
- <div ref={containerRef} className={styles.container} style={sizeStyle}>
138
+ <div ref={containerRef} className={styles.container} style={sizeStyle} data-crop-active={cropping || undefined}>
91
139
  <div className={styles.frame}>
92
140
  <img
141
+ ref={imgRef}
93
142
  src={getImageUrl(src)}
94
143
  alt=""
95
144
  className={styles.image}
96
145
  onLoad={handleImageLoad}
97
146
  draggable={false}
98
147
  />
99
- {isPrivate && (
148
+ {isPrivate && !cropping && (
100
149
  <span className={styles.privateBadge} title="Private — not committed to git">
101
150
  Private
102
151
  </span>
103
152
  )}
153
+ {cropping && (
154
+ <CropOverlay
155
+ containerWidth={containerSize?.width || width || 400}
156
+ containerHeight={containerSize?.height || height || 300}
157
+ naturalWidth={naturalSize?.width}
158
+ naturalHeight={naturalSize?.height}
159
+ onSave={handleCropSave}
160
+ onCancel={handleCropCancel}
161
+ onUndo={handleCropUndo}
162
+ canUndo={!!previousSrc}
163
+ />
164
+ )}
104
165
  </div>
105
- {resizable && (
166
+ {resizable && !cropping && (
106
167
  <ResizeHandle
107
168
  targetRef={containerRef}
108
169
  minWidth={100}
@@ -112,7 +173,67 @@ const ImageWidget = forwardRef(function ImageWidget({ props, onUpdate, resizable
112
173
  )}
113
174
  </div>
114
175
  </WidgetWrapper>
176
+ {expanded && (
177
+ <ImageExpandPane
178
+ widgetId={id}
179
+ src={src}
180
+ splitMode={expandMode === 'split'}
181
+ onClose={() => setExpandMode(null)}
182
+ />
183
+ )}
184
+ </>
115
185
  )
116
186
  })
117
187
 
188
+ /**
189
+ * Builds pane configs and renders ExpandedPane for an expanded image widget.
190
+ */
191
+ function ImageExpandPane({ widgetId, src, splitMode, onClose }) {
192
+ const connectedWidgets = useMemo(
193
+ () => splitMode ? findAllConnectedSplitTargets(widgetId) : [],
194
+ [widgetId, splitMode],
195
+ )
196
+ const primaryWidget = useMemo(() => {
197
+ const bridge = window.__storyboardCanvasBridgeState
198
+ return bridge?.widgets?.find((w) => w.id === widgetId) || { id: widgetId, type: 'image', position: { x: 0, y: 0 }, props: {} }
199
+ }, [widgetId])
200
+
201
+ const surface = splitMode ? 'splitbar' : 'fullbar'
202
+
203
+ const buildPaneFn = useCallback((widget) => {
204
+ if (widget.id === widgetId) {
205
+ return {
206
+ id: widgetId,
207
+ label: getSplitPaneLabel(primaryWidget) || 'Image',
208
+ widgetType: 'image',
209
+ kind: 'react',
210
+ render: () => (
211
+ <div className={styles.expandedImageContainer}>
212
+ <img
213
+ src={getImageUrl(src)}
214
+ alt=""
215
+ className={styles.expandedImage}
216
+ draggable={false}
217
+ />
218
+ </div>
219
+ ),
220
+ }
221
+ }
222
+ return buildPaneForWidget(widget, surface)
223
+ }, [widgetId, primaryWidget, src, surface])
224
+
225
+ const layout = useMemo(
226
+ () => buildSplitLayout(primaryWidget, connectedWidgets, buildPaneFn),
227
+ [primaryWidget, connectedWidgets, buildPaneFn],
228
+ )
229
+
230
+ return (
231
+ <ExpandedPane
232
+ initialLayout={layout}
233
+ variant={layout.flat().length <= 1 ? 'modal' : 'full'}
234
+ onClose={onClose}
235
+ />
236
+ )
237
+ }
238
+
118
239
  export default ImageWidget
@@ -23,6 +23,16 @@
23
23
  pointer-events: none;
24
24
  }
25
25
 
26
+ /* When cropping, the image needs pointer events for the overlay */
27
+ .container[data-crop-active] .image {
28
+ pointer-events: none;
29
+ }
30
+
31
+ /* Hide the widget toolbar when crop is active (WidgetChrome reads this) */
32
+ .container[data-crop-active] {
33
+ overflow: visible;
34
+ }
35
+
26
36
  .privateBadge {
27
37
  position: absolute;
28
38
  top: 20px;
@@ -37,3 +47,23 @@
37
47
  background: var(--bgColor-neutral-emphasis, rgba(0, 0, 0, 0.55));
38
48
  pointer-events: none;
39
49
  }
50
+
51
+ /* ── Expanded / split-screen image ───────────────────────────────── */
52
+
53
+ .expandedImageContainer {
54
+ width: 100%;
55
+ height: 100%;
56
+ display: flex;
57
+ align-items: center;
58
+ justify-content: center;
59
+ overflow: auto;
60
+ background: var(--bgColor-inset, #f6f8fa);
61
+ }
62
+
63
+ .expandedImage {
64
+ max-width: 100%;
65
+ max-height: 100%;
66
+ object-fit: contain;
67
+ border-radius: 0;
68
+ user-select: none;
69
+ }
@@ -6,7 +6,8 @@ import { MarkGithubIcon } from '@primer/octicons-react'
6
6
  import WidgetWrapper from './WidgetWrapper.jsx'
7
7
  import ResizeHandle from './ResizeHandle.jsx'
8
8
  import { readProp, linkPreviewSchema } from './widgetProps.js'
9
- import SplitExpandModal from './SplitExpandModal.jsx'
9
+ import ExpandedPane from './ExpandedPane.jsx'
10
+ import { findAllConnectedSplitTargets, getSplitPaneLabel, buildPaneForWidget, buildSplitLayout } from './expandUtils.js'
10
11
  import styles from './LinkPreview.module.css'
11
12
 
12
13
  const VIDEO_EXT_RE = /\.(mp4|mov|webm|ogg)(\?[^)]*)?$/i
@@ -107,7 +108,7 @@ function getCommentKindLabel(github) {
107
108
  return 'Comment'
108
109
  }
109
110
 
110
- function GitHubIssueCard({ id, url, title, github, width, collapsed, expanded, onCloseExpand }) {
111
+ function GitHubIssueCard({ id, url, title, github, width, collapsed, expanded, expandMode, onCloseExpand }) {
111
112
  const authors = Array.isArray(github?.authors)
112
113
  ? github.authors.filter((a) => typeof a === 'string' && a.trim())
113
114
  : []
@@ -206,33 +207,35 @@ function GitHubIssueCard({ id, url, title, github, width, collapsed, expanded, o
206
207
  )}
207
208
  </div>
208
209
  </WidgetWrapper>
209
- <SplitExpandModal
210
- expanded={expanded}
211
- onClose={onCloseExpand}
212
- widgetId={id}
213
- title={`${kindLabel}: ${titleText || url || 'GitHub'}`}
214
- >
215
- <div className={styles.expandedIssue}>
216
- <header className={styles.expandedIssueHeader}>
217
- <h2 className={styles.expandedIssueTitle}>
218
- <a href={url || '#'} target="_blank" rel="noopener noreferrer">
219
- {titleText || url}
220
- {issueNumber && <span className={styles.expandedIssueNumber}> {issueNumber}</span>}
221
- </a>
222
- </h2>
223
- <div className={styles.expandedByline}>
224
- {primaryAuthor && (
225
- <a href={`https://github.com/${primaryAuthor}`} target="_blank" rel="noopener noreferrer" className={styles.expandedAuthor}>
226
- <img src={`https://github.com/${primaryAuthor}.png?size=40`} alt="" width="20" height="20" className={styles.avatar} loading="lazy" />
227
- {primaryAuthor}
210
+ {expanded && (
211
+ <LinkPreviewExpandPane
212
+ widgetId={id}
213
+ label={`${kindLabel}: ${titleText || url || 'GitHub'}`}
214
+ splitMode={expandMode === 'split'}
215
+ onClose={onCloseExpand}
216
+ >
217
+ <div className={styles.expandedIssue}>
218
+ <header className={styles.expandedIssueHeader}>
219
+ <h2 className={styles.expandedIssueTitle}>
220
+ <a href={url || '#'} target="_blank" rel="noopener noreferrer">
221
+ {titleText || url}
222
+ {issueNumber && <span className={styles.expandedIssueNumber}> {issueNumber}</span>}
228
223
  </a>
229
- )}
230
- {createdAgo && <span className={styles.expandedBylineText}>{primaryAuthor ? ` opened ${createdAgo}` : `Opened ${createdAgo}`}</span>}
231
- </div>
232
- </header>
233
- {bodyHtml && <div className={styles.expandedIssueBody} dangerouslySetInnerHTML={{ __html: bodyHtml }} />}
234
- </div>
235
- </SplitExpandModal>
224
+ </h2>
225
+ <div className={styles.expandedByline}>
226
+ {primaryAuthor && (
227
+ <a href={`https://github.com/${primaryAuthor}`} target="_blank" rel="noopener noreferrer" className={styles.expandedAuthor}>
228
+ <img src={`https://github.com/${primaryAuthor}.png?size=40`} alt="" width="20" height="20" className={styles.avatar} loading="lazy" />
229
+ {primaryAuthor}
230
+ </a>
231
+ )}
232
+ {createdAgo && <span className={styles.expandedBylineText}>{primaryAuthor ? ` opened ${createdAgo}` : `Opened ${createdAgo}`}</span>}
233
+ </div>
234
+ </header>
235
+ {bodyHtml && <div className={styles.expandedIssueBody} dangerouslySetInnerHTML={{ __html: bodyHtml }} />}
236
+ </div>
237
+ </LinkPreviewExpandPane>
238
+ )}
236
239
  </>
237
240
  )
238
241
  }
@@ -252,11 +255,13 @@ export default forwardRef(function LinkPreview({ id, props, onUpdate, resizable
252
255
  const cardRef = useRef(null)
253
256
  const inputRef = useRef(null)
254
257
  const [editing, setEditing] = useState(false)
255
- const [expanded, setExpanded] = useState(false)
258
+ const [expandMode, setExpandMode] = useState(null)
259
+ const expanded = expandMode !== null
256
260
 
257
261
  useImperativeHandle(ref, () => ({
258
262
  handleAction(actionId) {
259
- if (actionId === 'expand' || actionId === 'split-screen') { setExpanded(true); return true }
263
+ if (actionId === 'expand' || actionId === 'expand-single') { setExpandMode('single'); return true }
264
+ if (actionId === 'split-screen') { setExpandMode('split'); return true }
260
265
  if (actionId === 'open-external') {
261
266
  if (url) window.open(url, '_blank', 'noopener')
262
267
  return true
@@ -291,7 +296,8 @@ export default forwardRef(function LinkPreview({ id, props, onUpdate, resizable
291
296
  width={width}
292
297
  collapsed={!!props?.collapsed}
293
298
  expanded={expanded}
294
- onCloseExpand={() => setExpanded(false)}
299
+ expandMode={expandMode}
300
+ onCloseExpand={() => setExpandMode(null)}
295
301
  />
296
302
  )
297
303
  }
@@ -361,19 +367,62 @@ export default forwardRef(function LinkPreview({ id, props, onUpdate, resizable
361
367
  </div>
362
368
  {resizable && <ResizeHandle targetRef={cardRef} width={width} height={height} onResize={handleResize} />}
363
369
  </div>
364
- <SplitExpandModal
365
- expanded={expanded}
366
- onClose={() => setExpanded(false)}
367
- widgetId={id}
368
- title={title || hostname || 'Link Preview'}
369
- >
370
- <div className={styles.expandedLink}>
371
- {ogImage && <img className={styles.expandedOgImage} src={ogImage} alt="" loading="lazy" />}
372
- <h2 className={styles.expandedTitle}>{title || hostname || url || 'Untitled'}</h2>
373
- {description && <p className={styles.expandedDescription}>{description}</p>}
374
- {url && <a href={url} target="_blank" rel="noopener noreferrer" className={styles.expandedUrl}>{url}</a>}
375
- </div>
376
- </SplitExpandModal>
370
+ {expanded && (
371
+ <LinkPreviewExpandPane
372
+ widgetId={id}
373
+ label={title || hostname || 'Link Preview'}
374
+ splitMode={expandMode === 'split'}
375
+ onClose={() => setExpandMode(null)}
376
+ >
377
+ <div className={styles.expandedLink}>
378
+ {ogImage && <img className={styles.expandedOgImage} src={ogImage} alt="" loading="lazy" />}
379
+ <h2 className={styles.expandedTitle}>{title || hostname || url || 'Untitled'}</h2>
380
+ {description && <p className={styles.expandedDescription}>{description}</p>}
381
+ {url && <a href={url} target="_blank" rel="noopener noreferrer" className={styles.expandedUrl}>{url}</a>}
382
+ </div>
383
+ </LinkPreviewExpandPane>
384
+ )}
377
385
  </>
378
386
  )
379
387
  })
388
+
389
+ /**
390
+ * Builds pane configs and renders ExpandedPane for an expanded link-preview widget.
391
+ */
392
+ function LinkPreviewExpandPane({ widgetId, label, splitMode, onClose, children }) {
393
+ const connectedWidgets = useMemo(
394
+ () => splitMode ? findAllConnectedSplitTargets(widgetId) : [],
395
+ [widgetId, splitMode],
396
+ )
397
+
398
+ const primaryWidget = useMemo(() => {
399
+ const bridge = window.__storyboardCanvasBridgeState
400
+ return bridge?.widgets?.find((w) => w.id === widgetId) || { id: widgetId, type: 'link-preview', position: { x: 0, y: 0 }, props: {} }
401
+ }, [widgetId])
402
+
403
+ const buildPaneFn = useCallback((widget) => {
404
+ if (widget.id === widgetId) {
405
+ return {
406
+ id: widgetId,
407
+ label,
408
+ widgetType: 'link-preview',
409
+ kind: 'react',
410
+ render: () => children,
411
+ }
412
+ }
413
+ return buildPaneForWidget(widget)
414
+ }, [widgetId, label, children])
415
+
416
+ const layout = useMemo(
417
+ () => buildSplitLayout(primaryWidget, connectedWidgets, buildPaneFn),
418
+ [primaryWidget, connectedWidgets, buildPaneFn],
419
+ )
420
+
421
+ return (
422
+ <ExpandedPane
423
+ initialLayout={layout}
424
+ variant={layout.flat().length <= 1 ? 'modal' : 'full'}
425
+ onClose={onClose}
426
+ />
427
+ )
428
+ }
@@ -5,8 +5,9 @@ import remarkHtml from 'remark-html'
5
5
  import WidgetWrapper from './WidgetWrapper.jsx'
6
6
  import ResizeHandle from './ResizeHandle.jsx'
7
7
  import { readProp } from './widgetProps.js'
8
- import { schemas } from './widgetConfig.js'
9
- import SplitExpandModal from './SplitExpandModal.jsx'
8
+ import { schemas, getFeaturesForSurface } from './widgetConfig.js'
9
+ import ExpandedPane from './ExpandedPane.jsx'
10
+ import { findAllConnectedSplitTargets, getSplitPaneLabel, buildPaneForWidget, buildSplitLayout } from './expandUtils.js'
10
11
  import styles from './MarkdownBlock.module.css'
11
12
 
12
13
  const markdownSchema = schemas['markdown']
@@ -72,7 +73,8 @@ export default forwardRef(function MarkdownBlock({ id, props, onUpdate, resizabl
72
73
  const collapsed = !!props?.collapsed
73
74
  const canEdit = typeof onUpdate === 'function'
74
75
  const [editing, setEditing] = useState(false)
75
- const [expanded, setExpanded] = useState(false)
76
+ const [expandMode, setExpandMode] = useState(null)
77
+ const expanded = expandMode !== null
76
78
  const editingActive = canEdit && editing
77
79
  const textareaRef = useRef(null)
78
80
  const blockRef = useRef(null)
@@ -80,7 +82,8 @@ export default forwardRef(function MarkdownBlock({ id, props, onUpdate, resizabl
80
82
 
81
83
  useImperativeHandle(ref, () => ({
82
84
  handleAction(actionId) {
83
- if (actionId === 'expand' || actionId === 'split-screen') { setExpanded(true); return true }
85
+ if (actionId === 'expand' || actionId === 'expand-single') { setExpandMode('single'); return true }
86
+ if (actionId === 'split-screen') { setExpandMode('split'); return true }
84
87
  return false
85
88
  },
86
89
  }), [])
@@ -208,19 +211,141 @@ export default forwardRef(function MarkdownBlock({ id, props, onUpdate, resizabl
208
211
  )}
209
212
  </div>
210
213
  </WidgetWrapper>
211
- <SplitExpandModal
212
- expanded={expanded}
213
- onClose={() => setExpanded(false)}
214
- widgetId={id}
215
- title="Markdown"
216
- >
217
- <div
218
- className={styles.expandedPreview}
219
- dangerouslySetInnerHTML={{
220
- __html: renderedHtml || '<p>No content</p>',
221
- }}
214
+ {expanded && (
215
+ <MarkdownExpandPane
216
+ widgetId={id}
217
+ content={content}
218
+ splitMode={expandMode === 'split'}
219
+ onClose={() => setExpandMode(null)}
220
+ onUpdate={onUpdate}
222
221
  />
223
- </SplitExpandModal>
222
+ )}
224
223
  </>
225
224
  )
226
225
  })
226
+
227
+ /**
228
+ * Builds pane configs and renders ExpandedPane for an expanded markdown widget.
229
+ */
230
+ function MarkdownExpandPane({ widgetId, content, splitMode, onClose, onUpdate }) {
231
+ const [editing, setEditing] = useState(false)
232
+ const canEdit = typeof onUpdate === 'function'
233
+
234
+ const connectedWidgets = useMemo(
235
+ () => splitMode ? findAllConnectedSplitTargets(widgetId) : [],
236
+ [widgetId, splitMode],
237
+ )
238
+ const primaryWidget = useMemo(() => {
239
+ const bridge = window.__storyboardCanvasBridgeState
240
+ return bridge?.widgets?.find((w) => w.id === widgetId) || { id: widgetId, type: 'markdown', position: { x: 0, y: 0 }, props: {} }
241
+ }, [widgetId])
242
+
243
+ // Surface: fullbar for single expand, splitbar for split
244
+ const surface = splitMode ? 'splitbar' : 'fullbar'
245
+ const surfaceFeatures = useMemo(
246
+ () => canEdit ? getFeaturesForSurface('markdown', surface) : [],
247
+ [canEdit, surface],
248
+ )
249
+
250
+ const getState = useCallback((key) => {
251
+ if (key === 'editing') return editing
252
+ return undefined
253
+ }, [editing])
254
+
255
+ const handleAction = useCallback((actionId) => {
256
+ if (actionId === 'toggle-edit') {
257
+ setEditing((v) => !v)
258
+ }
259
+ }, [])
260
+
261
+ const buildPaneFn = useCallback((widget) => {
262
+ if (widget.id === widgetId) {
263
+ return {
264
+ id: widgetId,
265
+ label: getSplitPaneLabel(primaryWidget) || 'Markdown',
266
+ widgetType: 'markdown',
267
+ kind: 'react',
268
+ features: surfaceFeatures,
269
+ getState,
270
+ onAction: handleAction,
271
+ render: () => (
272
+ <ExpandedMarkdownEditor
273
+ content={content}
274
+ onUpdate={onUpdate}
275
+ editing={editing}
276
+ onToggleEdit={() => setEditing((v) => !v)}
277
+ />
278
+ ),
279
+ }
280
+ }
281
+ return buildPaneForWidget(widget, surface)
282
+ }, [widgetId, primaryWidget, content, onUpdate, editing, surfaceFeatures, getState, handleAction, surface])
283
+
284
+ const layout = useMemo(
285
+ () => buildSplitLayout(primaryWidget, connectedWidgets, buildPaneFn),
286
+ [primaryWidget, connectedWidgets, buildPaneFn],
287
+ )
288
+
289
+ return (
290
+ <ExpandedPane
291
+ initialLayout={layout}
292
+ variant={layout.flat().length <= 1 ? 'modal' : 'full'}
293
+ onClose={onClose}
294
+ />
295
+ )
296
+ }
297
+
298
+ /**
299
+ * Editable markdown view for expanded/split-screen panes.
300
+ * Self-contained: renders markdown from raw content with syntax highlighting.
301
+ * Editing state is controlled externally via props (toggle button lives in the title bar).
302
+ */
303
+ export function ExpandedMarkdownEditor({ content, onUpdate, editing, onToggleEdit }) {
304
+ const textareaRef = useRef(null)
305
+ const canEdit = typeof onUpdate === 'function'
306
+
307
+ const rawHtml = useMemo(() => renderMarkdown(content), [content])
308
+ const [renderedHtml, setRenderedHtml] = useState(rawHtml)
309
+
310
+ useEffect(() => {
311
+ setRenderedHtml(rawHtml)
312
+ if (!rawHtml.includes('<code class="language-')) return
313
+ let cancelled = false
314
+ highlightCodeBlocks(rawHtml).then((highlighted) => {
315
+ if (!cancelled) setRenderedHtml(highlighted)
316
+ })
317
+ return () => { cancelled = true }
318
+ }, [rawHtml])
319
+
320
+ useEffect(() => {
321
+ if (editing && textareaRef.current) {
322
+ const len = textareaRef.current.value.length
323
+ textareaRef.current.setSelectionRange(len, len)
324
+ textareaRef.current.focus({ preventScroll: true })
325
+ }
326
+ }, [editing])
327
+
328
+ if (editing && canEdit) {
329
+ return (
330
+ <textarea
331
+ ref={textareaRef}
332
+ className={styles.expandedEditor}
333
+ value={content}
334
+ onChange={(e) => onUpdate({ content: e.target.value })}
335
+ onKeyDown={(e) => { if (e.key === 'Escape') onToggleEdit?.() }}
336
+ placeholder="Write markdown…"
337
+ />
338
+ )
339
+ }
340
+
341
+ return (
342
+ <div
343
+ className={styles.expandedPreview}
344
+ style={{ flex: 1, overflow: 'auto' }}
345
+ onDoubleClick={canEdit ? onToggleEdit : undefined}
346
+ dangerouslySetInnerHTML={{
347
+ __html: renderedHtml || '<p>No content</p>',
348
+ }}
349
+ />
350
+ )
351
+ }
@@ -231,10 +231,15 @@
231
231
  /* ── Expanded preview in modal ──────────────────────────────────── */
232
232
 
233
233
  .expandedPreview {
234
+ --sb--markdown-bg: var(--bgColor-default, #ffffff);
235
+ --sb--markdown-fg: var(--fgColor-default, #1f2328);
236
+ --sb--markdown-muted: var(--fgColor-muted, #656d76);
237
+ --sb--markdown-accent: var(--bgColor-accent-emphasis, #2f81f7);
234
238
  padding: 32px 40px;
235
239
  font-size: 15px;
236
240
  line-height: 1.7;
237
241
  color: var(--sb--markdown-fg);
242
+ background: var(--sb--markdown-bg);
238
243
  font-family: var(--tc-font-stack, system-ui, -apple-system, sans-serif);
239
244
  max-width: 800px;
240
245
  margin: 0 auto;
@@ -350,3 +355,23 @@
350
355
  list-style: none;
351
356
  margin-left: -24px;
352
357
  }
358
+
359
+ /* ── Expanded editor ─────────────────────────────────────────────── */
360
+
361
+ .expandedEditor {
362
+ display: block;
363
+ width: 100%;
364
+ height: 100%;
365
+ box-sizing: border-box;
366
+ padding: 32px 40px;
367
+ border: none;
368
+ outline: none;
369
+ resize: none;
370
+ background: var(--bgColor-default, #ffffff);
371
+ font-family: ui-monospace, SFMono-Regular, monospace;
372
+ font-size: 14px;
373
+ line-height: 1.6;
374
+ color: var(--fgColor-default, #1f2328);
375
+ max-width: 800px;
376
+ margin: 0 auto;
377
+ }