@dfosco/storyboard-react 4.0.0-beta.4 → 4.0.0-beta.41
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 +9 -4
- package/src/Icon.jsx +179 -0
- package/src/Viewfinder.jsx +1030 -57
- package/src/Viewfinder.module.css +1524 -155
- package/src/canvas/CanvasControls.jsx +51 -2
- package/src/canvas/CanvasControls.module.css +31 -0
- package/src/canvas/CanvasPage.bridge.test.jsx +95 -10
- package/src/canvas/CanvasPage.dragdrop.test.jsx +346 -0
- package/src/canvas/CanvasPage.jsx +843 -301
- package/src/canvas/CanvasPage.module.css +73 -50
- package/src/canvas/CanvasPage.multiselect.test.jsx +13 -11
- package/src/canvas/CanvasToolbar.jsx +2 -2
- package/src/canvas/ComponentErrorBoundary.jsx +50 -0
- package/src/canvas/PageSelector.jsx +198 -0
- package/src/canvas/PageSelector.module.css +158 -0
- package/src/canvas/PageSelector.test.jsx +104 -0
- package/src/canvas/canvasApi.js +22 -8
- package/src/canvas/canvasReloadGuard.js +37 -0
- package/src/canvas/canvasReloadGuard.test.js +27 -0
- package/src/canvas/componentIsolate.jsx +135 -0
- package/src/canvas/useCanvas.js +15 -10
- package/src/canvas/widgets/CodePenEmbed.jsx +292 -0
- package/src/canvas/widgets/CodePenEmbed.module.css +161 -0
- package/src/canvas/widgets/ComponentWidget.jsx +82 -9
- package/src/canvas/widgets/ComponentWidget.module.css +14 -6
- package/src/canvas/widgets/FigmaEmbed.jsx +110 -24
- package/src/canvas/widgets/FigmaEmbed.module.css +21 -7
- package/src/canvas/widgets/LinkPreview.jsx +297 -11
- package/src/canvas/widgets/LinkPreview.module.css +386 -18
- package/src/canvas/widgets/LinkPreview.test.jsx +193 -0
- package/src/canvas/widgets/MarkdownBlock.jsx +95 -21
- package/src/canvas/widgets/MarkdownBlock.module.css +133 -2
- package/src/canvas/widgets/MarkdownBlock.test.jsx +39 -0
- package/src/canvas/widgets/PrototypeEmbed.jsx +95 -144
- package/src/canvas/widgets/PrototypeEmbed.module.css +74 -4
- package/src/canvas/widgets/StickyNote.module.css +5 -0
- package/src/canvas/widgets/StickyNote.test.jsx +9 -9
- package/src/canvas/widgets/StoryWidget.jsx +276 -0
- package/src/canvas/widgets/StoryWidget.module.css +211 -0
- package/src/canvas/widgets/WidgetChrome.jsx +76 -20
- package/src/canvas/widgets/WidgetChrome.module.css +4 -7
- package/src/canvas/widgets/WidgetWrapper.module.css +2 -0
- package/src/canvas/widgets/codepenUrl.js +75 -0
- package/src/canvas/widgets/codepenUrl.test.js +76 -0
- package/src/canvas/widgets/embedInteraction.test.jsx +235 -0
- package/src/canvas/widgets/embedOverlay.module.css +35 -0
- package/src/canvas/widgets/embedTheme.js +56 -0
- package/src/canvas/widgets/githubUrl.js +82 -0
- package/src/canvas/widgets/githubUrl.test.js +74 -0
- package/src/canvas/widgets/iframeDevLogs.js +49 -0
- package/src/canvas/widgets/iframeDevLogs.test.jsx +81 -0
- package/src/canvas/widgets/index.js +4 -0
- package/src/canvas/widgets/pasteRules.js +295 -0
- package/src/canvas/widgets/pasteRules.test.js +474 -0
- package/src/canvas/widgets/snapshotDisplay.test.jsx +259 -0
- package/src/canvas/widgets/widgetConfig.js +16 -5
- package/src/canvas/widgets/widgetConfig.test.js +34 -12
- package/src/context.jsx +141 -16
- package/src/hooks/useSceneData.js +4 -2
- package/src/story/StoryPage.jsx +117 -0
- package/src/story/StoryPage.module.css +18 -0
- package/src/vite/data-plugin.js +375 -57
- package/src/vite/data-plugin.test.js +405 -5
|
@@ -0,0 +1,292 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CodePen embed widget for canvas.
|
|
3
|
+
*
|
|
4
|
+
* Behaves like FigmaEmbed: click-to-interact overlay, iframe kept alive
|
|
5
|
+
* after deselect, expand modal, open-external action. Created via paste
|
|
6
|
+
* when a CodePen URL is pasted onto the canvas.
|
|
7
|
+
*/
|
|
8
|
+
import { forwardRef, useImperativeHandle, useMemo, useCallback, useState, useEffect, useRef } from 'react'
|
|
9
|
+
import { createPortal } from 'react-dom'
|
|
10
|
+
import WidgetWrapper from './WidgetWrapper.jsx'
|
|
11
|
+
import { readProp } from './widgetProps.js'
|
|
12
|
+
import { schemas } from './widgetConfig.js'
|
|
13
|
+
import { isCodePenUrl, toCodePenEmbedUrl, getCodePenTitle, fetchCodePenMeta } from './codepenUrl.js'
|
|
14
|
+
import { useIframeDevLogs } from './iframeDevLogs.js'
|
|
15
|
+
import styles from './CodePenEmbed.module.css'
|
|
16
|
+
import overlayStyles from './embedOverlay.module.css'
|
|
17
|
+
|
|
18
|
+
const codepenEmbedSchema = schemas['codepen-embed']
|
|
19
|
+
|
|
20
|
+
/** Feather Icons codepen icon (stroke-based) */
|
|
21
|
+
function CodePenLogo({ className }) {
|
|
22
|
+
return (
|
|
23
|
+
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
|
|
24
|
+
<polygon points="12 2 22 8.5 22 15.5 12 22 2 15.5 2 8.5 12 2" />
|
|
25
|
+
<line x1="12" y1="22" x2="12" y2="15.5" />
|
|
26
|
+
<polyline points="22 8.5 12 15.5 2 8.5" />
|
|
27
|
+
<polyline points="2 15.5 12 8.5 22 15.5" />
|
|
28
|
+
<line x1="12" y1="2" x2="12" y2="8.5" />
|
|
29
|
+
</svg>
|
|
30
|
+
)
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Stroke-based code icon for empty state */
|
|
34
|
+
function CodeIcon({ size = 32, className }) {
|
|
35
|
+
return (
|
|
36
|
+
<svg
|
|
37
|
+
className={className}
|
|
38
|
+
width={size}
|
|
39
|
+
height={size}
|
|
40
|
+
viewBox="0 0 24 24"
|
|
41
|
+
fill="none"
|
|
42
|
+
stroke="currentColor"
|
|
43
|
+
strokeWidth="2"
|
|
44
|
+
strokeLinecap="round"
|
|
45
|
+
strokeLinejoin="round"
|
|
46
|
+
aria-hidden="true"
|
|
47
|
+
>
|
|
48
|
+
<polyline points="16 18 22 12 16 6" />
|
|
49
|
+
<polyline points="8 6 2 12 8 18" />
|
|
50
|
+
</svg>
|
|
51
|
+
)
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export default forwardRef(function CodePenEmbed({ props, onUpdate, resizable }, ref) {
|
|
55
|
+
const url = readProp(props, 'url', codepenEmbedSchema)
|
|
56
|
+
const width = readProp(props, 'width', codepenEmbedSchema)
|
|
57
|
+
const height = readProp(props, 'height', codepenEmbedSchema)
|
|
58
|
+
|
|
59
|
+
const [interactive, setInteractive] = useState(false)
|
|
60
|
+
const [showIframe, setShowIframe] = useState(true)
|
|
61
|
+
const [expanded, setExpanded] = useState(false)
|
|
62
|
+
|
|
63
|
+
const iframeRef = useRef(null)
|
|
64
|
+
const embedRef = useRef(null)
|
|
65
|
+
const inlineContainerRef = useRef(null)
|
|
66
|
+
const modalContainerRef = useRef(null)
|
|
67
|
+
const teardownTimerRef = useRef(null)
|
|
68
|
+
const exitSessionRef = useRef(0)
|
|
69
|
+
|
|
70
|
+
const isValid = useMemo(() => isCodePenUrl(url), [url])
|
|
71
|
+
const embedUrl = useMemo(() => (isValid ? toCodePenEmbedUrl(url) : ''), [url, isValid])
|
|
72
|
+
const fallbackTitle = useMemo(() => (url ? getCodePenTitle(url) : 'CodePen'), [url])
|
|
73
|
+
|
|
74
|
+
// Fetch pen metadata (title + author) from CodePen oEmbed API
|
|
75
|
+
const [penMeta, setPenMeta] = useState(null)
|
|
76
|
+
useEffect(() => {
|
|
77
|
+
if (!url || !isValid) return
|
|
78
|
+
let cancelled = false
|
|
79
|
+
fetchCodePenMeta(url).then((meta) => {
|
|
80
|
+
if (!cancelled && meta) setPenMeta(meta)
|
|
81
|
+
})
|
|
82
|
+
return () => { cancelled = true }
|
|
83
|
+
}, [url, isValid])
|
|
84
|
+
|
|
85
|
+
const headerTitle = penMeta?.title
|
|
86
|
+
? `${penMeta.title} · ${penMeta.author || fallbackTitle}`
|
|
87
|
+
: fallbackTitle
|
|
88
|
+
|
|
89
|
+
useIframeDevLogs({
|
|
90
|
+
widget: 'CodePenEmbed',
|
|
91
|
+
loaded: showIframe && Boolean(embedUrl),
|
|
92
|
+
src: embedUrl,
|
|
93
|
+
})
|
|
94
|
+
|
|
95
|
+
const enterInteractive = useCallback(() => {
|
|
96
|
+
exitSessionRef.current++
|
|
97
|
+
clearTimeout(teardownTimerRef.current)
|
|
98
|
+
setShowIframe(true)
|
|
99
|
+
setInteractive(true)
|
|
100
|
+
}, [])
|
|
101
|
+
|
|
102
|
+
// Exit interactive mode on click outside — keep iframe alive for 2 min
|
|
103
|
+
useEffect(() => {
|
|
104
|
+
if (!interactive || expanded) return
|
|
105
|
+
function handlePointerDown(e) {
|
|
106
|
+
if (embedRef.current && !embedRef.current.contains(e.target)) {
|
|
107
|
+
setInteractive(false)
|
|
108
|
+
const session = ++exitSessionRef.current
|
|
109
|
+
clearTimeout(teardownTimerRef.current)
|
|
110
|
+
teardownTimerRef.current = setTimeout(() => {
|
|
111
|
+
if (exitSessionRef.current !== session) return
|
|
112
|
+
setShowIframe(false)
|
|
113
|
+
}, 2 * 60 * 1000)
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
document.addEventListener('pointerdown', handlePointerDown)
|
|
117
|
+
return () => document.removeEventListener('pointerdown', handlePointerDown)
|
|
118
|
+
}, [interactive, expanded])
|
|
119
|
+
|
|
120
|
+
useEffect(() => () => clearTimeout(teardownTimerRef.current), [])
|
|
121
|
+
|
|
122
|
+
// Close expanded modal on Escape
|
|
123
|
+
useEffect(() => {
|
|
124
|
+
if (!expanded) return
|
|
125
|
+
function handleKeyDown(e) {
|
|
126
|
+
if (e.key === 'Escape') {
|
|
127
|
+
e.stopPropagation()
|
|
128
|
+
setExpanded(false)
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
document.addEventListener('keydown', handleKeyDown, true)
|
|
132
|
+
return () => document.removeEventListener('keydown', handleKeyDown, true)
|
|
133
|
+
}, [expanded])
|
|
134
|
+
|
|
135
|
+
// Reparent iframe between inline and modal containers
|
|
136
|
+
useEffect(() => {
|
|
137
|
+
const iframe = iframeRef.current
|
|
138
|
+
if (!iframe) return
|
|
139
|
+
|
|
140
|
+
if (expanded && modalContainerRef.current) {
|
|
141
|
+
iframe._savedClassName = iframe.className
|
|
142
|
+
iframe._savedStyle = iframe.getAttribute('style') || ''
|
|
143
|
+
iframe.className = styles.expandIframe
|
|
144
|
+
iframe.removeAttribute('style')
|
|
145
|
+
const target = modalContainerRef.current
|
|
146
|
+
if (target.moveBefore) {
|
|
147
|
+
target.moveBefore(iframe, target.firstChild)
|
|
148
|
+
} else {
|
|
149
|
+
target.prepend(iframe)
|
|
150
|
+
}
|
|
151
|
+
} else if (!expanded && inlineContainerRef.current) {
|
|
152
|
+
if (iframe._savedClassName !== undefined) {
|
|
153
|
+
iframe.className = iframe._savedClassName
|
|
154
|
+
iframe.setAttribute('style', iframe._savedStyle)
|
|
155
|
+
delete iframe._savedClassName
|
|
156
|
+
delete iframe._savedStyle
|
|
157
|
+
}
|
|
158
|
+
const target = inlineContainerRef.current
|
|
159
|
+
if (target.moveBefore) {
|
|
160
|
+
target.moveBefore(iframe, null)
|
|
161
|
+
} else {
|
|
162
|
+
target.appendChild(iframe)
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
}, [expanded])
|
|
166
|
+
|
|
167
|
+
useImperativeHandle(ref, () => ({
|
|
168
|
+
handleAction(actionId) {
|
|
169
|
+
if (actionId === 'open-external') {
|
|
170
|
+
if (url) window.open(url, '_blank', 'noopener')
|
|
171
|
+
} else if (actionId === 'expand') {
|
|
172
|
+
setShowIframe(true)
|
|
173
|
+
setExpanded(true)
|
|
174
|
+
}
|
|
175
|
+
},
|
|
176
|
+
}), [url])
|
|
177
|
+
|
|
178
|
+
return (
|
|
179
|
+
<>
|
|
180
|
+
<WidgetWrapper>
|
|
181
|
+
<div ref={embedRef} className={styles.embed} style={{ width, height }}>
|
|
182
|
+
<div className={styles.header}>
|
|
183
|
+
<CodePenLogo className={styles.codepenLogo} />
|
|
184
|
+
<span className={styles.headerTitle}>{headerTitle}</span>
|
|
185
|
+
</div>
|
|
186
|
+
{embedUrl ? (
|
|
187
|
+
<>
|
|
188
|
+
{showIframe ? (
|
|
189
|
+
<div
|
|
190
|
+
ref={inlineContainerRef}
|
|
191
|
+
className={styles.iframeContainer}
|
|
192
|
+
style={expanded ? { visibility: 'hidden' } : undefined}
|
|
193
|
+
>
|
|
194
|
+
<iframe
|
|
195
|
+
ref={iframeRef}
|
|
196
|
+
src={embedUrl}
|
|
197
|
+
className={styles.iframe}
|
|
198
|
+
title={`CodePen: ${headerTitle}`}
|
|
199
|
+
allowFullScreen
|
|
200
|
+
loading="lazy"
|
|
201
|
+
/>
|
|
202
|
+
</div>
|
|
203
|
+
) : (
|
|
204
|
+
<div className={styles.iframeContainer} />
|
|
205
|
+
)}
|
|
206
|
+
{!interactive && !expanded && (
|
|
207
|
+
<div
|
|
208
|
+
className={overlayStyles.interactOverlay}
|
|
209
|
+
onClick={(e) => {
|
|
210
|
+
if (e.shiftKey || e.metaKey || e.ctrlKey || e.altKey) return
|
|
211
|
+
enterInteractive()
|
|
212
|
+
}}
|
|
213
|
+
role="button"
|
|
214
|
+
tabIndex={0}
|
|
215
|
+
onKeyDown={(e) => {
|
|
216
|
+
if (e.key === 'Enter' || e.key === ' ') {
|
|
217
|
+
e.preventDefault()
|
|
218
|
+
e.stopPropagation()
|
|
219
|
+
enterInteractive()
|
|
220
|
+
}
|
|
221
|
+
}}
|
|
222
|
+
aria-label="Click to interact with CodePen embed"
|
|
223
|
+
>
|
|
224
|
+
<span className={overlayStyles.interactHint}>Click to interact</span>
|
|
225
|
+
</div>
|
|
226
|
+
)}
|
|
227
|
+
</>
|
|
228
|
+
) : (
|
|
229
|
+
<div className={styles.emptyState}>
|
|
230
|
+
<CodeIcon size={32} className={styles.emptyIcon} />
|
|
231
|
+
<span className={styles.emptyLabel}>No CodePen URL</span>
|
|
232
|
+
</div>
|
|
233
|
+
)}
|
|
234
|
+
</div>
|
|
235
|
+
{resizable && (
|
|
236
|
+
<div
|
|
237
|
+
className={styles.resizeHandle}
|
|
238
|
+
onMouseDown={(e) => {
|
|
239
|
+
e.stopPropagation()
|
|
240
|
+
e.preventDefault()
|
|
241
|
+
const startX = e.clientX
|
|
242
|
+
const startY = e.clientY
|
|
243
|
+
const startW = width
|
|
244
|
+
const startH = height
|
|
245
|
+
function onMove(ev) {
|
|
246
|
+
const newW = Math.max(200, startW + ev.clientX - startX)
|
|
247
|
+
const newH = Math.max(150, startH + ev.clientY - startY)
|
|
248
|
+
onUpdate?.({ width: newW, height: newH })
|
|
249
|
+
}
|
|
250
|
+
function onUp() {
|
|
251
|
+
document.removeEventListener('mousemove', onMove)
|
|
252
|
+
document.removeEventListener('mouseup', onUp)
|
|
253
|
+
}
|
|
254
|
+
document.addEventListener('mousemove', onMove)
|
|
255
|
+
document.addEventListener('mouseup', onUp)
|
|
256
|
+
}}
|
|
257
|
+
onPointerDown={(e) => e.stopPropagation()}
|
|
258
|
+
/>
|
|
259
|
+
)}
|
|
260
|
+
</WidgetWrapper>
|
|
261
|
+
{createPortal(
|
|
262
|
+
<div
|
|
263
|
+
className={styles.expandBackdrop}
|
|
264
|
+
style={expanded && embedUrl ? undefined : { display: 'none' }}
|
|
265
|
+
onClick={() => setExpanded(false)}
|
|
266
|
+
onPointerDown={(e) => e.stopPropagation()}
|
|
267
|
+
onKeyDown={(e) => {
|
|
268
|
+
e.stopPropagation()
|
|
269
|
+
if (e.key === 'Escape') setExpanded(false)
|
|
270
|
+
}}
|
|
271
|
+
onWheel={(e) => e.stopPropagation()}
|
|
272
|
+
tabIndex={-1}
|
|
273
|
+
ref={(el) => { if (el && expanded) el.focus() }}
|
|
274
|
+
>
|
|
275
|
+
<div
|
|
276
|
+
ref={modalContainerRef}
|
|
277
|
+
className={styles.expandContainer}
|
|
278
|
+
onClick={(e) => e.stopPropagation()}
|
|
279
|
+
>
|
|
280
|
+
<button
|
|
281
|
+
className={styles.expandClose}
|
|
282
|
+
onClick={() => setExpanded(false)}
|
|
283
|
+
aria-label="Close expanded view"
|
|
284
|
+
autoFocus
|
|
285
|
+
>✕</button>
|
|
286
|
+
</div>
|
|
287
|
+
</div>,
|
|
288
|
+
document.body
|
|
289
|
+
)}
|
|
290
|
+
</>
|
|
291
|
+
)
|
|
292
|
+
})
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
.embed {
|
|
2
|
+
position: relative;
|
|
3
|
+
overflow: hidden;
|
|
4
|
+
background: var(--bgColor-default, #ffffff);
|
|
5
|
+
border: 3px solid var(--borderColor-default, #d0d7de);
|
|
6
|
+
border-radius: 12px;
|
|
7
|
+
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.08);
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
.header {
|
|
11
|
+
display: flex;
|
|
12
|
+
align-items: center;
|
|
13
|
+
gap: 6px;
|
|
14
|
+
padding: 6px 10px;
|
|
15
|
+
font-size: 12px;
|
|
16
|
+
font-weight: 500;
|
|
17
|
+
color: var(--fgColor-muted, #656d76);
|
|
18
|
+
background: var(--bgColor-muted, #f6f8fa);
|
|
19
|
+
border-bottom: 1px solid var(--borderColor-muted, #d8dee4);
|
|
20
|
+
white-space: nowrap;
|
|
21
|
+
overflow: hidden;
|
|
22
|
+
text-overflow: ellipsis;
|
|
23
|
+
user-select: none;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
.codepenLogo {
|
|
27
|
+
width: 16px;
|
|
28
|
+
height: 16px;
|
|
29
|
+
flex-shrink: 0;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
.headerTitle {
|
|
33
|
+
overflow: hidden;
|
|
34
|
+
text-overflow: ellipsis;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
.iframeContainer {
|
|
38
|
+
width: 100%;
|
|
39
|
+
height: calc(100% - 10px);
|
|
40
|
+
overflow: hidden;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
.iframe {
|
|
44
|
+
width: 100%;
|
|
45
|
+
height: 100%;
|
|
46
|
+
border: none;
|
|
47
|
+
display: block;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
.resizeHandle {
|
|
51
|
+
position: absolute;
|
|
52
|
+
bottom: 0;
|
|
53
|
+
right: 0;
|
|
54
|
+
width: 16px;
|
|
55
|
+
height: 16px;
|
|
56
|
+
cursor: nwse-resize;
|
|
57
|
+
background: linear-gradient(
|
|
58
|
+
135deg,
|
|
59
|
+
transparent 40%,
|
|
60
|
+
var(--borderColor-muted, rgba(0, 0, 0, 0.15)) 40%,
|
|
61
|
+
var(--borderColor-muted, rgba(0, 0, 0, 0.15)) 50%,
|
|
62
|
+
transparent 50%,
|
|
63
|
+
transparent 65%,
|
|
64
|
+
var(--borderColor-muted, rgba(0, 0, 0, 0.15)) 65%,
|
|
65
|
+
var(--borderColor-muted, rgba(0, 0, 0, 0.15)) 75%,
|
|
66
|
+
transparent 75%
|
|
67
|
+
);
|
|
68
|
+
opacity: 0;
|
|
69
|
+
transition: opacity 150ms;
|
|
70
|
+
z-index: 2;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
.embed:hover ~ .resizeHandle,
|
|
74
|
+
.resizeHandle:hover {
|
|
75
|
+
opacity: 1;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/* Expand modal */
|
|
79
|
+
.expandBackdrop {
|
|
80
|
+
position: fixed;
|
|
81
|
+
inset: 0;
|
|
82
|
+
z-index: 100000;
|
|
83
|
+
background: rgba(0, 0, 0, 0.8);
|
|
84
|
+
display: flex;
|
|
85
|
+
align-items: center;
|
|
86
|
+
justify-content: center;
|
|
87
|
+
animation: expandFadeIn 0.15s ease;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
@keyframes expandFadeIn {
|
|
91
|
+
from { opacity: 0; }
|
|
92
|
+
to { opacity: 1; }
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
.expandContainer {
|
|
96
|
+
width: 90vw;
|
|
97
|
+
height: 90vh;
|
|
98
|
+
position: relative;
|
|
99
|
+
border-radius: 12px;
|
|
100
|
+
overflow: hidden;
|
|
101
|
+
background: var(--bgColor-default, #ffffff);
|
|
102
|
+
box-shadow: 0 24px 64px rgba(0, 0, 0, 0.4);
|
|
103
|
+
animation: expandScaleIn 0.2s ease;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
@keyframes expandScaleIn {
|
|
107
|
+
from { transform: scale(0.95); opacity: 0; }
|
|
108
|
+
to { transform: scale(1); opacity: 1; }
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
.expandIframe {
|
|
112
|
+
border: none;
|
|
113
|
+
display: block;
|
|
114
|
+
width: 100%;
|
|
115
|
+
height: 100%;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
.expandClose {
|
|
119
|
+
all: unset;
|
|
120
|
+
cursor: pointer;
|
|
121
|
+
position: absolute;
|
|
122
|
+
top: 12px;
|
|
123
|
+
right: 12px;
|
|
124
|
+
width: 32px;
|
|
125
|
+
height: 32px;
|
|
126
|
+
display: flex;
|
|
127
|
+
align-items: center;
|
|
128
|
+
justify-content: center;
|
|
129
|
+
border-radius: 8px;
|
|
130
|
+
background: rgba(0, 0, 0, 0.5);
|
|
131
|
+
color: #ffffff;
|
|
132
|
+
font-size: 16px;
|
|
133
|
+
z-index: 1;
|
|
134
|
+
transition: background 100ms;
|
|
135
|
+
backdrop-filter: blur(4px);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
.expandClose:hover {
|
|
139
|
+
background: rgba(0, 0, 0, 0.7);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
.emptyState {
|
|
143
|
+
width: 100%;
|
|
144
|
+
height: calc(100% - 10px);
|
|
145
|
+
display: flex;
|
|
146
|
+
flex-direction: column;
|
|
147
|
+
align-items: center;
|
|
148
|
+
justify-content: center;
|
|
149
|
+
gap: 8px;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
.emptyIcon {
|
|
153
|
+
color: var(--fgColor-muted, #656d76);
|
|
154
|
+
opacity: 0.5;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
.emptyLabel {
|
|
158
|
+
color: var(--fgColor-muted, #656d76);
|
|
159
|
+
font-size: 13px;
|
|
160
|
+
font-style: italic;
|
|
161
|
+
}
|
|
@@ -1,19 +1,38 @@
|
|
|
1
|
-
import { useRef, useCallback, useState, useEffect } from 'react'
|
|
1
|
+
import { useRef, useCallback, useState, useEffect, useMemo } from 'react'
|
|
2
2
|
import WidgetWrapper from './WidgetWrapper.jsx'
|
|
3
3
|
import ResizeHandle from './ResizeHandle.jsx'
|
|
4
|
+
import ComponentErrorBoundary from '../ComponentErrorBoundary.jsx'
|
|
5
|
+
import { useIframeDevLogs } from './iframeDevLogs.js'
|
|
4
6
|
import styles from './ComponentWidget.module.css'
|
|
7
|
+
import overlayStyles from './embedOverlay.module.css'
|
|
5
8
|
|
|
6
9
|
/**
|
|
7
10
|
* Renders a live JSX export from a .canvas.jsx companion file.
|
|
8
|
-
*
|
|
9
|
-
*
|
|
11
|
+
*
|
|
12
|
+
* In dev mode (isLocalDev), each component is rendered inside an iframe
|
|
13
|
+
* via the /_storyboard/canvas/isolate middleware. This isolates broken
|
|
14
|
+
* components so they cannot crash the entire canvas page.
|
|
15
|
+
*
|
|
16
|
+
* In production, the component is rendered directly with an ErrorBoundary
|
|
17
|
+
* as a fallback safety net.
|
|
10
18
|
*
|
|
11
19
|
* Double-click the overlay to enter interactive mode (dropdowns, buttons work).
|
|
12
20
|
* Click outside to exit interactive mode.
|
|
13
21
|
*/
|
|
14
|
-
export default function ComponentWidget({
|
|
22
|
+
export default function ComponentWidget({
|
|
23
|
+
component: Component,
|
|
24
|
+
jsxModule,
|
|
25
|
+
exportName,
|
|
26
|
+
canvasTheme,
|
|
27
|
+
isLocalDev,
|
|
28
|
+
width,
|
|
29
|
+
height,
|
|
30
|
+
onUpdate,
|
|
31
|
+
resizable,
|
|
32
|
+
}) {
|
|
15
33
|
const containerRef = useRef(null)
|
|
16
34
|
const [interactive, setInteractive] = useState(false)
|
|
35
|
+
const [showIframe, setShowIframe] = useState(false)
|
|
17
36
|
|
|
18
37
|
const handleResize = useCallback((w, h) => {
|
|
19
38
|
onUpdate?.({ width: w, height: h })
|
|
@@ -27,13 +46,34 @@ export default function ComponentWidget({ component: Component, width, height, o
|
|
|
27
46
|
function handlePointerDown(e) {
|
|
28
47
|
if (containerRef.current && !containerRef.current.contains(e.target)) {
|
|
29
48
|
setInteractive(false)
|
|
49
|
+
setShowIframe(false)
|
|
30
50
|
}
|
|
31
51
|
}
|
|
32
52
|
document.addEventListener('pointerdown', handlePointerDown)
|
|
33
53
|
return () => document.removeEventListener('pointerdown', handlePointerDown)
|
|
34
54
|
}, [interactive])
|
|
35
55
|
|
|
36
|
-
|
|
56
|
+
// Build iframe src for dev isolation
|
|
57
|
+
const iframeSrc = useMemo(() => {
|
|
58
|
+
if (!isLocalDev || !jsxModule || !exportName) return null
|
|
59
|
+
const basePath = (import.meta.env.BASE_URL || '/').replace(/\/$/, '')
|
|
60
|
+
const params = new URLSearchParams({
|
|
61
|
+
module: jsxModule,
|
|
62
|
+
export: exportName,
|
|
63
|
+
theme: canvasTheme || 'light',
|
|
64
|
+
})
|
|
65
|
+
return `${basePath}/_storyboard/canvas/isolate?${params}`
|
|
66
|
+
}, [isLocalDev, jsxModule, exportName, canvasTheme])
|
|
67
|
+
|
|
68
|
+
const useIframe = isLocalDev && iframeSrc
|
|
69
|
+
|
|
70
|
+
useIframeDevLogs({
|
|
71
|
+
widget: 'ComponentWidget',
|
|
72
|
+
loaded: Boolean(useIframe && showIframe),
|
|
73
|
+
src: iframeSrc,
|
|
74
|
+
})
|
|
75
|
+
|
|
76
|
+
if (!useIframe && !Component) return null
|
|
37
77
|
|
|
38
78
|
const sizeStyle = {}
|
|
39
79
|
if (typeof width === 'number') sizeStyle.width = `${width}px`
|
|
@@ -43,13 +83,46 @@ export default function ComponentWidget({ component: Component, width, height, o
|
|
|
43
83
|
<WidgetWrapper>
|
|
44
84
|
<div ref={containerRef} className={styles.container} style={sizeStyle}>
|
|
45
85
|
<div className={styles.content}>
|
|
46
|
-
|
|
86
|
+
{useIframe ? (
|
|
87
|
+
showIframe ? (
|
|
88
|
+
<iframe
|
|
89
|
+
src={iframeSrc}
|
|
90
|
+
className={styles.iframe}
|
|
91
|
+
title={exportName || 'Component widget'}
|
|
92
|
+
sandbox="allow-same-origin allow-scripts"
|
|
93
|
+
/>
|
|
94
|
+
) : (
|
|
95
|
+
<div className={styles.placeholder} />
|
|
96
|
+
)
|
|
97
|
+
) : Component ? (
|
|
98
|
+
<ComponentErrorBoundary name={exportName}>
|
|
99
|
+
<Component />
|
|
100
|
+
</ComponentErrorBoundary>
|
|
101
|
+
) : null}
|
|
47
102
|
</div>
|
|
48
103
|
{!interactive && (
|
|
49
104
|
<div
|
|
50
|
-
className={
|
|
51
|
-
|
|
52
|
-
|
|
105
|
+
className={overlayStyles.interactOverlay}
|
|
106
|
+
onClick={(e) => {
|
|
107
|
+
// Don't enter interactive mode for modifier clicks (shift/meta/ctrl for multi-select)
|
|
108
|
+
if (e.shiftKey || e.metaKey || e.ctrlKey || e.altKey) return
|
|
109
|
+
if (useIframe) setShowIframe(true)
|
|
110
|
+
enterInteractive()
|
|
111
|
+
}}
|
|
112
|
+
role="button"
|
|
113
|
+
tabIndex={0}
|
|
114
|
+
onKeyDown={(e) => {
|
|
115
|
+
if (e.key === 'Enter' || e.key === ' ') {
|
|
116
|
+
e.preventDefault()
|
|
117
|
+
e.stopPropagation()
|
|
118
|
+
if (useIframe) setShowIframe(true)
|
|
119
|
+
enterInteractive()
|
|
120
|
+
}
|
|
121
|
+
}}
|
|
122
|
+
aria-label="Click to interact with component"
|
|
123
|
+
>
|
|
124
|
+
<span className={overlayStyles.interactHint}>Click to interact</span>
|
|
125
|
+
</div>
|
|
53
126
|
)}
|
|
54
127
|
{resizable && (
|
|
55
128
|
<ResizeHandle
|
|
@@ -1,8 +1,11 @@
|
|
|
1
1
|
.container {
|
|
2
2
|
position: relative;
|
|
3
|
-
overflow:
|
|
3
|
+
overflow: hidden;
|
|
4
4
|
min-width: 100px;
|
|
5
5
|
min-height: 60px;
|
|
6
|
+
background: var(--bgColor-default, #ffffff);
|
|
7
|
+
width: 100%;
|
|
8
|
+
height: 100%;
|
|
6
9
|
}
|
|
7
10
|
|
|
8
11
|
.content {
|
|
@@ -10,9 +13,14 @@
|
|
|
10
13
|
height: 100%;
|
|
11
14
|
}
|
|
12
15
|
|
|
13
|
-
.
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
16
|
+
.iframe {
|
|
17
|
+
display: block;
|
|
18
|
+
width: 100%;
|
|
19
|
+
height: 100%;
|
|
20
|
+
border: none;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
.placeholder {
|
|
24
|
+
width: 100%;
|
|
25
|
+
height: 100%;
|
|
18
26
|
}
|