@dfosco/storyboard-react 4.2.0-beta.1 → 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 +5 -4
- package/src/AuthModal/AuthModal.jsx +6 -2
- package/src/BranchBar/BranchBar.jsx +20 -6
- package/src/BranchBar/BranchBar.module.css +13 -4
- package/src/BranchBar/useBranches.js +20 -6
- package/src/BranchBar/useBranches.test.js +68 -0
- package/src/CommandPalette/CommandPalette.jsx +478 -186
- package/src/CommandPalette/command-palette.css +142 -78
- package/src/Icon.jsx +157 -58
- package/src/Viewfinder.jsx +561 -191
- package/src/Viewfinder.module.css +434 -93
- package/src/Workspace.jsx +7 -0
- package/src/canvas/CanvasPage.bridge.test.jsx +14 -6
- package/src/canvas/CanvasPage.dragdrop.test.jsx +10 -6
- package/src/canvas/CanvasPage.jsx +738 -216
- package/src/canvas/CanvasPage.module.css +13 -15
- package/src/canvas/CanvasPage.multiselect.test.jsx +17 -6
- package/src/canvas/ConnectorLayer.jsx +121 -153
- package/src/canvas/ConnectorLayer.module.css +69 -0
- package/src/canvas/PageSelector.test.jsx +15 -6
- package/src/canvas/canvasApi.js +68 -2
- package/src/canvas/connectorGeometry.js +132 -0
- package/src/canvas/hotPoolDevLogs.js +25 -0
- package/src/canvas/useCanvas.js +1 -1
- 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 +62 -47
- package/src/canvas/widgets/FigmaEmbed.module.css +61 -0
- package/src/canvas/widgets/ImageWidget.jsx +130 -9
- package/src/canvas/widgets/ImageWidget.module.css +30 -0
- package/src/canvas/widgets/LinkPreview.jsx +112 -4
- package/src/canvas/widgets/LinkPreview.module.css +127 -0
- package/src/canvas/widgets/MarkdownBlock.jsx +164 -17
- package/src/canvas/widgets/MarkdownBlock.module.css +148 -0
- package/src/canvas/widgets/PromptWidget.jsx +414 -0
- package/src/canvas/widgets/PromptWidget.module.css +273 -0
- package/src/canvas/widgets/PrototypeEmbed.jsx +77 -38
- package/src/canvas/widgets/PrototypeEmbed.module.css +117 -0
- package/src/canvas/widgets/PrototypeEmbed.test.jsx +2 -2
- package/src/canvas/widgets/ResizeHandle.jsx +17 -6
- package/src/canvas/widgets/StoryWidget.jsx +72 -15
- package/src/canvas/widgets/TerminalReadWidget.jsx +146 -0
- package/src/canvas/widgets/TerminalReadWidget.module.css +94 -0
- package/src/canvas/widgets/TerminalWidget.jsx +496 -69
- package/src/canvas/widgets/TerminalWidget.module.css +271 -8
- package/src/canvas/widgets/TilesWidget.jsx +302 -0
- package/src/canvas/widgets/TilesWidget.module.css +133 -0
- package/src/canvas/widgets/WidgetChrome.jsx +73 -153
- package/src/canvas/widgets/WidgetChrome.module.css +30 -1
- package/src/canvas/widgets/embedInteraction.test.jsx +24 -26
- package/src/canvas/widgets/expandUtils.js +557 -0
- package/src/canvas/widgets/expandUtils.test.js +155 -0
- package/src/canvas/widgets/index.js +9 -0
- package/src/canvas/widgets/snapshotDisplay.test.jsx +23 -71
- 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 +55 -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/useConfig.js +14 -0
- package/src/hooks/usePrototypeReloadGuard.js +64 -0
- package/src/index.js +8 -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 +324 -30
|
@@ -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) => {
|
|
@@ -59,7 +106,7 @@ const ImageWidget = forwardRef(function ImageWidget({ props, onUpdate, resizable
|
|
|
59
106
|
const url = getImageUrl(src)
|
|
60
107
|
const a = document.createElement('a')
|
|
61
108
|
a.href = url
|
|
62
|
-
a.download = src.replace(
|
|
109
|
+
a.download = src.replace(/^~/, '')
|
|
63
110
|
document.body.appendChild(a)
|
|
64
111
|
a.click()
|
|
65
112
|
document.body.removeChild(a)
|
|
@@ -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
|
+
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
|
1
|
+
import { useCallback, useEffect, useMemo, useRef, useState, forwardRef, useImperativeHandle } from 'react'
|
|
2
2
|
import { remark } from 'remark'
|
|
3
3
|
import remarkGfm from 'remark-gfm'
|
|
4
4
|
import remarkHtml from 'remark-html'
|
|
@@ -6,6 +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 ExpandedPane from './ExpandedPane.jsx'
|
|
10
|
+
import { findAllConnectedSplitTargets, getSplitPaneLabel, buildPaneForWidget, buildSplitLayout } from './expandUtils.js'
|
|
9
11
|
import styles from './LinkPreview.module.css'
|
|
10
12
|
|
|
11
13
|
const VIDEO_EXT_RE = /\.(mp4|mov|webm|ogg)(\?[^)]*)?$/i
|
|
@@ -106,7 +108,7 @@ function getCommentKindLabel(github) {
|
|
|
106
108
|
return 'Comment'
|
|
107
109
|
}
|
|
108
110
|
|
|
109
|
-
function GitHubIssueCard({ url, title, github, width, collapsed,
|
|
111
|
+
function GitHubIssueCard({ id, url, title, github, width, collapsed, expanded, expandMode, onCloseExpand }) {
|
|
110
112
|
const authors = Array.isArray(github?.authors)
|
|
111
113
|
? github.authors.filter((a) => typeof a === 'string' && a.trim())
|
|
112
114
|
: []
|
|
@@ -146,6 +148,7 @@ function GitHubIssueCard({ url, title, github, width, collapsed, onUpdate }) {
|
|
|
146
148
|
}
|
|
147
149
|
|
|
148
150
|
return (
|
|
151
|
+
<>
|
|
149
152
|
<WidgetWrapper>
|
|
150
153
|
<div className={`${styles.issueCard} ${collapsed ? styles.issueCardCollapsed : ''}`} style={sizeStyle}>
|
|
151
154
|
<div className={styles.typeBar}>
|
|
@@ -204,10 +207,40 @@ function GitHubIssueCard({ url, title, github, width, collapsed, onUpdate }) {
|
|
|
204
207
|
)}
|
|
205
208
|
</div>
|
|
206
209
|
</WidgetWrapper>
|
|
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>}
|
|
223
|
+
</a>
|
|
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
|
+
)}
|
|
239
|
+
</>
|
|
207
240
|
)
|
|
208
241
|
}
|
|
209
242
|
|
|
210
|
-
export default function LinkPreview({ props, onUpdate, resizable }) {
|
|
243
|
+
export default forwardRef(function LinkPreview({ id, props, onUpdate, resizable }, ref) {
|
|
211
244
|
const url = readProp(props, 'url', linkPreviewSchema)
|
|
212
245
|
const title = readProp(props, 'title', linkPreviewSchema)
|
|
213
246
|
const github = props?.github && typeof props.github === 'object' ? props.github : null
|
|
@@ -222,6 +255,20 @@ export default function LinkPreview({ props, onUpdate, resizable }) {
|
|
|
222
255
|
const cardRef = useRef(null)
|
|
223
256
|
const inputRef = useRef(null)
|
|
224
257
|
const [editing, setEditing] = useState(false)
|
|
258
|
+
const [expandMode, setExpandMode] = useState(null)
|
|
259
|
+
const expanded = expandMode !== null
|
|
260
|
+
|
|
261
|
+
useImperativeHandle(ref, () => ({
|
|
262
|
+
handleAction(actionId) {
|
|
263
|
+
if (actionId === 'expand' || actionId === 'expand-single') { setExpandMode('single'); return true }
|
|
264
|
+
if (actionId === 'split-screen') { setExpandMode('split'); return true }
|
|
265
|
+
if (actionId === 'open-external') {
|
|
266
|
+
if (url) window.open(url, '_blank', 'noopener')
|
|
267
|
+
return true
|
|
268
|
+
}
|
|
269
|
+
return false
|
|
270
|
+
},
|
|
271
|
+
}), [url])
|
|
225
272
|
|
|
226
273
|
const startEditing = useCallback(() => {
|
|
227
274
|
if (!canEdit) return
|
|
@@ -242,12 +289,15 @@ export default function LinkPreview({ props, onUpdate, resizable }) {
|
|
|
242
289
|
if (github) {
|
|
243
290
|
return (
|
|
244
291
|
<GitHubIssueCard
|
|
292
|
+
id={id}
|
|
245
293
|
url={url}
|
|
246
294
|
title={title}
|
|
247
295
|
github={github}
|
|
248
296
|
width={width}
|
|
249
297
|
collapsed={!!props?.collapsed}
|
|
250
|
-
|
|
298
|
+
expanded={expanded}
|
|
299
|
+
expandMode={expandMode}
|
|
300
|
+
onCloseExpand={() => setExpandMode(null)}
|
|
251
301
|
/>
|
|
252
302
|
)
|
|
253
303
|
}
|
|
@@ -262,6 +312,7 @@ export default function LinkPreview({ props, onUpdate, resizable }) {
|
|
|
262
312
|
const handleResize = (w, h) => onUpdate?.({ width: w, height: h })
|
|
263
313
|
|
|
264
314
|
return (
|
|
315
|
+
<>
|
|
265
316
|
<div className={styles.container}>
|
|
266
317
|
<div ref={cardRef} className={styles.card} style={sizeStyle}>
|
|
267
318
|
{ogImage && (
|
|
@@ -316,5 +367,62 @@ export default function LinkPreview({ props, onUpdate, resizable }) {
|
|
|
316
367
|
</div>
|
|
317
368
|
{resizable && <ResizeHandle targetRef={cardRef} width={width} height={height} onResize={handleResize} />}
|
|
318
369
|
</div>
|
|
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
|
+
)}
|
|
385
|
+
</>
|
|
386
|
+
)
|
|
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
|
+
/>
|
|
319
427
|
)
|
|
320
428
|
}
|
|
@@ -417,3 +417,130 @@
|
|
|
417
417
|
font-size: 12px;
|
|
418
418
|
color: var(--fgColor-danger, #cf222e);
|
|
419
419
|
}
|
|
420
|
+
|
|
421
|
+
/* ── Expanded issue view in modal ─────────────────────────────────── */
|
|
422
|
+
|
|
423
|
+
.expandedIssue {
|
|
424
|
+
height: 100%;
|
|
425
|
+
display: flex;
|
|
426
|
+
flex-direction: column;
|
|
427
|
+
background: var(--bgColor-default, #ffffff);
|
|
428
|
+
font-family: var(--tc-font-stack, system-ui, -apple-system, sans-serif);
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
.expandedIssueHeader {
|
|
432
|
+
padding: 24px 40px 16px;
|
|
433
|
+
border-bottom: 1px solid var(--borderColor-muted, #d8dee4);
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
.expandedIssueTitle {
|
|
437
|
+
margin: 0 0 8px;
|
|
438
|
+
font-size: 28px;
|
|
439
|
+
font-weight: 400;
|
|
440
|
+
line-height: 1.25;
|
|
441
|
+
color: var(--fgColor-default, #1f2328);
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
.expandedIssueTitle a {
|
|
445
|
+
color: inherit;
|
|
446
|
+
text-decoration: none;
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
.expandedIssueTitle a:hover {
|
|
450
|
+
text-decoration: underline;
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
.expandedIssueNumber {
|
|
454
|
+
color: var(--fgColor-muted, #656d76);
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
.expandedByline {
|
|
458
|
+
display: flex;
|
|
459
|
+
align-items: center;
|
|
460
|
+
gap: 8px;
|
|
461
|
+
font-size: 13px;
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
.expandedAuthor {
|
|
465
|
+
display: flex;
|
|
466
|
+
align-items: center;
|
|
467
|
+
gap: 6px;
|
|
468
|
+
text-decoration: none;
|
|
469
|
+
color: var(--fgColor-default, #1f2328);
|
|
470
|
+
font-weight: 600;
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
.expandedAuthor:hover {
|
|
474
|
+
text-decoration: underline;
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
.expandedBylineText {
|
|
478
|
+
color: var(--fgColor-muted, #656d76);
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
.expandedIssueBody {
|
|
482
|
+
flex: 1;
|
|
483
|
+
overflow: auto;
|
|
484
|
+
padding: 24px 40px;
|
|
485
|
+
font-size: 15px;
|
|
486
|
+
line-height: 1.7;
|
|
487
|
+
color: var(--fgColor-default, #1f2328);
|
|
488
|
+
max-width: 800px;
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
.expandedIssueBody * { pointer-events: auto; }
|
|
492
|
+
.expandedIssueBody a { color: var(--fgColor-accent, #0969da); text-decoration: none; }
|
|
493
|
+
.expandedIssueBody a:hover { text-decoration: underline; }
|
|
494
|
+
.expandedIssueBody img { max-width: 100%; height: auto; border-radius: 6px; margin: 8px 0; display: block; }
|
|
495
|
+
.expandedIssueBody video { max-width: 100%; height: auto; border-radius: 6px; margin: 8px 0; display: block; }
|
|
496
|
+
.expandedIssueBody h1 { font-size: 20px; font-weight: 700; margin: 16px 0 8px; border-bottom: 1px solid var(--borderColor-muted, #d8dee4); padding-bottom: 4px; }
|
|
497
|
+
.expandedIssueBody h2 { font-size: 17px; font-weight: 600; margin: 14px 0 6px; }
|
|
498
|
+
.expandedIssueBody h3 { font-size: 15px; font-weight: 600; margin: 12px 0 4px; }
|
|
499
|
+
.expandedIssueBody p { margin: 0 0 12px; }
|
|
500
|
+
.expandedIssueBody code { background: var(--bgColor-neutral-muted, #afb8c133); padding: 2px 5px; border-radius: 4px; font-size: 13px; font-family: ui-monospace, SFMono-Regular, monospace; }
|
|
501
|
+
.expandedIssueBody pre { padding: 12px 16px; border-radius: 6px; border: 1px solid var(--borderColor-muted, #d8dee4); overflow-x: auto; margin: 12px 0; background: var(--bgColor-neutral-muted, #afb8c133); }
|
|
502
|
+
.expandedIssueBody pre code { background: none; padding: 0; }
|
|
503
|
+
.expandedIssueBody ul { margin: 0 0 12px; padding-left: 24px; list-style: disc; }
|
|
504
|
+
.expandedIssueBody ol { margin: 0 0 12px; padding-left: 24px; list-style: decimal; }
|
|
505
|
+
.expandedIssueBody li { margin: 0 0 4px; display: list-item; }
|
|
506
|
+
.expandedIssueBody blockquote { border-left: 4px solid var(--borderColor-default, #d0d7de); margin: 12px 0; padding: 4px 16px; color: var(--fgColor-muted, #656d76); }
|
|
507
|
+
|
|
508
|
+
/* ── Expanded plain link view ─────────────────────────────────────── */
|
|
509
|
+
|
|
510
|
+
.expandedLink {
|
|
511
|
+
padding: 32px 40px;
|
|
512
|
+
font-family: var(--tc-font-stack, system-ui, -apple-system, sans-serif);
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
.expandedOgImage {
|
|
516
|
+
max-width: 100%;
|
|
517
|
+
max-height: 400px;
|
|
518
|
+
object-fit: cover;
|
|
519
|
+
border-radius: 8px;
|
|
520
|
+
margin-bottom: 16px;
|
|
521
|
+
display: block;
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
.expandedTitle {
|
|
525
|
+
margin: 0 0 8px;
|
|
526
|
+
font-size: 24px;
|
|
527
|
+
font-weight: 600;
|
|
528
|
+
color: var(--fgColor-default, #1f2328);
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
.expandedDescription {
|
|
532
|
+
margin: 0 0 12px;
|
|
533
|
+
font-size: 15px;
|
|
534
|
+
line-height: 1.5;
|
|
535
|
+
color: var(--fgColor-muted, #656d76);
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
.expandedUrl {
|
|
539
|
+
font-size: 14px;
|
|
540
|
+
color: var(--fgColor-accent, #0969da);
|
|
541
|
+
text-decoration: none;
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
.expandedUrl:hover {
|
|
545
|
+
text-decoration: underline;
|
|
546
|
+
}
|