@dfosco/storyboard-react 4.0.0-beta.8 → 4.0.0
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 +6 -3
- package/src/AuthModal/AuthModal.jsx +134 -0
- package/src/AuthModal/AuthModal.module.css +221 -0
- package/src/BranchBar/BranchBar.jsx +56 -0
- package/src/BranchBar/BranchBar.module.css +230 -0
- package/src/BranchBar/useBranches.js +79 -0
- package/src/CommandPalette/CommandPalette.jsx +936 -0
- package/src/CommandPalette/CreateDialog.jsx +219 -0
- package/src/CommandPalette/command-palette.css +111 -0
- package/src/Icon.jsx +180 -0
- package/src/Viewfinder.jsx +1104 -57
- package/src/Viewfinder.module.css +1107 -149
- package/src/canvas/CanvasControls.jsx +51 -2
- package/src/canvas/CanvasControls.module.css +31 -0
- package/src/canvas/CanvasPage.bridge.test.jsx +142 -19
- package/src/canvas/CanvasPage.dragdrop.test.jsx +346 -0
- package/src/canvas/CanvasPage.jsx +807 -251
- package/src/canvas/CanvasPage.module.css +98 -50
- package/src/canvas/CanvasPage.multiselect.test.jsx +13 -11
- package/src/canvas/CanvasToolbar.jsx +2 -2
- package/src/canvas/MarqueeOverlay.jsx +20 -0
- package/src/canvas/PageSelector.jsx +239 -0
- package/src/canvas/PageSelector.module.css +165 -0
- package/src/canvas/PageSelector.test.jsx +104 -0
- package/src/canvas/canvasApi.js +22 -8
- package/src/canvas/canvasTheme.js +96 -52
- package/src/canvas/componentIsolate.jsx +33 -7
- package/src/canvas/useCanvas.js +9 -8
- package/src/canvas/useCanvas.test.js +4 -4
- package/src/canvas/useMarqueeSelect.js +187 -0
- package/src/canvas/useMarqueeSelect.test.js +78 -0
- package/src/canvas/widgets/CodePenEmbed.jsx +292 -0
- package/src/canvas/widgets/CodePenEmbed.module.css +161 -0
- package/src/canvas/widgets/ComponentWidget.jsx +42 -10
- package/src/canvas/widgets/ComponentWidget.module.css +6 -5
- package/src/canvas/widgets/FigmaEmbed.jsx +110 -24
- package/src/canvas/widgets/FigmaEmbed.module.css +21 -7
- package/src/canvas/widgets/LinkPreview.jsx +297 -11
- package/src/canvas/widgets/LinkPreview.module.css +386 -18
- package/src/canvas/widgets/LinkPreview.test.jsx +193 -0
- package/src/canvas/widgets/MarkdownBlock.jsx +86 -5
- package/src/canvas/widgets/MarkdownBlock.module.css +64 -15
- package/src/canvas/widgets/PrototypeEmbed.jsx +96 -145
- 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 +277 -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 +2 -6
- 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 +138 -39
- package/src/canvas/widgets/githubUrl.js +82 -0
- package/src/canvas/widgets/githubUrl.test.js +74 -0
- package/src/canvas/widgets/iframeDevLogs.js +49 -0
- package/src/canvas/widgets/iframeDevLogs.test.jsx +81 -0
- package/src/canvas/widgets/index.js +4 -0
- package/src/canvas/widgets/pasteRules.js +295 -0
- package/src/canvas/widgets/pasteRules.test.js +474 -0
- package/src/canvas/widgets/snapshotDisplay.test.jsx +259 -0
- package/src/canvas/widgets/widgetConfig.js +16 -5
- package/src/canvas/widgets/widgetConfig.test.js +34 -12
- package/src/context.jsx +145 -16
- package/src/hooks/useSceneData.js +4 -2
- package/src/hooks/useThemeState.js +61 -0
- package/src/hooks/useThemeState.test.js +66 -0
- package/src/index.js +10 -0
- package/src/story/StoryPage.jsx +117 -0
- package/src/story/StoryPage.module.css +18 -0
- package/src/vite/data-plugin.js +348 -66
- package/src/vite/data-plugin.test.js +405 -5
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
import { useCallback, useEffect, useRef, useState } from 'react'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Returns the bounding-box list for all widgets on the canvas.
|
|
5
|
+
* Each entry: { id, x, y, width, height }
|
|
6
|
+
*/
|
|
7
|
+
function getWidgetBounds(widgets, componentEntries, fallbackSizes) {
|
|
8
|
+
const bounds = []
|
|
9
|
+
for (const w of (widgets ?? [])) {
|
|
10
|
+
const fb = fallbackSizes[w.type] || { width: 200, height: 150 }
|
|
11
|
+
bounds.push({
|
|
12
|
+
id: w.id,
|
|
13
|
+
x: w?.position?.x ?? 0,
|
|
14
|
+
y: w?.position?.y ?? 0,
|
|
15
|
+
width: w.props?.width ?? fb.width,
|
|
16
|
+
height: w.props?.height ?? fb.height,
|
|
17
|
+
})
|
|
18
|
+
}
|
|
19
|
+
for (const entry of componentEntries) {
|
|
20
|
+
const fb = fallbackSizes['component'] || { width: 200, height: 150 }
|
|
21
|
+
bounds.push({
|
|
22
|
+
id: `jsx-${entry.exportName}`,
|
|
23
|
+
x: entry.sourceData?.position?.x ?? 0,
|
|
24
|
+
y: entry.sourceData?.position?.y ?? 0,
|
|
25
|
+
width: entry.sourceData?.width ?? fb.width,
|
|
26
|
+
height: entry.sourceData?.height ?? fb.height,
|
|
27
|
+
})
|
|
28
|
+
}
|
|
29
|
+
return bounds
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Test whether two axis-aligned rectangles overlap. */
|
|
33
|
+
function rectsIntersect(a, b) {
|
|
34
|
+
return a.x < b.x + b.width &&
|
|
35
|
+
a.x + a.width > b.x &&
|
|
36
|
+
a.y < b.y + b.height &&
|
|
37
|
+
a.y + a.height > b.y
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Hook that powers marquee (lasso) selection on the canvas.
|
|
42
|
+
*
|
|
43
|
+
* @param {Object} opts
|
|
44
|
+
* @param {React.RefObject} opts.scrollRef — ref to the scroll container
|
|
45
|
+
* @param {React.RefObject} opts.zoomRef — ref holding current zoom (number 25-200)
|
|
46
|
+
* @param {Function} opts.setSelectedWidgetIds — state setter for selected IDs (Set)
|
|
47
|
+
* @param {Array} opts.widgets — current localWidgets array
|
|
48
|
+
* @param {Array} opts.componentEntries — current componentEntries array
|
|
49
|
+
* @param {Object} opts.fallbackSizes — WIDGET_FALLBACK_SIZES map
|
|
50
|
+
* @param {boolean} opts.spaceHeld — whether the space key is pressed (panning)
|
|
51
|
+
* @param {boolean} opts.isLocalDev — only enable in local dev
|
|
52
|
+
*
|
|
53
|
+
* @returns {{ marqueeScreenRect: {x,y,w,h}|null, handleMarqueeMouseDown: Function }}
|
|
54
|
+
*/
|
|
55
|
+
export default function useMarqueeSelect({
|
|
56
|
+
scrollRef,
|
|
57
|
+
zoomRef,
|
|
58
|
+
setSelectedWidgetIds,
|
|
59
|
+
widgets,
|
|
60
|
+
componentEntries,
|
|
61
|
+
fallbackSizes,
|
|
62
|
+
spaceHeld,
|
|
63
|
+
isLocalDev,
|
|
64
|
+
}) {
|
|
65
|
+
const [marqueeScreenRect, setMarqueeScreenRect] = useState(null)
|
|
66
|
+
const marqueeState = useRef(null) // { startCanvasX, startCanvasY, startClientX, startClientY }
|
|
67
|
+
|
|
68
|
+
/** Convert a client (screen) point to canvas-space coords. */
|
|
69
|
+
const clientToCanvas = useCallback((clientX, clientY) => {
|
|
70
|
+
const el = scrollRef.current
|
|
71
|
+
if (!el) return { x: 0, y: 0 }
|
|
72
|
+
const rect = el.getBoundingClientRect()
|
|
73
|
+
const scale = (zoomRef.current ?? 100) / 100
|
|
74
|
+
return {
|
|
75
|
+
x: (el.scrollLeft + (clientX - rect.left)) / scale,
|
|
76
|
+
y: (el.scrollTop + (clientY - rect.top)) / scale,
|
|
77
|
+
}
|
|
78
|
+
}, [scrollRef, zoomRef])
|
|
79
|
+
|
|
80
|
+
// Ref to track active drag listeners for cleanup on unmount
|
|
81
|
+
const cleanupRef = useRef(null)
|
|
82
|
+
|
|
83
|
+
const handleMarqueeMouseDown = useCallback((e) => {
|
|
84
|
+
if (!isLocalDev) return
|
|
85
|
+
if (spaceHeld) return
|
|
86
|
+
// Only start on direct background click (not on a widget)
|
|
87
|
+
if (e.button !== 0) return
|
|
88
|
+
if (e.target.closest('[data-tc-x]')) return
|
|
89
|
+
|
|
90
|
+
const canvasStart = clientToCanvas(e.clientX, e.clientY)
|
|
91
|
+
marqueeState.current = {
|
|
92
|
+
startCanvasX: canvasStart.x,
|
|
93
|
+
startCanvasY: canvasStart.y,
|
|
94
|
+
startClientX: e.clientX,
|
|
95
|
+
startClientY: e.clientY,
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// Minimum drag distance before showing the marquee (avoids flicker on clicks)
|
|
99
|
+
const MIN_DRAG = 4
|
|
100
|
+
|
|
101
|
+
function handleMove(ev) {
|
|
102
|
+
const ms = marqueeState.current
|
|
103
|
+
if (!ms) return
|
|
104
|
+
|
|
105
|
+
const dx = ev.clientX - ms.startClientX
|
|
106
|
+
const dy = ev.clientY - ms.startClientY
|
|
107
|
+
if (Math.abs(dx) < MIN_DRAG && Math.abs(dy) < MIN_DRAG) return
|
|
108
|
+
|
|
109
|
+
const el = scrollRef.current
|
|
110
|
+
if (!el) return
|
|
111
|
+
const containerRect = el.getBoundingClientRect()
|
|
112
|
+
|
|
113
|
+
// Content-space rectangle (accounts for scroll offset)
|
|
114
|
+
const sx = Math.min(ms.startClientX, ev.clientX) - containerRect.left + el.scrollLeft
|
|
115
|
+
const sy = Math.min(ms.startClientY, ev.clientY) - containerRect.top + el.scrollTop
|
|
116
|
+
const sw = Math.abs(dx)
|
|
117
|
+
const sh = Math.abs(dy)
|
|
118
|
+
|
|
119
|
+
setMarqueeScreenRect({ x: sx, y: sy, w: sw, h: sh })
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function removeListeners() {
|
|
123
|
+
document.removeEventListener('mousemove', handleMove)
|
|
124
|
+
document.removeEventListener('mouseup', handleUp)
|
|
125
|
+
window.removeEventListener('blur', handleCancel)
|
|
126
|
+
cleanupRef.current = null
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function handleCancel() {
|
|
130
|
+
removeListeners()
|
|
131
|
+
marqueeState.current = null
|
|
132
|
+
setMarqueeScreenRect(null)
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function handleUp(ev) {
|
|
136
|
+
removeListeners()
|
|
137
|
+
|
|
138
|
+
const ms = marqueeState.current
|
|
139
|
+
marqueeState.current = null
|
|
140
|
+
setMarqueeScreenRect(null)
|
|
141
|
+
|
|
142
|
+
if (!ms) return
|
|
143
|
+
|
|
144
|
+
// If the user barely moved, treat as a deselect click
|
|
145
|
+
const dx = ev.clientX - ms.startClientX
|
|
146
|
+
const dy = ev.clientY - ms.startClientY
|
|
147
|
+
if (Math.abs(dx) < MIN_DRAG && Math.abs(dy) < MIN_DRAG) {
|
|
148
|
+
setSelectedWidgetIds(new Set())
|
|
149
|
+
return
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// Compute the selection rect in canvas space
|
|
153
|
+
const canvasEnd = clientToCanvas(ev.clientX, ev.clientY)
|
|
154
|
+
const selRect = {
|
|
155
|
+
x: Math.min(ms.startCanvasX, canvasEnd.x),
|
|
156
|
+
y: Math.min(ms.startCanvasY, canvasEnd.y),
|
|
157
|
+
width: Math.abs(canvasEnd.x - ms.startCanvasX),
|
|
158
|
+
height: Math.abs(canvasEnd.y - ms.startCanvasY),
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// Find intersecting widgets
|
|
162
|
+
const allBounds = getWidgetBounds(widgets, componentEntries, fallbackSizes)
|
|
163
|
+
const selected = new Set()
|
|
164
|
+
for (const wb of allBounds) {
|
|
165
|
+
if (rectsIntersect(selRect, wb)) {
|
|
166
|
+
selected.add(wb.id)
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
setSelectedWidgetIds(selected)
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
document.addEventListener('mousemove', handleMove)
|
|
174
|
+
document.addEventListener('mouseup', handleUp)
|
|
175
|
+
window.addEventListener('blur', handleCancel)
|
|
176
|
+
cleanupRef.current = removeListeners
|
|
177
|
+
}, [isLocalDev, spaceHeld, clientToCanvas, scrollRef, setSelectedWidgetIds, widgets, componentEntries, fallbackSizes])
|
|
178
|
+
|
|
179
|
+
// Clean up listeners if component unmounts mid-drag
|
|
180
|
+
useEffect(() => {
|
|
181
|
+
return () => { cleanupRef.current?.() }
|
|
182
|
+
}, [])
|
|
183
|
+
|
|
184
|
+
return { marqueeScreenRect, handleMarqueeMouseDown }
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
export { getWidgetBounds, rectsIntersect }
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest'
|
|
2
|
+
import { rectsIntersect, getWidgetBounds } from './useMarqueeSelect.js'
|
|
3
|
+
|
|
4
|
+
describe('rectsIntersect', () => {
|
|
5
|
+
it('returns true for overlapping rectangles', () => {
|
|
6
|
+
const a = { x: 0, y: 0, width: 100, height: 100 }
|
|
7
|
+
const b = { x: 50, y: 50, width: 100, height: 100 }
|
|
8
|
+
expect(rectsIntersect(a, b)).toBe(true)
|
|
9
|
+
})
|
|
10
|
+
|
|
11
|
+
it('returns false for non-overlapping rectangles', () => {
|
|
12
|
+
const a = { x: 0, y: 0, width: 100, height: 100 }
|
|
13
|
+
const b = { x: 200, y: 200, width: 100, height: 100 }
|
|
14
|
+
expect(rectsIntersect(a, b)).toBe(false)
|
|
15
|
+
})
|
|
16
|
+
|
|
17
|
+
it('returns false for edge-touching rectangles (no overlap)', () => {
|
|
18
|
+
const a = { x: 0, y: 0, width: 100, height: 100 }
|
|
19
|
+
const b = { x: 100, y: 0, width: 100, height: 100 }
|
|
20
|
+
expect(rectsIntersect(a, b)).toBe(false)
|
|
21
|
+
})
|
|
22
|
+
|
|
23
|
+
it('returns true when one rectangle contains the other', () => {
|
|
24
|
+
const a = { x: 0, y: 0, width: 200, height: 200 }
|
|
25
|
+
const b = { x: 50, y: 50, width: 50, height: 50 }
|
|
26
|
+
expect(rectsIntersect(a, b)).toBe(true)
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
it('returns true for partial horizontal overlap', () => {
|
|
30
|
+
const a = { x: 0, y: 0, width: 100, height: 100 }
|
|
31
|
+
const b = { x: 50, y: 0, width: 100, height: 50 }
|
|
32
|
+
expect(rectsIntersect(a, b)).toBe(true)
|
|
33
|
+
})
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
describe('getWidgetBounds', () => {
|
|
37
|
+
const fallbackSizes = {
|
|
38
|
+
'sticky-note': { width: 270, height: 170 },
|
|
39
|
+
'component': { width: 200, height: 150 },
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
it('returns bounds for JSON widgets', () => {
|
|
43
|
+
const widgets = [
|
|
44
|
+
{ id: 'w1', type: 'sticky-note', position: { x: 10, y: 20 }, props: { width: 300, height: 200 } },
|
|
45
|
+
{ id: 'w2', type: 'sticky-note', position: { x: 100, y: 200 }, props: {} },
|
|
46
|
+
]
|
|
47
|
+
const result = getWidgetBounds(widgets, [], fallbackSizes)
|
|
48
|
+
expect(result).toEqual([
|
|
49
|
+
{ id: 'w1', x: 10, y: 20, width: 300, height: 200 },
|
|
50
|
+
{ id: 'w2', x: 100, y: 200, width: 270, height: 170 },
|
|
51
|
+
])
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
it('returns bounds for component entries', () => {
|
|
55
|
+
const entries = [
|
|
56
|
+
{ exportName: 'MyComp', sourceData: { position: { x: 5, y: 10 }, width: 400, height: 300 } },
|
|
57
|
+
]
|
|
58
|
+
const result = getWidgetBounds([], entries, fallbackSizes)
|
|
59
|
+
expect(result).toEqual([
|
|
60
|
+
{ id: 'jsx-MyComp', x: 5, y: 10, width: 400, height: 300 },
|
|
61
|
+
])
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
it('handles null/missing widget data gracefully', () => {
|
|
65
|
+
const widgets = [
|
|
66
|
+
{ id: 'w1', type: 'unknown' },
|
|
67
|
+
]
|
|
68
|
+
const result = getWidgetBounds(widgets, [], fallbackSizes)
|
|
69
|
+
expect(result).toEqual([
|
|
70
|
+
{ id: 'w1', x: 0, y: 0, width: 200, height: 150 },
|
|
71
|
+
])
|
|
72
|
+
})
|
|
73
|
+
|
|
74
|
+
it('handles null widgets array', () => {
|
|
75
|
+
const result = getWidgetBounds(null, [], fallbackSizes)
|
|
76
|
+
expect(result).toEqual([])
|
|
77
|
+
})
|
|
78
|
+
})
|
|
@@ -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
|
+
})
|