@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,37 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Canvas reload guard — client-side state for preventing HMR full reloads.
|
|
3
|
+
*
|
|
4
|
+
* This module tracks whether a canvas is currently active. When active,
|
|
5
|
+
* the Vite plugin suppresses full-page reloads to preserve canvas state.
|
|
6
|
+
*
|
|
7
|
+
* The actual guard logic is implemented in:
|
|
8
|
+
* - Server: vite.config.js (ws.send monkey-patch + heartbeat)
|
|
9
|
+
* - Client: CanvasPage.jsx (vite:beforeFullReload + vite:ws:disconnect)
|
|
10
|
+
*
|
|
11
|
+
* This module provides the state that those systems check.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
let active = false
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Enable the canvas reload guard.
|
|
18
|
+
* Call when a canvas page mounts.
|
|
19
|
+
*/
|
|
20
|
+
export function enableCanvasGuard() {
|
|
21
|
+
active = true
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Disable the canvas reload guard.
|
|
26
|
+
* Call when a canvas page unmounts.
|
|
27
|
+
*/
|
|
28
|
+
export function disableCanvasGuard() {
|
|
29
|
+
active = false
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Check if the canvas reload guard is currently active.
|
|
34
|
+
*/
|
|
35
|
+
export function isCanvasGuardActive() {
|
|
36
|
+
return active
|
|
37
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
|
2
|
+
import { enableCanvasGuard, disableCanvasGuard, isCanvasGuardActive } from './canvasReloadGuard.js'
|
|
3
|
+
|
|
4
|
+
describe('canvasReloadGuard', () => {
|
|
5
|
+
beforeEach(() => {
|
|
6
|
+
disableCanvasGuard()
|
|
7
|
+
})
|
|
8
|
+
|
|
9
|
+
it('starts inactive', () => {
|
|
10
|
+
expect(isCanvasGuardActive()).toBe(false)
|
|
11
|
+
})
|
|
12
|
+
|
|
13
|
+
it('can be enabled and disabled', () => {
|
|
14
|
+
enableCanvasGuard()
|
|
15
|
+
expect(isCanvasGuardActive()).toBe(true)
|
|
16
|
+
disableCanvasGuard()
|
|
17
|
+
expect(isCanvasGuardActive()).toBe(false)
|
|
18
|
+
})
|
|
19
|
+
|
|
20
|
+
it('enable is idempotent', () => {
|
|
21
|
+
enableCanvasGuard()
|
|
22
|
+
enableCanvasGuard()
|
|
23
|
+
expect(isCanvasGuardActive()).toBe(true)
|
|
24
|
+
disableCanvasGuard()
|
|
25
|
+
expect(isCanvasGuardActive()).toBe(false)
|
|
26
|
+
})
|
|
27
|
+
})
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Canvas Component Isolate — iframe entry point.
|
|
3
|
+
*
|
|
4
|
+
* Renders a single named export from a .canvas.jsx module inside an
|
|
5
|
+
* isolated document. The parent CanvasPage embeds this via an iframe
|
|
6
|
+
* so a broken component cannot crash the entire canvas.
|
|
7
|
+
*
|
|
8
|
+
* Query params:
|
|
9
|
+
* module — absolute or base-relative path to the .canvas.jsx file
|
|
10
|
+
* export — the named export to render
|
|
11
|
+
* theme — canvas theme (light / dark / dark_dimmed)
|
|
12
|
+
*/
|
|
13
|
+
import { createElement, Component as ReactComponent } from 'react'
|
|
14
|
+
import { createRoot } from 'react-dom/client'
|
|
15
|
+
import { ThemeProvider, BaseStyles } from '@primer/react'
|
|
16
|
+
|
|
17
|
+
// ── Primer Primitives CSS (required for CSS variables) ──────────────
|
|
18
|
+
import '@primer/primitives/dist/css/base/size/size.css'
|
|
19
|
+
import '@primer/primitives/dist/css/base/typography/typography.css'
|
|
20
|
+
import '@primer/primitives/dist/css/base/motion/motion.css'
|
|
21
|
+
import '@primer/primitives/dist/css/functional/size/border.css'
|
|
22
|
+
import '@primer/primitives/dist/css/functional/size/breakpoints.css'
|
|
23
|
+
import '@primer/primitives/dist/css/functional/size/size-coarse.css'
|
|
24
|
+
import '@primer/primitives/dist/css/functional/size/size-fine.css'
|
|
25
|
+
import '@primer/primitives/dist/css/functional/size/size.css'
|
|
26
|
+
import '@primer/primitives/dist/css/functional/size/viewport.css'
|
|
27
|
+
import '@primer/primitives/dist/css/functional/typography/typography.css'
|
|
28
|
+
import '@primer/primitives/dist/css/functional/themes/light.css'
|
|
29
|
+
import '@primer/primitives/dist/css/functional/themes/light-colorblind.css'
|
|
30
|
+
import '@primer/primitives/dist/css/functional/themes/dark.css'
|
|
31
|
+
import '@primer/primitives/dist/css/functional/themes/dark-colorblind.css'
|
|
32
|
+
import '@primer/primitives/dist/css/functional/themes/dark-high-contrast.css'
|
|
33
|
+
import '@primer/primitives/dist/css/functional/themes/dark-dimmed.css'
|
|
34
|
+
|
|
35
|
+
// ── Error Boundary ──────────────────────────────────────────────────
|
|
36
|
+
class IsolateErrorBoundary extends ReactComponent {
|
|
37
|
+
constructor(props) {
|
|
38
|
+
super(props)
|
|
39
|
+
this.state = { error: null }
|
|
40
|
+
}
|
|
41
|
+
static getDerivedStateFromError(error) {
|
|
42
|
+
return { error }
|
|
43
|
+
}
|
|
44
|
+
render() {
|
|
45
|
+
if (this.state.error) {
|
|
46
|
+
return createElement('div', { style: errorStyle },
|
|
47
|
+
createElement('strong', null, this.props.name || 'Component'),
|
|
48
|
+
createElement('br'),
|
|
49
|
+
String(this.state.error.message || this.state.error),
|
|
50
|
+
)
|
|
51
|
+
}
|
|
52
|
+
return this.props.children
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// ── Styles ──────────────────────────────────────────────────────────
|
|
57
|
+
const errorStyle = {
|
|
58
|
+
padding: '16px',
|
|
59
|
+
color: '#cf222e',
|
|
60
|
+
fontFamily: 'system-ui, -apple-system, sans-serif',
|
|
61
|
+
fontSize: '13px',
|
|
62
|
+
lineHeight: 1.5,
|
|
63
|
+
whiteSpace: 'pre-wrap',
|
|
64
|
+
wordBreak: 'break-word',
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// ── Resolve module path (mirrors useCanvas.resolveCanvasModuleImport) ─
|
|
68
|
+
function resolveModulePath(raw) {
|
|
69
|
+
if (!raw) return raw
|
|
70
|
+
if (/^[a-zA-Z][a-zA-Z\d+\-.]*:/.test(raw)) return raw
|
|
71
|
+
if (!raw.startsWith('/')) return raw
|
|
72
|
+
const base = (import.meta.env?.BASE_URL || '/').replace(/\/$/, '')
|
|
73
|
+
if (!base) return raw
|
|
74
|
+
if (raw.startsWith(base)) return raw
|
|
75
|
+
return `${base}${raw}`
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// ── Main ────────────────────────────────────────────────────────────
|
|
79
|
+
const params = new URLSearchParams(window.location.search)
|
|
80
|
+
const modulePath = params.get('module')
|
|
81
|
+
const exportName = params.get('export')
|
|
82
|
+
const theme = params.get('theme') || 'light'
|
|
83
|
+
|
|
84
|
+
// Map theme to Primer colorMode
|
|
85
|
+
const colorMode = theme.startsWith('dark') ? 'night' : 'day'
|
|
86
|
+
|
|
87
|
+
// Apply theme to document for Primer / CSS-var inheritance
|
|
88
|
+
document.documentElement.setAttribute('data-color-mode', theme.startsWith('dark') ? 'dark' : 'light')
|
|
89
|
+
document.documentElement.setAttribute('data-dark-theme', theme.startsWith('dark') ? theme : '')
|
|
90
|
+
document.documentElement.setAttribute('data-light-theme', theme.startsWith('dark') ? '' : theme || 'light')
|
|
91
|
+
|
|
92
|
+
const root = createRoot(document.getElementById('root'))
|
|
93
|
+
|
|
94
|
+
async function mount() {
|
|
95
|
+
if (!modulePath || !exportName) {
|
|
96
|
+
root.render(createElement('div', { style: errorStyle }, 'Missing module or export param'))
|
|
97
|
+
return
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// Validate: only allow .canvas.jsx and .story.{jsx,tsx} modules
|
|
101
|
+
if (!modulePath.endsWith('.canvas.jsx') && !modulePath.match(/\.story\.(jsx|tsx)$/)) {
|
|
102
|
+
root.render(createElement('div', { style: errorStyle }, 'Invalid module path — only .canvas.jsx and .story.jsx/.tsx files are allowed'))
|
|
103
|
+
return
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
try {
|
|
107
|
+
const resolved = resolveModulePath(modulePath)
|
|
108
|
+
const mod = await import(/* @vite-ignore */ resolved)
|
|
109
|
+
const Component = mod[exportName]
|
|
110
|
+
|
|
111
|
+
if (!Component || typeof Component !== 'function') {
|
|
112
|
+
throw new Error(`Export "${exportName}" not found or is not a component`)
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
root.render(
|
|
116
|
+
createElement(ThemeProvider, { colorMode },
|
|
117
|
+
createElement(BaseStyles, null,
|
|
118
|
+
createElement(IsolateErrorBoundary, { name: exportName },
|
|
119
|
+
createElement(Component),
|
|
120
|
+
),
|
|
121
|
+
),
|
|
122
|
+
),
|
|
123
|
+
)
|
|
124
|
+
} catch (err) {
|
|
125
|
+
root.render(
|
|
126
|
+
createElement('div', { style: errorStyle },
|
|
127
|
+
createElement('strong', null, exportName),
|
|
128
|
+
createElement('br'),
|
|
129
|
+
String(err.message || err),
|
|
130
|
+
),
|
|
131
|
+
)
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
mount()
|
package/src/canvas/useCanvas.js
CHANGED
|
@@ -31,13 +31,14 @@ export function resolveCanvasModuleImport(modulePath, baseUrl = import.meta.env?
|
|
|
31
31
|
* Uses build-time data for static config (routes, JSX path), but fetches
|
|
32
32
|
* fresh widget data from the server to pick up persisted edits.
|
|
33
33
|
*
|
|
34
|
-
* @param {string}
|
|
35
|
-
* @returns {{ canvas: object|null, jsxExports: object|null, loading: boolean }}
|
|
34
|
+
* @param {string} canvasId - Canonical canvas ID as indexed by the data plugin
|
|
35
|
+
* @returns {{ canvas: object|null, jsxExports: object|null, jsxError: boolean, loading: boolean }}
|
|
36
36
|
*/
|
|
37
|
-
export function useCanvas(
|
|
38
|
-
const buildTimeCanvas = useMemo(() => getCanvasData(
|
|
37
|
+
export function useCanvas(canvasId) {
|
|
38
|
+
const buildTimeCanvas = useMemo(() => getCanvasData(canvasId), [canvasId])
|
|
39
39
|
const [canvas, setCanvas] = useState(buildTimeCanvas)
|
|
40
40
|
const [jsxExports, setJsxExports] = useState(null)
|
|
41
|
+
const [jsxError, setJsxError] = useState(false)
|
|
41
42
|
const [loading, setLoading] = useState(true)
|
|
42
43
|
|
|
43
44
|
// Fetch fresh data from server on mount
|
|
@@ -49,7 +50,7 @@ export function useCanvas(name) {
|
|
|
49
50
|
}
|
|
50
51
|
|
|
51
52
|
setLoading(true)
|
|
52
|
-
fetchCanvasFromServer(
|
|
53
|
+
fetchCanvasFromServer(canvasId).then((fresh) => {
|
|
53
54
|
if (fresh) {
|
|
54
55
|
// Merge: use server data for widgets/sources, keep build-time for _route/_jsxModule
|
|
55
56
|
setCanvas({ ...buildTimeCanvas, ...fresh })
|
|
@@ -58,7 +59,7 @@ export function useCanvas(name) {
|
|
|
58
59
|
}
|
|
59
60
|
setLoading(false)
|
|
60
61
|
})
|
|
61
|
-
}, [
|
|
62
|
+
}, [canvasId, buildTimeCanvas])
|
|
62
63
|
|
|
63
64
|
const jsxModule = canvas?._jsxModule
|
|
64
65
|
const jsxImport = canvas?._jsxImport
|
|
@@ -66,6 +67,7 @@ export function useCanvas(name) {
|
|
|
66
67
|
useEffect(() => {
|
|
67
68
|
if (!jsxModule) {
|
|
68
69
|
setJsxExports(null)
|
|
70
|
+
setJsxError(false)
|
|
69
71
|
return
|
|
70
72
|
}
|
|
71
73
|
|
|
@@ -82,10 +84,12 @@ export function useCanvas(name) {
|
|
|
82
84
|
}
|
|
83
85
|
}
|
|
84
86
|
setJsxExports(exports)
|
|
87
|
+
setJsxError(false)
|
|
85
88
|
})
|
|
86
89
|
.catch((err) => {
|
|
87
90
|
console.error(`[storyboard] Failed to load canvas JSX module: ${jsxModule}`, err)
|
|
88
91
|
setJsxExports(null)
|
|
92
|
+
setJsxError(true)
|
|
89
93
|
})
|
|
90
94
|
}, [jsxModule, jsxImport])
|
|
91
95
|
|
|
@@ -95,8 +99,9 @@ export function useCanvas(name) {
|
|
|
95
99
|
if (!import.meta.hot || !buildTimeCanvas) return
|
|
96
100
|
|
|
97
101
|
const handleCanvasFileChanged = ({ data }) => {
|
|
98
|
-
|
|
99
|
-
|
|
102
|
+
const eventId = data?.canvasId || data?.name
|
|
103
|
+
if (!data || eventId !== canvasId) return
|
|
104
|
+
fetchCanvasFromServer(canvasId).then((fresh) => {
|
|
100
105
|
if (fresh) {
|
|
101
106
|
setCanvas((prev) => ({ ...(prev || buildTimeCanvas), ...fresh }))
|
|
102
107
|
}
|
|
@@ -107,7 +112,7 @@ export function useCanvas(name) {
|
|
|
107
112
|
return () => {
|
|
108
113
|
import.meta.hot.off('storyboard:canvas-file-changed', handleCanvasFileChanged)
|
|
109
114
|
}
|
|
110
|
-
}, [
|
|
115
|
+
}, [canvasId, buildTimeCanvas])
|
|
111
116
|
|
|
112
|
-
return { canvas, jsxExports, loading }
|
|
117
|
+
return { canvas, jsxExports, jsxError, loading }
|
|
113
118
|
}
|
|
@@ -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
|
+
})
|