@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.
Files changed (75) hide show
  1. package/package.json +6 -3
  2. package/src/AuthModal/AuthModal.jsx +134 -0
  3. package/src/AuthModal/AuthModal.module.css +221 -0
  4. package/src/BranchBar/BranchBar.jsx +56 -0
  5. package/src/BranchBar/BranchBar.module.css +230 -0
  6. package/src/BranchBar/useBranches.js +79 -0
  7. package/src/CommandPalette/CommandPalette.jsx +936 -0
  8. package/src/CommandPalette/CreateDialog.jsx +219 -0
  9. package/src/CommandPalette/command-palette.css +111 -0
  10. package/src/Icon.jsx +180 -0
  11. package/src/Viewfinder.jsx +1104 -57
  12. package/src/Viewfinder.module.css +1107 -149
  13. package/src/canvas/CanvasControls.jsx +51 -2
  14. package/src/canvas/CanvasControls.module.css +31 -0
  15. package/src/canvas/CanvasPage.bridge.test.jsx +142 -19
  16. package/src/canvas/CanvasPage.dragdrop.test.jsx +346 -0
  17. package/src/canvas/CanvasPage.jsx +807 -251
  18. package/src/canvas/CanvasPage.module.css +98 -50
  19. package/src/canvas/CanvasPage.multiselect.test.jsx +13 -11
  20. package/src/canvas/CanvasToolbar.jsx +2 -2
  21. package/src/canvas/MarqueeOverlay.jsx +20 -0
  22. package/src/canvas/PageSelector.jsx +239 -0
  23. package/src/canvas/PageSelector.module.css +165 -0
  24. package/src/canvas/PageSelector.test.jsx +104 -0
  25. package/src/canvas/canvasApi.js +22 -8
  26. package/src/canvas/canvasTheme.js +96 -52
  27. package/src/canvas/componentIsolate.jsx +33 -7
  28. package/src/canvas/useCanvas.js +9 -8
  29. package/src/canvas/useCanvas.test.js +4 -4
  30. package/src/canvas/useMarqueeSelect.js +187 -0
  31. package/src/canvas/useMarqueeSelect.test.js +78 -0
  32. package/src/canvas/widgets/CodePenEmbed.jsx +292 -0
  33. package/src/canvas/widgets/CodePenEmbed.module.css +161 -0
  34. package/src/canvas/widgets/ComponentWidget.jsx +42 -10
  35. package/src/canvas/widgets/ComponentWidget.module.css +6 -5
  36. package/src/canvas/widgets/FigmaEmbed.jsx +110 -24
  37. package/src/canvas/widgets/FigmaEmbed.module.css +21 -7
  38. package/src/canvas/widgets/LinkPreview.jsx +297 -11
  39. package/src/canvas/widgets/LinkPreview.module.css +386 -18
  40. package/src/canvas/widgets/LinkPreview.test.jsx +193 -0
  41. package/src/canvas/widgets/MarkdownBlock.jsx +86 -5
  42. package/src/canvas/widgets/MarkdownBlock.module.css +64 -15
  43. package/src/canvas/widgets/PrototypeEmbed.jsx +96 -145
  44. package/src/canvas/widgets/PrototypeEmbed.module.css +74 -4
  45. package/src/canvas/widgets/StickyNote.module.css +5 -0
  46. package/src/canvas/widgets/StickyNote.test.jsx +9 -9
  47. package/src/canvas/widgets/StoryWidget.jsx +277 -0
  48. package/src/canvas/widgets/StoryWidget.module.css +211 -0
  49. package/src/canvas/widgets/WidgetChrome.jsx +76 -20
  50. package/src/canvas/widgets/WidgetChrome.module.css +2 -6
  51. package/src/canvas/widgets/WidgetWrapper.module.css +2 -0
  52. package/src/canvas/widgets/codepenUrl.js +75 -0
  53. package/src/canvas/widgets/codepenUrl.test.js +76 -0
  54. package/src/canvas/widgets/embedInteraction.test.jsx +235 -0
  55. package/src/canvas/widgets/embedOverlay.module.css +35 -0
  56. package/src/canvas/widgets/embedTheme.js +138 -39
  57. package/src/canvas/widgets/githubUrl.js +82 -0
  58. package/src/canvas/widgets/githubUrl.test.js +74 -0
  59. package/src/canvas/widgets/iframeDevLogs.js +49 -0
  60. package/src/canvas/widgets/iframeDevLogs.test.jsx +81 -0
  61. package/src/canvas/widgets/index.js +4 -0
  62. package/src/canvas/widgets/pasteRules.js +295 -0
  63. package/src/canvas/widgets/pasteRules.test.js +474 -0
  64. package/src/canvas/widgets/snapshotDisplay.test.jsx +259 -0
  65. package/src/canvas/widgets/widgetConfig.js +16 -5
  66. package/src/canvas/widgets/widgetConfig.test.js +34 -12
  67. package/src/context.jsx +145 -16
  68. package/src/hooks/useSceneData.js +4 -2
  69. package/src/hooks/useThemeState.js +61 -0
  70. package/src/hooks/useThemeState.test.js +66 -0
  71. package/src/index.js +10 -0
  72. package/src/story/StoryPage.jsx +117 -0
  73. package/src/story/StoryPage.module.css +18 -0
  74. package/src/vite/data-plugin.js +348 -66
  75. 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
+ })