@dfosco/storyboard-react 4.0.0-beta.28 → 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/widgets/CodePenEmbed.jsx +291 -0
- package/src/canvas/widgets/CodePenEmbed.module.css +161 -0
- package/src/canvas/widgets/codepenUrl.js +75 -0
- package/src/canvas/widgets/codepenUrl.test.js +76 -0
- package/src/canvas/widgets/index.js +2 -0
- package/src/canvas/widgets/widgetConfig.js +1 -1
package/package.json
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@dfosco/storyboard-react",
|
|
3
|
-
"version": "4.0.0-beta.
|
|
3
|
+
"version": "4.0.0-beta.29",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"dependencies": {
|
|
6
|
-
"@dfosco/storyboard-core": "4.0.0-beta.
|
|
7
|
-
"@dfosco/tiny-canvas": "4.0.0-beta.
|
|
6
|
+
"@dfosco/storyboard-core": "4.0.0-beta.29",
|
|
7
|
+
"@dfosco/tiny-canvas": "4.0.0-beta.29",
|
|
8
8
|
"@neodrag/react": "^2.3.1",
|
|
9
9
|
"glob": "^11.0.0",
|
|
10
10
|
"jsonc-parser": "^3.3.1",
|
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CodePen URL utilities — parse, validate, and convert CodePen URLs
|
|
3
|
+
* to their embeddable format.
|
|
4
|
+
*
|
|
5
|
+
* Supported URL formats:
|
|
6
|
+
* https://codepen.io/{user}/pen/{penId}
|
|
7
|
+
* https://codepen.io/{user}/full/{penId}
|
|
8
|
+
* https://codepen.io/{user}/details/{penId}
|
|
9
|
+
* https://codepen.io/{user}/embed/{penId}
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
const CODEPEN_RE = /^https?:\/\/codepen\.io\/([^/]+)\/(pen|full|details|embed)\/([A-Za-z0-9]+)/
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Check if a URL is a valid CodePen pen URL.
|
|
16
|
+
*/
|
|
17
|
+
export function isCodePenUrl(url) {
|
|
18
|
+
if (!url) return false
|
|
19
|
+
return CODEPEN_RE.test(url)
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Convert any CodePen pen URL to the embed format.
|
|
24
|
+
* Defaults to showing the result tab with a dark theme.
|
|
25
|
+
*/
|
|
26
|
+
export function toCodePenEmbedUrl(url) {
|
|
27
|
+
const m = url?.match(CODEPEN_RE)
|
|
28
|
+
if (!m) return ''
|
|
29
|
+
const [, user, , penId] = m
|
|
30
|
+
return `https://codepen.io/${user}/embed/${penId}?default-tab=result&editable=true`
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Extract a fallback title from a CodePen URL (user/penId).
|
|
35
|
+
*/
|
|
36
|
+
export function getCodePenTitle(url) {
|
|
37
|
+
const m = url?.match(CODEPEN_RE)
|
|
38
|
+
if (!m) return 'CodePen'
|
|
39
|
+
return `${m[1]}/${m[3]}`
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Extract the username from a CodePen URL.
|
|
44
|
+
*/
|
|
45
|
+
export function getCodePenUser(url) {
|
|
46
|
+
const m = url?.match(CODEPEN_RE)
|
|
47
|
+
return m?.[1] || ''
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** In-memory cache for oEmbed results keyed by pen URL. */
|
|
51
|
+
const _oembedCache = new Map()
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Fetch pen metadata (title, author_name) via CodePen's oEmbed API.
|
|
55
|
+
* Returns `{ title, author }` or null on failure. Results are cached.
|
|
56
|
+
*/
|
|
57
|
+
export async function fetchCodePenMeta(url) {
|
|
58
|
+
if (!url || !isCodePenUrl(url)) return null
|
|
59
|
+
if (_oembedCache.has(url)) return _oembedCache.get(url)
|
|
60
|
+
|
|
61
|
+
try {
|
|
62
|
+
const endpoint = `https://codepen.io/api/oembed?url=${encodeURIComponent(url)}&format=json`
|
|
63
|
+
const res = await fetch(endpoint)
|
|
64
|
+
if (!res.ok) return null
|
|
65
|
+
const data = await res.json()
|
|
66
|
+
const meta = {
|
|
67
|
+
title: data.title || '',
|
|
68
|
+
author: data.author_name || '',
|
|
69
|
+
}
|
|
70
|
+
_oembedCache.set(url, meta)
|
|
71
|
+
return meta
|
|
72
|
+
} catch {
|
|
73
|
+
return null
|
|
74
|
+
}
|
|
75
|
+
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for CodePen URL utilities.
|
|
3
|
+
*/
|
|
4
|
+
import { describe, it, expect } from 'vitest'
|
|
5
|
+
import { isCodePenUrl, toCodePenEmbedUrl, getCodePenTitle, getCodePenUser } from './codepenUrl.js'
|
|
6
|
+
|
|
7
|
+
describe('isCodePenUrl', () => {
|
|
8
|
+
it('returns true for pen URLs', () => {
|
|
9
|
+
expect(isCodePenUrl('https://codepen.io/Calleb/pen/jEMXgvq')).toBe(true)
|
|
10
|
+
})
|
|
11
|
+
|
|
12
|
+
it('returns true for full view URLs', () => {
|
|
13
|
+
expect(isCodePenUrl('https://codepen.io/Calleb/full/jEMXgvq')).toBe(true)
|
|
14
|
+
})
|
|
15
|
+
|
|
16
|
+
it('returns true for details URLs', () => {
|
|
17
|
+
expect(isCodePenUrl('https://codepen.io/Calleb/details/jEMXgvq')).toBe(true)
|
|
18
|
+
})
|
|
19
|
+
|
|
20
|
+
it('returns true for embed URLs', () => {
|
|
21
|
+
expect(isCodePenUrl('https://codepen.io/Calleb/embed/jEMXgvq')).toBe(true)
|
|
22
|
+
})
|
|
23
|
+
|
|
24
|
+
it('returns false for non-CodePen URLs', () => {
|
|
25
|
+
expect(isCodePenUrl('https://example.com')).toBe(false)
|
|
26
|
+
expect(isCodePenUrl('https://figma.com/design/abc')).toBe(false)
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
it('returns false for CodePen homepage', () => {
|
|
30
|
+
expect(isCodePenUrl('https://codepen.io')).toBe(false)
|
|
31
|
+
expect(isCodePenUrl('https://codepen.io/Calleb')).toBe(false)
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
it('returns false for null/empty', () => {
|
|
35
|
+
expect(isCodePenUrl(null)).toBe(false)
|
|
36
|
+
expect(isCodePenUrl('')).toBe(false)
|
|
37
|
+
})
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
describe('toCodePenEmbedUrl', () => {
|
|
41
|
+
it('converts pen URL to embed format', () => {
|
|
42
|
+
const result = toCodePenEmbedUrl('https://codepen.io/Calleb/pen/jEMXgvq')
|
|
43
|
+
expect(result).toBe('https://codepen.io/Calleb/embed/jEMXgvq?default-tab=result&editable=true')
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
it('converts full URL to embed format', () => {
|
|
47
|
+
const result = toCodePenEmbedUrl('https://codepen.io/Calleb/full/jEMXgvq')
|
|
48
|
+
expect(result).toBe('https://codepen.io/Calleb/embed/jEMXgvq?default-tab=result&editable=true')
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
it('returns empty string for invalid URL', () => {
|
|
52
|
+
expect(toCodePenEmbedUrl('https://example.com')).toBe('')
|
|
53
|
+
expect(toCodePenEmbedUrl(null)).toBe('')
|
|
54
|
+
})
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
describe('getCodePenTitle', () => {
|
|
58
|
+
it('extracts user/penId from URL', () => {
|
|
59
|
+
expect(getCodePenTitle('https://codepen.io/Calleb/pen/jEMXgvq')).toBe('Calleb/jEMXgvq')
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
it('returns "CodePen" for invalid URL', () => {
|
|
63
|
+
expect(getCodePenTitle('https://example.com')).toBe('CodePen')
|
|
64
|
+
expect(getCodePenTitle(null)).toBe('CodePen')
|
|
65
|
+
})
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
describe('getCodePenUser', () => {
|
|
69
|
+
it('extracts username', () => {
|
|
70
|
+
expect(getCodePenUser('https://codepen.io/Calleb/pen/jEMXgvq')).toBe('Calleb')
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
it('returns empty for invalid URL', () => {
|
|
74
|
+
expect(getCodePenUser('https://example.com')).toBe('')
|
|
75
|
+
})
|
|
76
|
+
})
|
|
@@ -4,6 +4,7 @@ import PrototypeEmbed from './PrototypeEmbed.jsx'
|
|
|
4
4
|
import LinkPreview from './LinkPreview.jsx'
|
|
5
5
|
import ImageWidget from './ImageWidget.jsx'
|
|
6
6
|
import FigmaEmbed from './FigmaEmbed.jsx'
|
|
7
|
+
import CodePenEmbed from './CodePenEmbed.jsx'
|
|
7
8
|
import StoryWidget from './StoryWidget.jsx'
|
|
8
9
|
|
|
9
10
|
/**
|
|
@@ -17,6 +18,7 @@ export const widgetRegistry = {
|
|
|
17
18
|
'link-preview': LinkPreview,
|
|
18
19
|
'image': ImageWidget,
|
|
19
20
|
'figma-embed': FigmaEmbed,
|
|
21
|
+
'codepen-embed': CodePenEmbed,
|
|
20
22
|
'story': StoryWidget,
|
|
21
23
|
}
|
|
22
24
|
|
|
@@ -157,6 +157,6 @@ export function getWidgetMeta(type) {
|
|
|
157
157
|
*/
|
|
158
158
|
export function getMenuWidgetTypes() {
|
|
159
159
|
return Object.entries(widgetTypes)
|
|
160
|
-
.filter(([type]) => type !== 'link-preview' && type !== 'image' && type !== 'figma-embed' && type !== 'story')
|
|
160
|
+
.filter(([type]) => type !== 'link-preview' && type !== 'image' && type !== 'figma-embed' && type !== 'codepen-embed' && type !== 'story')
|
|
161
161
|
.map(([type, def]) => ({ type, label: def.label, icon: def.icon }))
|
|
162
162
|
}
|