@dfosco/storyboard-react 4.0.0-beta.3 → 4.0.0-beta.31
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 +7 -4
- 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 +790 -302
- package/src/canvas/CanvasPage.module.css +70 -47
- 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 +102 -0
- package/src/canvas/PageSelector.module.css +93 -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 +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 +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 +319 -70
- 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 +512 -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/refreshQueue.js +108 -0
- package/src/canvas/widgets/snapshotDisplay.test.jsx +259 -0
- package/src/canvas/widgets/useSnapshotCapture.js +157 -0
- package/src/canvas/widgets/useSnapshotCapture.test.jsx +164 -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 +458 -71
- package/src/vite/data-plugin.test.js +405 -5
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
.container {
|
|
2
|
+
position: relative;
|
|
3
|
+
overflow: hidden;
|
|
4
|
+
min-width: 100px;
|
|
5
|
+
min-height: 60px;
|
|
6
|
+
background: var(--bgColor-default, #ffffff);
|
|
7
|
+
border: 3px solid var(--borderColor-default, #d0d7de);
|
|
8
|
+
border-radius: 12px;
|
|
9
|
+
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.08);
|
|
10
|
+
width: 100%;
|
|
11
|
+
height: 100%;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
.header {
|
|
15
|
+
display: flex;
|
|
16
|
+
align-items: center;
|
|
17
|
+
gap: 6px;
|
|
18
|
+
padding: 10px 10px;
|
|
19
|
+
font-size: 12px;
|
|
20
|
+
font-weight: 500;
|
|
21
|
+
color: var(--fgColor-muted, #656d76);
|
|
22
|
+
background: var(--bgColor-muted, #f6f8fa);
|
|
23
|
+
border-bottom: 1px solid var(--borderColor-muted, #d8dee4);
|
|
24
|
+
white-space: nowrap;
|
|
25
|
+
overflow: hidden;
|
|
26
|
+
text-overflow: ellipsis;
|
|
27
|
+
user-select: none;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
.headerIcon {
|
|
31
|
+
display: inline-flex;
|
|
32
|
+
flex-shrink: 0;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
.headerTitle {
|
|
36
|
+
overflow: hidden;
|
|
37
|
+
text-overflow: ellipsis;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
.content {
|
|
41
|
+
position: relative;
|
|
42
|
+
width: 100%;
|
|
43
|
+
height: calc(100% - 37px);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
.placeholder {
|
|
47
|
+
position: absolute;
|
|
48
|
+
inset: 0;
|
|
49
|
+
display: flex;
|
|
50
|
+
flex-direction: column;
|
|
51
|
+
align-items: center;
|
|
52
|
+
justify-content: center;
|
|
53
|
+
gap: 8px;
|
|
54
|
+
color: var(--fgColor-muted, #656d76);
|
|
55
|
+
text-align: center;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
.placeholderLabel {
|
|
59
|
+
font-size: 13px;
|
|
60
|
+
font-weight: 500;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
.spinner {
|
|
64
|
+
width: 24px;
|
|
65
|
+
height: 24px;
|
|
66
|
+
border: 3px solid var(--borderColor-muted, #d0d7de);
|
|
67
|
+
border-top-color: var(--fgColor-accent, #2f81f7);
|
|
68
|
+
border-radius: 50%;
|
|
69
|
+
animation: spin 0.8s linear infinite;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
@keyframes spin {
|
|
73
|
+
from { transform: rotate(0deg); }
|
|
74
|
+
to { transform: rotate(360deg); }
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
.iframe {
|
|
78
|
+
position: absolute;
|
|
79
|
+
inset: 0;
|
|
80
|
+
display: block;
|
|
81
|
+
width: 100%;
|
|
82
|
+
height: 100%;
|
|
83
|
+
border: none;
|
|
84
|
+
z-index: 1;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
.snapshotImage {
|
|
88
|
+
position: absolute;
|
|
89
|
+
inset: 0;
|
|
90
|
+
width: 100%;
|
|
91
|
+
height: 100%;
|
|
92
|
+
object-fit: cover;
|
|
93
|
+
object-position: top left;
|
|
94
|
+
display: block;
|
|
95
|
+
pointer-events: none;
|
|
96
|
+
transition: opacity 150ms ease;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
.codeView {
|
|
100
|
+
display: flex;
|
|
101
|
+
flex-direction: column;
|
|
102
|
+
height: calc(100% - 37px);
|
|
103
|
+
overflow: hidden;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
.codeHeader {
|
|
107
|
+
display: flex;
|
|
108
|
+
align-items: center;
|
|
109
|
+
justify-content: space-between;
|
|
110
|
+
padding: 4px 10px;
|
|
111
|
+
font-size: 11px;
|
|
112
|
+
font-weight: 500;
|
|
113
|
+
color: var(--fgColor-muted, #656d76);
|
|
114
|
+
background: var(--bgColor-inset, #eff2f5);
|
|
115
|
+
border-bottom: 1px solid var(--borderColor-muted, #d8dee4);
|
|
116
|
+
user-select: none;
|
|
117
|
+
flex-shrink: 0;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
.codeLabel {
|
|
121
|
+
font-family: "Ioskeley Mono", ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
.codeCloseBtn {
|
|
125
|
+
all: unset;
|
|
126
|
+
cursor: pointer;
|
|
127
|
+
display: flex;
|
|
128
|
+
align-items: center;
|
|
129
|
+
justify-content: center;
|
|
130
|
+
width: 20px;
|
|
131
|
+
height: 20px;
|
|
132
|
+
border-radius: 4px;
|
|
133
|
+
font-size: 14px;
|
|
134
|
+
color: var(--fgColor-muted, #656d76);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
.codeCloseBtn:hover {
|
|
138
|
+
background: var(--bgColor-neutral-muted, #eaeef2);
|
|
139
|
+
color: var(--fgColor-default, #1f2328);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
.codeBlock {
|
|
143
|
+
flex: 1;
|
|
144
|
+
margin: 0;
|
|
145
|
+
overflow: auto;
|
|
146
|
+
user-select: text;
|
|
147
|
+
cursor: text;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/* Style the highlighted pre from the inspector highlighter.
|
|
151
|
+
padding uses !important to override the inline padding:0 from codeToHtml. */
|
|
152
|
+
.codeBlock pre {
|
|
153
|
+
margin: 0;
|
|
154
|
+
padding: var(--base-size-8, 8px) !important;
|
|
155
|
+
font-family: "Ioskeley Mono", ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace;
|
|
156
|
+
font-size: 12px;
|
|
157
|
+
font-weight: 400;
|
|
158
|
+
line-height: 1.6;
|
|
159
|
+
tab-size: 2;
|
|
160
|
+
min-height: 100%;
|
|
161
|
+
box-sizing: border-box;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/* Fallback when no highlighted HTML (plain pre/code) */
|
|
165
|
+
.codeBlock > code {
|
|
166
|
+
font-family: "Ioskeley Mono", ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace;
|
|
167
|
+
font-size: 12px;
|
|
168
|
+
font-weight: 400;
|
|
169
|
+
line-height: 1.6;
|
|
170
|
+
white-space: pre;
|
|
171
|
+
tab-size: 2;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
.codeLoading {
|
|
175
|
+
flex: 1;
|
|
176
|
+
display: flex;
|
|
177
|
+
align-items: center;
|
|
178
|
+
justify-content: center;
|
|
179
|
+
font-size: 12px;
|
|
180
|
+
color: var(--fgColor-muted, #656d76);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
.error {
|
|
184
|
+
display: flex;
|
|
185
|
+
align-items: center;
|
|
186
|
+
gap: 8px;
|
|
187
|
+
padding: 16px;
|
|
188
|
+
color: var(--fgColor-danger, #cf222e);
|
|
189
|
+
font-family: system-ui, -apple-system, sans-serif;
|
|
190
|
+
font-size: 13px;
|
|
191
|
+
line-height: 1.5;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
.errorIcon {
|
|
195
|
+
font-size: 20px;
|
|
196
|
+
flex-shrink: 0;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
.errorText {
|
|
200
|
+
word-break: break-word;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
.loading {
|
|
204
|
+
display: flex;
|
|
205
|
+
align-items: center;
|
|
206
|
+
justify-content: center;
|
|
207
|
+
padding: 16px;
|
|
208
|
+
color: var(--fgColor-muted, #656d76);
|
|
209
|
+
font-family: system-ui, -apple-system, sans-serif;
|
|
210
|
+
font-size: 13px;
|
|
211
|
+
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { useState, useCallback, useRef, useEffect, useSyncExternalStore } from 'react'
|
|
2
2
|
import { Tooltip } from '@primer/react'
|
|
3
|
-
import { EyeIcon as OcticonEye, EyeClosedIcon as OcticonEyeClosed } from '@primer/octicons-react'
|
|
3
|
+
import { EyeIcon as OcticonEye, EyeClosedIcon as OcticonEyeClosed, CodeIcon as OcticonCode, UnwrapIcon as OcticonUnwrap, ImageIcon as OcticonImage, UnfoldIcon as OcticonUnfold, FoldIcon as OcticonFold } from '@primer/octicons-react'
|
|
4
4
|
import styles from './WidgetChrome.module.css'
|
|
5
5
|
|
|
6
6
|
const STICKY_NOTE_COLORS = {
|
|
@@ -60,6 +60,18 @@ function EyeClosedIcon() {
|
|
|
60
60
|
return <OcticonEyeClosed size={12} />
|
|
61
61
|
}
|
|
62
62
|
|
|
63
|
+
function CodeIcon() {
|
|
64
|
+
return <OcticonCode size={12} />
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function UnwrapIcon() {
|
|
68
|
+
return <OcticonUnwrap size={12} />
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function ImageIcon() {
|
|
72
|
+
return <OcticonImage size={12} />
|
|
73
|
+
}
|
|
74
|
+
|
|
63
75
|
function CopyIcon() {
|
|
64
76
|
return (
|
|
65
77
|
<svg width="12" height="12" viewBox="0 0 16 16" fill="currentColor" aria-hidden="true">
|
|
@@ -110,6 +122,22 @@ function ExpandIcon() {
|
|
|
110
122
|
)
|
|
111
123
|
}
|
|
112
124
|
|
|
125
|
+
function SyncIcon() {
|
|
126
|
+
return (
|
|
127
|
+
<svg width="12" height="12" viewBox="0 0 16 16" fill="currentColor" aria-hidden="true">
|
|
128
|
+
<path d="M1.705 8.005a.75.75 0 0 1 .834.656 5.5 5.5 0 0 0 9.592 2.97l-1.204-1.204a.25.25 0 0 1 .177-.427h3.646a.25.25 0 0 1 .25.25v3.646a.25.25 0 0 1-.427.177l-1.38-1.38A7.002 7.002 0 0 1 1.05 8.84a.75.75 0 0 1 .656-.834ZM8 2.5a5.487 5.487 0 0 0-4.131 1.869l1.204 1.204A.25.25 0 0 1 4.896 6H1.25A.25.25 0 0 1 1 5.75V2.104a.25.25 0 0 1 .427-.177l1.38 1.38A7.002 7.002 0 0 1 14.95 7.16a.75.75 0 0 1-1.49.178A5.5 5.5 0 0 0 8 2.5Z" />
|
|
129
|
+
</svg>
|
|
130
|
+
)
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function UnfoldIcon() {
|
|
134
|
+
return <OcticonUnfold size={12} />
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function FoldIcon() {
|
|
138
|
+
return <OcticonFold size={12} />
|
|
139
|
+
}
|
|
140
|
+
|
|
113
141
|
/** Icon registry — maps icon name strings from config to React components. */
|
|
114
142
|
const ICON_REGISTRY = {
|
|
115
143
|
'trash': DeleteIcon,
|
|
@@ -119,12 +147,18 @@ const ICON_REGISTRY = {
|
|
|
119
147
|
'open-external': OpenExternalIcon,
|
|
120
148
|
'eye': EyeIcon,
|
|
121
149
|
'eye-closed': EyeClosedIcon,
|
|
150
|
+
'code': CodeIcon,
|
|
151
|
+
'unwrap': UnwrapIcon,
|
|
152
|
+
'image': ImageIcon,
|
|
122
153
|
'copy': CopyIcon,
|
|
123
154
|
'link': LinkIcon,
|
|
124
155
|
'more': MoreIcon,
|
|
125
156
|
'chevron-down': ChevronDownIcon,
|
|
126
157
|
'download': DownloadIcon,
|
|
127
158
|
'expand': ExpandIcon,
|
|
159
|
+
'sync': SyncIcon,
|
|
160
|
+
'unfold': UnfoldIcon,
|
|
161
|
+
'fold': FoldIcon,
|
|
128
162
|
}
|
|
129
163
|
|
|
130
164
|
/** Danger-styled actions in the overflow menu. */
|
|
@@ -189,8 +223,8 @@ function WidgetOverflowMenu({ widgetId, menuFeatures, onAction }) {
|
|
|
189
223
|
url.searchParams.set('widget', widgetId)
|
|
190
224
|
navigator.clipboard.writeText(url.toString()).catch(() => {})
|
|
191
225
|
} else if (action === 'copy-widget-id') {
|
|
192
|
-
const
|
|
193
|
-
navigator.clipboard.writeText(`${
|
|
226
|
+
const canvasId = window.__storyboardCanvasBridgeState?.canvasId || ''
|
|
227
|
+
navigator.clipboard.writeText(`${canvasId}::${widgetId}`).catch(() => {})
|
|
194
228
|
} else {
|
|
195
229
|
onAction?.(action)
|
|
196
230
|
}
|
|
@@ -411,16 +445,22 @@ export default function WidgetChrome({
|
|
|
411
445
|
onUpdate?.({ color })
|
|
412
446
|
}, [onUpdate])
|
|
413
447
|
|
|
414
|
-
|
|
448
|
+
// In readOnly mode, features are already filtered to prod-only by getFeatures.
|
|
449
|
+
// Show toolbar if there are prod features even when readOnly.
|
|
450
|
+
const hasFeatures = features.length > 0
|
|
451
|
+
const showToolbar = (hovered || selected) && (!readOnly || hasFeatures)
|
|
415
452
|
const showFeatures = showToolbar && !multiSelected
|
|
453
|
+
const menuFeatures = features.filter((f) => f.menu)
|
|
416
454
|
|
|
417
455
|
return (
|
|
418
456
|
<div
|
|
419
457
|
className={styles.chromeContainer}
|
|
420
|
-
|
|
421
|
-
|
|
458
|
+
data-widget-id={widgetId}
|
|
459
|
+
data-tc-elevated={(hovered || selected) || undefined}
|
|
460
|
+
onMouseEnter={(readOnly && !hasFeatures) ? undefined : handleMouseEnter}
|
|
461
|
+
onMouseLeave={(readOnly && !hasFeatures) ? undefined : handleMouseLeave}
|
|
422
462
|
>
|
|
423
|
-
<div className={`tc-drag-surface ${styles.widgetSlot} ${selected ? styles.widgetSlotSelected : ''} ${multiSelected ? styles.widgetSlotMultiSelected : ''}`}>
|
|
463
|
+
<div className={`tc-drag-surface ${styles.widgetSlot} ${selected ? styles.widgetSlotSelected : ''} ${multiSelected ? styles.widgetSlotMultiSelected : ''}`} data-widget-selected={selected || undefined}>
|
|
424
464
|
{children}
|
|
425
465
|
</div>
|
|
426
466
|
<div
|
|
@@ -464,6 +504,11 @@ export default function WidgetChrome({
|
|
|
464
504
|
}
|
|
465
505
|
}
|
|
466
506
|
|
|
507
|
+
// Show-code toggle: swap label based on widget state
|
|
508
|
+
if (feature.action === 'show-code' && widgetRef?.current?.getState?.('showCode')) {
|
|
509
|
+
label = 'Show component'
|
|
510
|
+
}
|
|
511
|
+
|
|
467
512
|
return (
|
|
468
513
|
<Tooltip key={feature.id} text={label} direction="n">
|
|
469
514
|
<button
|
|
@@ -495,22 +540,33 @@ export default function WidgetChrome({
|
|
|
495
540
|
|
|
496
541
|
return null
|
|
497
542
|
})}
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
543
|
+
{menuFeatures.length > 0 && (
|
|
544
|
+
<WidgetOverflowMenu
|
|
545
|
+
widgetId={widgetId}
|
|
546
|
+
menuFeatures={menuFeatures}
|
|
547
|
+
onAction={(actionId) => {
|
|
548
|
+
// Route overflow menu actions through the widget ref first
|
|
549
|
+
if (actionId !== 'delete' && actionId !== 'copy' && widgetRef?.current?.handleAction) {
|
|
550
|
+
widgetRef.current.handleAction(actionId)
|
|
551
|
+
} else {
|
|
552
|
+
onAction?.(actionId)
|
|
553
|
+
}
|
|
554
|
+
}}
|
|
555
|
+
/>
|
|
556
|
+
)}
|
|
503
557
|
</div>
|
|
504
558
|
)}
|
|
505
559
|
|
|
506
|
-
|
|
507
|
-
<
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
560
|
+
{!readOnly && (
|
|
561
|
+
<Tooltip text={selected ? "Click and drag to move" : "Select"} direction="n">
|
|
562
|
+
<button
|
|
563
|
+
className={`tc-drag-handle ${styles.selectHandle} ${selected ? styles.selectHandleActive : ''}`}
|
|
564
|
+
onClick={handleHandleClick}
|
|
565
|
+
aria-label={selected ? "Drag to move widget" : "Select widget"}
|
|
566
|
+
aria-pressed={selected}
|
|
567
|
+
/>
|
|
568
|
+
</Tooltip>
|
|
569
|
+
)}
|
|
514
570
|
</div>
|
|
515
571
|
</div>
|
|
516
572
|
</div>
|
|
@@ -33,7 +33,7 @@
|
|
|
33
33
|
top: calc(100% + 10px);
|
|
34
34
|
}
|
|
35
35
|
|
|
36
|
-
/* Trigger dot —
|
|
36
|
+
/* Trigger dot — positioned in the toolbar, visible at rest */
|
|
37
37
|
.triggerDot {
|
|
38
38
|
width: 6px;
|
|
39
39
|
height: 6px;
|
|
@@ -41,10 +41,6 @@
|
|
|
41
41
|
background: var(--borderColor-muted, #d0d7de);
|
|
42
42
|
opacity: 0.5;
|
|
43
43
|
transition: opacity 120ms;
|
|
44
|
-
position: absolute;
|
|
45
|
-
left: 50%;
|
|
46
|
-
top: 50%;
|
|
47
|
-
transform: translate(-50%, -50%);
|
|
48
44
|
}
|
|
49
45
|
|
|
50
46
|
:global([data-sb-canvas-theme^='dark']) .triggerDot {
|
|
@@ -235,8 +231,8 @@
|
|
|
235
231
|
.overflowMenu {
|
|
236
232
|
position: absolute;
|
|
237
233
|
top: calc(100% + 10px);
|
|
238
|
-
|
|
239
|
-
min-width:
|
|
234
|
+
left: 0;
|
|
235
|
+
min-width: max-content;
|
|
240
236
|
padding: 4px;
|
|
241
237
|
background: var(--bgColor-default, #ffffff);
|
|
242
238
|
border-radius: 10px;
|
|
@@ -265,6 +261,7 @@
|
|
|
265
261
|
color: var(--fgColor-default, #1f2328);
|
|
266
262
|
border-radius: 6px;
|
|
267
263
|
box-sizing: border-box;
|
|
264
|
+
white-space: nowrap;
|
|
268
265
|
}
|
|
269
266
|
|
|
270
267
|
:global([data-sb-canvas-theme^='dark']) .overflowItem {
|
|
@@ -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
|
+
})
|