@dfosco/storyboard-react 4.0.0-beta.27 → 4.0.0-beta.29
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/canvas/CanvasPage.bridge.test.jsx +87 -2
- package/src/canvas/CanvasPage.jsx +152 -9
- package/src/canvas/CanvasPage.module.css +54 -0
- package/src/canvas/CanvasPage.multiselect.test.jsx +2 -0
- package/src/canvas/canvasApi.js +8 -0
- package/src/canvas/widgets/CodePenEmbed.jsx +291 -0
- package/src/canvas/widgets/CodePenEmbed.module.css +161 -0
- package/src/canvas/widgets/FigmaEmbed.jsx +42 -7
- package/src/canvas/widgets/FigmaEmbed.module.css +21 -0
- package/src/canvas/widgets/LinkPreview.jsx +247 -18
- package/src/canvas/widgets/LinkPreview.module.css +349 -8
- package/src/canvas/widgets/LinkPreview.test.jsx +71 -0
- package/src/canvas/widgets/MarkdownBlock.jsx +2 -1
- package/src/canvas/widgets/MarkdownBlock.module.css +34 -11
- package/src/canvas/widgets/PrototypeEmbed.jsx +101 -44
- package/src/canvas/widgets/PrototypeEmbed.module.css +1 -0
- package/src/canvas/widgets/StoryWidget.jsx +86 -42
- package/src/canvas/widgets/StoryWidget.module.css +1 -0
- package/src/canvas/widgets/WidgetChrome.jsx +20 -1
- package/src/canvas/widgets/codepenUrl.js +75 -0
- package/src/canvas/widgets/codepenUrl.test.js +76 -0
- package/src/canvas/widgets/embedInteraction.test.jsx +16 -18
- package/src/canvas/widgets/embedTheme.js +37 -1
- package/src/canvas/widgets/githubUrl.js +82 -0
- package/src/canvas/widgets/githubUrl.test.js +74 -0
- package/src/canvas/widgets/index.js +2 -0
- package/src/canvas/widgets/refreshQueue.js +108 -0
- package/src/canvas/widgets/snapshotDisplay.test.jsx +60 -90
- package/src/canvas/widgets/useSnapshotCapture.js +38 -139
- package/src/canvas/widgets/useSnapshotCapture.test.jsx +30 -91
- package/src/canvas/widgets/widgetConfig.js +1 -1
- package/src/canvas/widgets/widgetConfig.test.js +1 -1
- package/src/story/StoryPage.jsx +25 -60
- package/src/story/StoryPage.module.css +0 -55
|
@@ -0,0 +1,291 @@
|
|
|
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
|
+
/** CodePen logo SVG */
|
|
21
|
+
function CodePenLogo({ className }) {
|
|
22
|
+
return (
|
|
23
|
+
<svg className={className} viewBox="0 0 100 100" fill="none" aria-hidden="true">
|
|
24
|
+
<path
|
|
25
|
+
d="M100 34.2c0-.4-.1-.8-.2-1.2 0-.1 0-.2-.1-.3 0-.2-.1-.3-.2-.5 0-.1-.1-.2-.1-.3-.1-.2-.1-.3-.2-.4-.1-.1-.1-.2-.2-.3-.1-.1-.1-.3-.2-.4l-.3-.3-.2-.3c-.1-.1-.2-.2-.3-.2l-.3-.3c-.1-.1-.2-.1-.3-.2l-.3-.3-.4-.2-.6-.3L52.1 2.5c-1.3-.8-2.9-.8-4.2 0L3.2 27.6l-.6.3c-.1.1-.2.1-.4.2l-.3.3c-.1.1-.2.1-.3.2l-.3.3-.3.3-.2.3c-.1.1-.2.3-.2.4-.1.1-.1.2-.2.3-.1.1-.1.3-.2.4 0 .1-.1.2-.1.3-.1.2-.1.3-.2.5 0 .1 0 .2-.1.3-.1.4-.1.8-.2 1.2v31.4c0 .4.1.8.2 1.2 0 .1 0 .2.1.3 0 .2.1.3.2.5 0 .1.1.2.1.3.1.2.1.3.2.4.1.1.1.2.2.3.1.1.1.3.2.4l.3.3.2.3c.1.1.2.2.3.2l.3.3c.1.1.2.1.3.2l.3.3.4.2.6.3 44.7 25.1c.6.4 1.4.5 2.1.5.7 0 1.4-.2 2.1-.5l44.7-25.1.6-.3c.1-.1.2-.1.4-.2l.3-.3c.1-.1.2-.1.3-.2l.3-.3.3-.3.2-.3c.1-.1.2-.3.2-.4.1-.1.1-.2.2-.3.1-.1.1-.3.2-.4 0-.1.1-.2.1-.3.1-.2.1-.3.2-.5 0-.1 0-.2.1-.3.1-.4.1-.8.2-1.2V34.2zm-50-24L88.5 33 73 43.4 54.8 32.2V10.2zM45.2 10.2v22l-18.2 11.2L11.5 33l38.5-22.8zm-40 28.4L18.4 49.8 5.2 60.9V38.6zm40 51.2L6.7 66.6 22.2 56.2l23 14.1v19.5zm4.8-24.3l-16-9.8 16-9.8 16 9.8-16 9.8zm4.8 24.3V70.3l23-14.1 15.5 10.4L54.8 89.8zm40-28.9L81.6 49.8 94.8 38.6v22.3z"
|
|
26
|
+
fill="currentColor"
|
|
27
|
+
/>
|
|
28
|
+
</svg>
|
|
29
|
+
)
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Stroke-based code icon for empty state */
|
|
33
|
+
function CodeIcon({ size = 32, className }) {
|
|
34
|
+
return (
|
|
35
|
+
<svg
|
|
36
|
+
className={className}
|
|
37
|
+
width={size}
|
|
38
|
+
height={size}
|
|
39
|
+
viewBox="0 0 24 24"
|
|
40
|
+
fill="none"
|
|
41
|
+
stroke="currentColor"
|
|
42
|
+
strokeWidth="2"
|
|
43
|
+
strokeLinecap="round"
|
|
44
|
+
strokeLinejoin="round"
|
|
45
|
+
aria-hidden="true"
|
|
46
|
+
>
|
|
47
|
+
<polyline points="16 18 22 12 16 6" />
|
|
48
|
+
<polyline points="8 6 2 12 8 18" />
|
|
49
|
+
</svg>
|
|
50
|
+
)
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export default forwardRef(function CodePenEmbed({ props, onUpdate, resizable }, ref) {
|
|
54
|
+
const url = readProp(props, 'url', codepenEmbedSchema)
|
|
55
|
+
const width = readProp(props, 'width', codepenEmbedSchema)
|
|
56
|
+
const height = readProp(props, 'height', codepenEmbedSchema)
|
|
57
|
+
|
|
58
|
+
const [interactive, setInteractive] = useState(false)
|
|
59
|
+
const [showIframe, setShowIframe] = useState(true)
|
|
60
|
+
const [expanded, setExpanded] = useState(false)
|
|
61
|
+
|
|
62
|
+
const iframeRef = useRef(null)
|
|
63
|
+
const embedRef = useRef(null)
|
|
64
|
+
const inlineContainerRef = useRef(null)
|
|
65
|
+
const modalContainerRef = useRef(null)
|
|
66
|
+
const teardownTimerRef = useRef(null)
|
|
67
|
+
const exitSessionRef = useRef(0)
|
|
68
|
+
|
|
69
|
+
const isValid = useMemo(() => isCodePenUrl(url), [url])
|
|
70
|
+
const embedUrl = useMemo(() => (isValid ? toCodePenEmbedUrl(url) : ''), [url, isValid])
|
|
71
|
+
const fallbackTitle = useMemo(() => (url ? getCodePenTitle(url) : 'CodePen'), [url])
|
|
72
|
+
|
|
73
|
+
// Fetch pen metadata (title + author) from CodePen oEmbed API
|
|
74
|
+
const [penMeta, setPenMeta] = useState(null)
|
|
75
|
+
useEffect(() => {
|
|
76
|
+
if (!url || !isValid) return
|
|
77
|
+
let cancelled = false
|
|
78
|
+
fetchCodePenMeta(url).then((meta) => {
|
|
79
|
+
if (!cancelled && meta) setPenMeta(meta)
|
|
80
|
+
})
|
|
81
|
+
return () => { cancelled = true }
|
|
82
|
+
}, [url, isValid])
|
|
83
|
+
|
|
84
|
+
const headerTitle = penMeta?.title
|
|
85
|
+
? `${penMeta.title} · ${penMeta.author || fallbackTitle}`
|
|
86
|
+
: fallbackTitle
|
|
87
|
+
|
|
88
|
+
useIframeDevLogs({
|
|
89
|
+
widget: 'CodePenEmbed',
|
|
90
|
+
loaded: showIframe && Boolean(embedUrl),
|
|
91
|
+
src: embedUrl,
|
|
92
|
+
})
|
|
93
|
+
|
|
94
|
+
const enterInteractive = useCallback(() => {
|
|
95
|
+
exitSessionRef.current++
|
|
96
|
+
clearTimeout(teardownTimerRef.current)
|
|
97
|
+
setShowIframe(true)
|
|
98
|
+
setInteractive(true)
|
|
99
|
+
}, [])
|
|
100
|
+
|
|
101
|
+
// Exit interactive mode on click outside — keep iframe alive for 2 min
|
|
102
|
+
useEffect(() => {
|
|
103
|
+
if (!interactive || expanded) return
|
|
104
|
+
function handlePointerDown(e) {
|
|
105
|
+
if (embedRef.current && !embedRef.current.contains(e.target)) {
|
|
106
|
+
setInteractive(false)
|
|
107
|
+
const session = ++exitSessionRef.current
|
|
108
|
+
clearTimeout(teardownTimerRef.current)
|
|
109
|
+
teardownTimerRef.current = setTimeout(() => {
|
|
110
|
+
if (exitSessionRef.current !== session) return
|
|
111
|
+
setShowIframe(false)
|
|
112
|
+
}, 2 * 60 * 1000)
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
document.addEventListener('pointerdown', handlePointerDown)
|
|
116
|
+
return () => document.removeEventListener('pointerdown', handlePointerDown)
|
|
117
|
+
}, [interactive, expanded])
|
|
118
|
+
|
|
119
|
+
useEffect(() => () => clearTimeout(teardownTimerRef.current), [])
|
|
120
|
+
|
|
121
|
+
// Close expanded modal on Escape
|
|
122
|
+
useEffect(() => {
|
|
123
|
+
if (!expanded) return
|
|
124
|
+
function handleKeyDown(e) {
|
|
125
|
+
if (e.key === 'Escape') {
|
|
126
|
+
e.stopPropagation()
|
|
127
|
+
setExpanded(false)
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
document.addEventListener('keydown', handleKeyDown, true)
|
|
131
|
+
return () => document.removeEventListener('keydown', handleKeyDown, true)
|
|
132
|
+
}, [expanded])
|
|
133
|
+
|
|
134
|
+
// Reparent iframe between inline and modal containers
|
|
135
|
+
useEffect(() => {
|
|
136
|
+
const iframe = iframeRef.current
|
|
137
|
+
if (!iframe) return
|
|
138
|
+
|
|
139
|
+
if (expanded && modalContainerRef.current) {
|
|
140
|
+
iframe._savedClassName = iframe.className
|
|
141
|
+
iframe._savedStyle = iframe.getAttribute('style') || ''
|
|
142
|
+
iframe.className = styles.expandIframe
|
|
143
|
+
iframe.removeAttribute('style')
|
|
144
|
+
const target = modalContainerRef.current
|
|
145
|
+
if (target.moveBefore) {
|
|
146
|
+
target.moveBefore(iframe, target.firstChild)
|
|
147
|
+
} else {
|
|
148
|
+
target.prepend(iframe)
|
|
149
|
+
}
|
|
150
|
+
} else if (!expanded && inlineContainerRef.current) {
|
|
151
|
+
if (iframe._savedClassName !== undefined) {
|
|
152
|
+
iframe.className = iframe._savedClassName
|
|
153
|
+
iframe.setAttribute('style', iframe._savedStyle)
|
|
154
|
+
delete iframe._savedClassName
|
|
155
|
+
delete iframe._savedStyle
|
|
156
|
+
}
|
|
157
|
+
const target = inlineContainerRef.current
|
|
158
|
+
if (target.moveBefore) {
|
|
159
|
+
target.moveBefore(iframe, null)
|
|
160
|
+
} else {
|
|
161
|
+
target.appendChild(iframe)
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
}, [expanded])
|
|
165
|
+
|
|
166
|
+
useImperativeHandle(ref, () => ({
|
|
167
|
+
handleAction(actionId) {
|
|
168
|
+
if (actionId === 'open-external') {
|
|
169
|
+
if (url) window.open(url, '_blank', 'noopener')
|
|
170
|
+
} else if (actionId === 'expand') {
|
|
171
|
+
setShowIframe(true)
|
|
172
|
+
setExpanded(true)
|
|
173
|
+
}
|
|
174
|
+
},
|
|
175
|
+
}), [url])
|
|
176
|
+
|
|
177
|
+
return (
|
|
178
|
+
<>
|
|
179
|
+
<WidgetWrapper>
|
|
180
|
+
<div ref={embedRef} className={styles.embed} style={{ width, height }}>
|
|
181
|
+
<div className={styles.header}>
|
|
182
|
+
<CodePenLogo className={styles.codepenLogo} />
|
|
183
|
+
<span className={styles.headerTitle}>{headerTitle}</span>
|
|
184
|
+
</div>
|
|
185
|
+
{embedUrl ? (
|
|
186
|
+
<>
|
|
187
|
+
{showIframe ? (
|
|
188
|
+
<div
|
|
189
|
+
ref={inlineContainerRef}
|
|
190
|
+
className={styles.iframeContainer}
|
|
191
|
+
style={expanded ? { visibility: 'hidden' } : undefined}
|
|
192
|
+
>
|
|
193
|
+
<iframe
|
|
194
|
+
ref={iframeRef}
|
|
195
|
+
src={embedUrl}
|
|
196
|
+
className={styles.iframe}
|
|
197
|
+
title={`CodePen: ${headerTitle}`}
|
|
198
|
+
allowFullScreen
|
|
199
|
+
loading="lazy"
|
|
200
|
+
/>
|
|
201
|
+
</div>
|
|
202
|
+
) : (
|
|
203
|
+
<div className={styles.iframeContainer} />
|
|
204
|
+
)}
|
|
205
|
+
{!interactive && !expanded && (
|
|
206
|
+
<div
|
|
207
|
+
className={overlayStyles.interactOverlay}
|
|
208
|
+
onClick={(e) => {
|
|
209
|
+
if (e.shiftKey || e.metaKey || e.ctrlKey || e.altKey) return
|
|
210
|
+
enterInteractive()
|
|
211
|
+
}}
|
|
212
|
+
role="button"
|
|
213
|
+
tabIndex={0}
|
|
214
|
+
onKeyDown={(e) => {
|
|
215
|
+
if (e.key === 'Enter' || e.key === ' ') {
|
|
216
|
+
e.preventDefault()
|
|
217
|
+
e.stopPropagation()
|
|
218
|
+
enterInteractive()
|
|
219
|
+
}
|
|
220
|
+
}}
|
|
221
|
+
aria-label="Click to interact with CodePen embed"
|
|
222
|
+
>
|
|
223
|
+
<span className={overlayStyles.interactHint}>Click to interact</span>
|
|
224
|
+
</div>
|
|
225
|
+
)}
|
|
226
|
+
</>
|
|
227
|
+
) : (
|
|
228
|
+
<div className={styles.emptyState}>
|
|
229
|
+
<CodeIcon size={32} className={styles.emptyIcon} />
|
|
230
|
+
<span className={styles.emptyLabel}>No CodePen URL</span>
|
|
231
|
+
</div>
|
|
232
|
+
)}
|
|
233
|
+
</div>
|
|
234
|
+
{resizable && (
|
|
235
|
+
<div
|
|
236
|
+
className={styles.resizeHandle}
|
|
237
|
+
onMouseDown={(e) => {
|
|
238
|
+
e.stopPropagation()
|
|
239
|
+
e.preventDefault()
|
|
240
|
+
const startX = e.clientX
|
|
241
|
+
const startY = e.clientY
|
|
242
|
+
const startW = width
|
|
243
|
+
const startH = height
|
|
244
|
+
function onMove(ev) {
|
|
245
|
+
const newW = Math.max(200, startW + ev.clientX - startX)
|
|
246
|
+
const newH = Math.max(150, startH + ev.clientY - startY)
|
|
247
|
+
onUpdate?.({ width: newW, height: newH })
|
|
248
|
+
}
|
|
249
|
+
function onUp() {
|
|
250
|
+
document.removeEventListener('mousemove', onMove)
|
|
251
|
+
document.removeEventListener('mouseup', onUp)
|
|
252
|
+
}
|
|
253
|
+
document.addEventListener('mousemove', onMove)
|
|
254
|
+
document.addEventListener('mouseup', onUp)
|
|
255
|
+
}}
|
|
256
|
+
onPointerDown={(e) => e.stopPropagation()}
|
|
257
|
+
/>
|
|
258
|
+
)}
|
|
259
|
+
</WidgetWrapper>
|
|
260
|
+
{createPortal(
|
|
261
|
+
<div
|
|
262
|
+
className={styles.expandBackdrop}
|
|
263
|
+
style={expanded && embedUrl ? undefined : { display: 'none' }}
|
|
264
|
+
onClick={() => setExpanded(false)}
|
|
265
|
+
onPointerDown={(e) => e.stopPropagation()}
|
|
266
|
+
onKeyDown={(e) => {
|
|
267
|
+
e.stopPropagation()
|
|
268
|
+
if (e.key === 'Escape') setExpanded(false)
|
|
269
|
+
}}
|
|
270
|
+
onWheel={(e) => e.stopPropagation()}
|
|
271
|
+
tabIndex={-1}
|
|
272
|
+
ref={(el) => { if (el && expanded) el.focus() }}
|
|
273
|
+
>
|
|
274
|
+
<div
|
|
275
|
+
ref={modalContainerRef}
|
|
276
|
+
className={styles.expandContainer}
|
|
277
|
+
onClick={(e) => e.stopPropagation()}
|
|
278
|
+
>
|
|
279
|
+
<button
|
|
280
|
+
className={styles.expandClose}
|
|
281
|
+
onClick={() => setExpanded(false)}
|
|
282
|
+
aria-label="Close expanded view"
|
|
283
|
+
autoFocus
|
|
284
|
+
>✕</button>
|
|
285
|
+
</div>
|
|
286
|
+
</div>,
|
|
287
|
+
document.body
|
|
288
|
+
)}
|
|
289
|
+
</>
|
|
290
|
+
)
|
|
291
|
+
})
|
|
@@ -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
|
+
}
|
|
@@ -10,6 +10,30 @@ import overlayStyles from './embedOverlay.module.css'
|
|
|
10
10
|
|
|
11
11
|
const figmaEmbedSchema = schemas['figma-embed']
|
|
12
12
|
|
|
13
|
+
/** Feather-icons figma icon (monochrome, stroke-based) */
|
|
14
|
+
function FigmaIcon({ size = 32, className }) {
|
|
15
|
+
return (
|
|
16
|
+
<svg
|
|
17
|
+
className={className}
|
|
18
|
+
width={size}
|
|
19
|
+
height={size}
|
|
20
|
+
viewBox="0 0 24 24"
|
|
21
|
+
fill="none"
|
|
22
|
+
stroke="currentColor"
|
|
23
|
+
strokeWidth="2"
|
|
24
|
+
strokeLinecap="round"
|
|
25
|
+
strokeLinejoin="round"
|
|
26
|
+
aria-hidden="true"
|
|
27
|
+
>
|
|
28
|
+
<path d="M5 5.5A3.5 3.5 0 0 1 8.5 2H12v7H8.5A3.5 3.5 0 0 1 5 5.5z" />
|
|
29
|
+
<path d="M12 2h3.5a3.5 3.5 0 1 1 0 7H12V2z" />
|
|
30
|
+
<path d="M12 12.5a3.5 3.5 0 1 1 7 0 3.5 3.5 0 1 1-7 0z" />
|
|
31
|
+
<path d="M5 19.5A3.5 3.5 0 0 1 8.5 16H12v3.5a3.5 3.5 0 1 1-7 0z" />
|
|
32
|
+
<path d="M5 12.5A3.5 3.5 0 0 1 8.5 9H12v7H8.5A3.5 3.5 0 0 1 5 12.5z" />
|
|
33
|
+
</svg>
|
|
34
|
+
)
|
|
35
|
+
}
|
|
36
|
+
|
|
13
37
|
/** Inline Figma logo SVG */
|
|
14
38
|
function FigmaLogo() {
|
|
15
39
|
return (
|
|
@@ -31,13 +55,15 @@ export default forwardRef(function FigmaEmbed({ props, onUpdate, resizable }, re
|
|
|
31
55
|
const height = readProp(props, 'height', figmaEmbedSchema)
|
|
32
56
|
|
|
33
57
|
const [interactive, setInteractive] = useState(false)
|
|
34
|
-
const [showIframe, setShowIframe] = useState(
|
|
58
|
+
const [showIframe, setShowIframe] = useState(true)
|
|
35
59
|
const [expanded, setExpanded] = useState(false)
|
|
36
60
|
|
|
37
61
|
const iframeRef = useRef(null)
|
|
38
62
|
const embedRef = useRef(null)
|
|
39
63
|
const inlineContainerRef = useRef(null)
|
|
40
64
|
const modalContainerRef = useRef(null)
|
|
65
|
+
const teardownTimerRef = useRef(null)
|
|
66
|
+
const exitSessionRef = useRef(0)
|
|
41
67
|
|
|
42
68
|
// Validate URL at render time — only embed known Figma URLs
|
|
43
69
|
const isValid = useMemo(() => isFigmaUrl(url), [url])
|
|
@@ -53,6 +79,8 @@ export default forwardRef(function FigmaEmbed({ props, onUpdate, resizable }, re
|
|
|
53
79
|
})
|
|
54
80
|
|
|
55
81
|
const enterInteractive = useCallback(() => {
|
|
82
|
+
exitSessionRef.current++
|
|
83
|
+
clearTimeout(teardownTimerRef.current)
|
|
56
84
|
setShowIframe(true)
|
|
57
85
|
setInteractive(true)
|
|
58
86
|
}, [])
|
|
@@ -62,13 +90,21 @@ export default forwardRef(function FigmaEmbed({ props, onUpdate, resizable }, re
|
|
|
62
90
|
function handlePointerDown(e) {
|
|
63
91
|
if (embedRef.current && !embedRef.current.contains(e.target)) {
|
|
64
92
|
setInteractive(false)
|
|
65
|
-
|
|
93
|
+
// Keep iframe alive for 5 min — Figma is slow to reload
|
|
94
|
+
const session = ++exitSessionRef.current
|
|
95
|
+
clearTimeout(teardownTimerRef.current)
|
|
96
|
+
teardownTimerRef.current = setTimeout(() => {
|
|
97
|
+
if (exitSessionRef.current !== session) return
|
|
98
|
+
setShowIframe(false)
|
|
99
|
+
}, 5 * 60 * 1000)
|
|
66
100
|
}
|
|
67
101
|
}
|
|
68
102
|
document.addEventListener('pointerdown', handlePointerDown)
|
|
69
103
|
return () => document.removeEventListener('pointerdown', handlePointerDown)
|
|
70
104
|
}, [interactive, expanded])
|
|
71
105
|
|
|
106
|
+
useEffect(() => () => clearTimeout(teardownTimerRef.current), [])
|
|
107
|
+
|
|
72
108
|
// Close expanded modal on Escape
|
|
73
109
|
useEffect(() => {
|
|
74
110
|
if (!expanded) return
|
|
@@ -178,11 +214,10 @@ export default forwardRef(function FigmaEmbed({ props, onUpdate, resizable }, re
|
|
|
178
214
|
)}
|
|
179
215
|
</>
|
|
180
216
|
) : (
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
</div>
|
|
217
|
+
<div className={styles.emptyState}>
|
|
218
|
+
<FigmaIcon size={32} className={styles.emptyIcon} />
|
|
219
|
+
<span className={styles.emptyLabel}>No Figma URL</span>
|
|
220
|
+
</div>
|
|
186
221
|
)}
|
|
187
222
|
</div>
|
|
188
223
|
{resizable && (
|
|
@@ -138,3 +138,24 @@
|
|
|
138
138
|
.expandClose:hover {
|
|
139
139
|
background: rgba(0, 0, 0, 0.7);
|
|
140
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
|
+
}
|