@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.
- package/package.json +3 -3
- package/src/BranchBar/BranchBar.jsx +3 -1
- package/src/BranchBar/BranchBar.module.css +2 -2
- package/src/BranchBar/useBranches.js +20 -6
- package/src/BranchBar/useBranches.test.js +68 -0
- package/src/CommandPalette/CommandPalette.jsx +250 -61
- package/src/CommandPalette/command-palette.css +12 -0
- package/src/Icon.jsx +46 -11
- package/src/Viewfinder.jsx +53 -133
- package/src/Viewfinder.module.css +20 -91
- package/src/Workspace.jsx +7 -0
- package/src/canvas/CanvasPage.jsx +601 -62
- package/src/canvas/CanvasPage.module.css +15 -2
- package/src/canvas/CanvasPage.multiselect.test.jsx +7 -0
- package/src/canvas/ConnectorLayer.jsx +120 -152
- package/src/canvas/ConnectorLayer.module.css +69 -0
- package/src/canvas/canvasApi.js +68 -2
- package/src/canvas/connectorGeometry.js +132 -0
- package/src/canvas/hotPoolDevLogs.js +25 -0
- package/src/canvas/useMarqueeSelect.js +30 -4
- package/src/canvas/widgets/CodePenEmbed.jsx +1 -0
- package/src/canvas/widgets/ComponentSetWidget.jsx +199 -0
- package/src/canvas/widgets/ComponentSetWidget.module.css +89 -0
- package/src/canvas/widgets/ComponentWidget.jsx +1 -0
- package/src/canvas/widgets/CropOverlay.jsx +219 -0
- package/src/canvas/widgets/CropOverlay.module.css +118 -0
- package/src/canvas/widgets/ExpandedPane.jsx +472 -0
- package/src/canvas/widgets/ExpandedPane.module.css +179 -0
- package/src/canvas/widgets/ExpandedPane.test.jsx +240 -0
- package/src/canvas/widgets/ExpandedPaneTopBar.jsx +111 -0
- package/src/canvas/widgets/ExpandedPaneTopBar.module.css +59 -0
- package/src/canvas/widgets/ExpandedPaneTopBar.test.jsx +45 -0
- package/src/canvas/widgets/FigmaEmbed.jsx +49 -102
- package/src/canvas/widgets/ImageWidget.jsx +129 -8
- package/src/canvas/widgets/ImageWidget.module.css +30 -0
- package/src/canvas/widgets/LinkPreview.jsx +93 -44
- package/src/canvas/widgets/MarkdownBlock.jsx +141 -16
- package/src/canvas/widgets/MarkdownBlock.module.css +25 -0
- package/src/canvas/widgets/PromptWidget.jsx +414 -0
- package/src/canvas/widgets/PromptWidget.module.css +273 -0
- package/src/canvas/widgets/PrototypeEmbed.jsx +46 -170
- package/src/canvas/widgets/ResizeHandle.jsx +17 -6
- package/src/canvas/widgets/StoryWidget.jsx +65 -11
- package/src/canvas/widgets/TerminalReadWidget.jsx +11 -5
- package/src/canvas/widgets/TerminalReadWidget.module.css +3 -1
- package/src/canvas/widgets/TerminalWidget.jsx +301 -124
- package/src/canvas/widgets/TerminalWidget.module.css +121 -12
- package/src/canvas/widgets/TilesWidget.jsx +302 -0
- package/src/canvas/widgets/TilesWidget.module.css +133 -0
- package/src/canvas/widgets/WidgetChrome.jsx +67 -152
- package/src/canvas/widgets/WidgetChrome.module.css +20 -1
- package/src/canvas/widgets/expandUtils.js +385 -16
- package/src/canvas/widgets/expandUtils.test.js +155 -0
- package/src/canvas/widgets/index.js +6 -2
- package/src/canvas/widgets/tilePool.js +23 -0
- package/src/canvas/widgets/tiles/diagonal-bl.png +0 -0
- package/src/canvas/widgets/tiles/diagonal-br.png +0 -0
- package/src/canvas/widgets/tiles/diagonal-tl.png +0 -0
- package/src/canvas/widgets/tiles/leaf.png +0 -0
- package/src/canvas/widgets/tiles/quarter-tl.png +0 -0
- package/src/canvas/widgets/tiles/quarter-tr.png +0 -0
- package/src/canvas/widgets/tiles/solid-a.png +0 -0
- package/src/canvas/widgets/tiles/solid-b.png +0 -0
- package/src/canvas/widgets/widgetConfig.js +37 -4
- package/src/canvas/widgets/widgetIcons.jsx +190 -0
- package/src/canvas/widgets/widgetProps.js +1 -0
- package/src/context.jsx +47 -19
- package/src/hooks/usePrototypeReloadGuard.js +64 -0
- package/src/index.js +4 -2
- package/src/story/ComponentSetPage.jsx +186 -0
- package/src/story/ComponentSetPage.module.css +121 -0
- package/src/story/StoryPage.jsx +32 -2
- package/src/vite/data-plugin.js +79 -35
- package/src/canvas/widgets/ActionWidget.jsx +0 -200
- package/src/canvas/widgets/ActionWidget.module.css +0 -122
- package/src/canvas/widgets/SplitExpandModal.jsx +0 -234
- package/src/canvas/widgets/SplitExpandModal.module.css +0 -335
- package/src/canvas/widgets/SplitScreenTopBar.jsx +0 -30
- 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
|
|
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
|
|
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
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
<
|
|
217
|
-
<
|
|
218
|
-
<
|
|
219
|
-
{
|
|
220
|
-
|
|
221
|
-
|
|
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
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
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 [
|
|
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 === '
|
|
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
|
-
|
|
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
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
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
|
|
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 [
|
|
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 === '
|
|
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
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
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
|
-
|
|
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
|
+
}
|