@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,235 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for embed interaction UX (click-to-interact overlay).
|
|
3
|
+
*/
|
|
4
|
+
import { describe, it, expect, vi } from 'vitest'
|
|
5
|
+
import { render, fireEvent, screen } from '@testing-library/react'
|
|
6
|
+
import PrototypeEmbed from './PrototypeEmbed.jsx'
|
|
7
|
+
import FigmaEmbed from './FigmaEmbed.jsx'
|
|
8
|
+
import ComponentWidget from './ComponentWidget.jsx'
|
|
9
|
+
import StoryWidget from './StoryWidget.jsx'
|
|
10
|
+
|
|
11
|
+
// Mock buildPrototypeIndex for PrototypeEmbed
|
|
12
|
+
vi.mock('@dfosco/storyboard-core', () => ({
|
|
13
|
+
buildPrototypeIndex: () => ({
|
|
14
|
+
folders: [],
|
|
15
|
+
prototypes: [
|
|
16
|
+
{
|
|
17
|
+
name: 'Design Overview',
|
|
18
|
+
dirName: 'examples',
|
|
19
|
+
isExternal: false,
|
|
20
|
+
hideFlows: true,
|
|
21
|
+
flows: [{ route: '/test', name: 'default', meta: { title: 'Design Overview' } }],
|
|
22
|
+
},
|
|
23
|
+
],
|
|
24
|
+
globalFlows: [],
|
|
25
|
+
sorted: { title: { prototypes: [], folders: [] } },
|
|
26
|
+
}),
|
|
27
|
+
getStoryData: (storyId) => ({ _route: `/components/${storyId}` }),
|
|
28
|
+
}))
|
|
29
|
+
|
|
30
|
+
// Simple mock wrapper for WidgetWrapper
|
|
31
|
+
vi.mock('./WidgetWrapper.jsx', () => ({
|
|
32
|
+
default: ({ children }) => <div data-testid="widget-wrapper">{children}</div>,
|
|
33
|
+
}))
|
|
34
|
+
|
|
35
|
+
vi.mock('@dfosco/storyboard-core/inspector/highlighter', () => ({
|
|
36
|
+
createInspectorHighlighter: async () => ({
|
|
37
|
+
codeToHtml: () => '<pre><code></code></pre>',
|
|
38
|
+
}),
|
|
39
|
+
}))
|
|
40
|
+
|
|
41
|
+
// Mock ResizeHandle
|
|
42
|
+
vi.mock('./ResizeHandle.jsx', () => ({
|
|
43
|
+
default: () => <div data-testid="resize-handle" />,
|
|
44
|
+
}))
|
|
45
|
+
|
|
46
|
+
// Mock ComponentErrorBoundary
|
|
47
|
+
vi.mock('../ComponentErrorBoundary.jsx', () => ({
|
|
48
|
+
default: ({ children }) => <div data-testid="error-boundary">{children}</div>,
|
|
49
|
+
}))
|
|
50
|
+
|
|
51
|
+
describe('Embed interaction overlay', () => {
|
|
52
|
+
describe('PrototypeEmbed', () => {
|
|
53
|
+
const defaultProps = {
|
|
54
|
+
props: { src: '/test', width: 400, height: 300, zoom: 100 },
|
|
55
|
+
onUpdate: vi.fn(),
|
|
56
|
+
resizable: false,
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
it('renders "Click to open" hint when no snapshot exists', () => {
|
|
60
|
+
render(<PrototypeEmbed {...defaultProps} />)
|
|
61
|
+
|
|
62
|
+
const hint = screen.getByText('Click to open')
|
|
63
|
+
expect(hint).toBeInTheDocument()
|
|
64
|
+
// CSS modules mangle class names, just check the element exists
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
it('enters interactive mode on single click (not double-click)', async () => {
|
|
68
|
+
const { container } = render(<PrototypeEmbed {...defaultProps} />)
|
|
69
|
+
|
|
70
|
+
// Overlay should exist before interaction
|
|
71
|
+
const overlay = screen.getByRole('button', { name: /click to open/i })
|
|
72
|
+
expect(overlay).toBeInTheDocument()
|
|
73
|
+
expect(container.querySelector('iframe')).not.toBeInTheDocument()
|
|
74
|
+
expect(screen.getByText('Design Overview')).toBeInTheDocument()
|
|
75
|
+
|
|
76
|
+
// Single click should remove the overlay (enter interactive mode)
|
|
77
|
+
fireEvent.click(overlay)
|
|
78
|
+
|
|
79
|
+
// Overlay should no longer exist
|
|
80
|
+
expect(screen.queryByRole('button', { name: /click to open/i })).not.toBeInTheDocument()
|
|
81
|
+
expect(container.querySelector('iframe')).toBeInTheDocument()
|
|
82
|
+
|
|
83
|
+
fireEvent.pointerDown(document.body)
|
|
84
|
+
expect(screen.getByRole('button', { name: /click to open/i })).toBeInTheDocument()
|
|
85
|
+
expect(container.querySelector('iframe')).not.toBeInTheDocument()
|
|
86
|
+
})
|
|
87
|
+
|
|
88
|
+
it('does not enter interactive mode on shift+click (preserves multi-select)', () => {
|
|
89
|
+
render(<PrototypeEmbed {...defaultProps} />)
|
|
90
|
+
|
|
91
|
+
const overlay = screen.getByRole('button', { name: /click to open/i })
|
|
92
|
+
fireEvent.click(overlay, { shiftKey: true })
|
|
93
|
+
|
|
94
|
+
// Overlay should still exist (did not enter interactive mode)
|
|
95
|
+
expect(screen.getByRole('button', { name: /click to open/i })).toBeInTheDocument()
|
|
96
|
+
})
|
|
97
|
+
|
|
98
|
+
it('does not enter interactive mode on meta+click (preserves multi-select)', () => {
|
|
99
|
+
render(<PrototypeEmbed {...defaultProps} />)
|
|
100
|
+
|
|
101
|
+
const overlay = screen.getByRole('button', { name: /click to open/i })
|
|
102
|
+
fireEvent.click(overlay, { metaKey: true })
|
|
103
|
+
|
|
104
|
+
expect(screen.getByRole('button', { name: /click to open/i })).toBeInTheDocument()
|
|
105
|
+
})
|
|
106
|
+
|
|
107
|
+
it('supports keyboard interaction (Enter key) with event prevention', () => {
|
|
108
|
+
render(<PrototypeEmbed {...defaultProps} />)
|
|
109
|
+
|
|
110
|
+
const overlay = screen.getByRole('button', { name: /click to open/i })
|
|
111
|
+
const event = { key: 'Enter', preventDefault: vi.fn(), stopPropagation: vi.fn() }
|
|
112
|
+
fireEvent.keyDown(overlay, event)
|
|
113
|
+
|
|
114
|
+
expect(screen.queryByRole('button', { name: /click to open/i })).not.toBeInTheDocument()
|
|
115
|
+
})
|
|
116
|
+
|
|
117
|
+
it('supports keyboard interaction (Space key) with event prevention', () => {
|
|
118
|
+
render(<PrototypeEmbed {...defaultProps} />)
|
|
119
|
+
|
|
120
|
+
const overlay = screen.getByRole('button', { name: /click to open/i })
|
|
121
|
+
const event = { key: ' ', preventDefault: vi.fn(), stopPropagation: vi.fn() }
|
|
122
|
+
fireEvent.keyDown(overlay, event)
|
|
123
|
+
|
|
124
|
+
expect(screen.queryByRole('button', { name: /click to open/i })).not.toBeInTheDocument()
|
|
125
|
+
})
|
|
126
|
+
})
|
|
127
|
+
|
|
128
|
+
describe('FigmaEmbed', () => {
|
|
129
|
+
const defaultProps = {
|
|
130
|
+
props: { url: 'https://www.figma.com/design/abc123/Test', width: 400, height: 300 },
|
|
131
|
+
onUpdate: vi.fn(),
|
|
132
|
+
resizable: false,
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
it('renders "Click to interact" hint', () => {
|
|
136
|
+
render(<FigmaEmbed {...defaultProps} />)
|
|
137
|
+
|
|
138
|
+
const hint = screen.getByText('Click to interact')
|
|
139
|
+
expect(hint).toBeInTheDocument()
|
|
140
|
+
})
|
|
141
|
+
|
|
142
|
+
it('enters interactive mode on single click', () => {
|
|
143
|
+
const { container } = render(<FigmaEmbed {...defaultProps} />)
|
|
144
|
+
|
|
145
|
+
const overlay = screen.getByRole('button', { name: /click to interact/i })
|
|
146
|
+
expect(container.querySelector('iframe')).not.toBeInTheDocument()
|
|
147
|
+
fireEvent.click(overlay)
|
|
148
|
+
|
|
149
|
+
expect(screen.queryByRole('button', { name: /click to interact/i })).not.toBeInTheDocument()
|
|
150
|
+
expect(container.querySelector('iframe')).toBeInTheDocument()
|
|
151
|
+
|
|
152
|
+
fireEvent.pointerDown(document.body)
|
|
153
|
+
expect(screen.getByRole('button', { name: /click to interact/i })).toBeInTheDocument()
|
|
154
|
+
expect(container.querySelector('iframe')).not.toBeInTheDocument()
|
|
155
|
+
})
|
|
156
|
+
})
|
|
157
|
+
|
|
158
|
+
describe('StoryWidget', () => {
|
|
159
|
+
const defaultProps = {
|
|
160
|
+
props: { storyId: 'button-patterns', exportName: 'Primary', width: 400, height: 300 },
|
|
161
|
+
onUpdate: vi.fn(),
|
|
162
|
+
resizable: false,
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
it('mounts iframe only after user activation', () => {
|
|
166
|
+
const { container } = render(<StoryWidget {...defaultProps} />)
|
|
167
|
+
|
|
168
|
+
const overlay = screen.getByRole('button', { name: /click to open story component/i })
|
|
169
|
+
expect(container.querySelector('iframe')).not.toBeInTheDocument()
|
|
170
|
+
|
|
171
|
+
fireEvent.click(overlay)
|
|
172
|
+
|
|
173
|
+
expect(screen.queryByRole('button', { name: /click to open story component/i })).not.toBeInTheDocument()
|
|
174
|
+
expect(container.querySelector('iframe')).toBeInTheDocument()
|
|
175
|
+
|
|
176
|
+
fireEvent.pointerDown(document.body)
|
|
177
|
+
expect(screen.getByRole('button', { name: /click to open story component/i })).toBeInTheDocument()
|
|
178
|
+
expect(container.querySelector('iframe')).not.toBeInTheDocument()
|
|
179
|
+
})
|
|
180
|
+
})
|
|
181
|
+
|
|
182
|
+
describe('ComponentWidget', () => {
|
|
183
|
+
const MockComponent = () => <div>Mock Component</div>
|
|
184
|
+
|
|
185
|
+
const defaultProps = {
|
|
186
|
+
component: MockComponent,
|
|
187
|
+
jsxModule: null,
|
|
188
|
+
exportName: 'MockComponent',
|
|
189
|
+
canvasTheme: 'light',
|
|
190
|
+
isLocalDev: false,
|
|
191
|
+
width: 200,
|
|
192
|
+
height: 150,
|
|
193
|
+
onUpdate: vi.fn(),
|
|
194
|
+
resizable: false,
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
it('renders "Click to interact" hint', () => {
|
|
198
|
+
render(<ComponentWidget {...defaultProps} />)
|
|
199
|
+
|
|
200
|
+
const hint = screen.getByText('Click to interact')
|
|
201
|
+
expect(hint).toBeInTheDocument()
|
|
202
|
+
})
|
|
203
|
+
|
|
204
|
+
it('enters interactive mode on single click', () => {
|
|
205
|
+
render(<ComponentWidget {...defaultProps} />)
|
|
206
|
+
|
|
207
|
+
const overlay = screen.getByRole('button', { name: /click to interact/i })
|
|
208
|
+
fireEvent.click(overlay)
|
|
209
|
+
|
|
210
|
+
expect(screen.queryByRole('button', { name: /click to interact/i })).not.toBeInTheDocument()
|
|
211
|
+
})
|
|
212
|
+
|
|
213
|
+
it('mounts dev iframe only after user activation', () => {
|
|
214
|
+
const { container } = render(
|
|
215
|
+
<ComponentWidget
|
|
216
|
+
{...defaultProps}
|
|
217
|
+
isLocalDev
|
|
218
|
+
jsxModule="/src/canvas/mock.canvas.jsx"
|
|
219
|
+
exportName="MockComponent"
|
|
220
|
+
/>
|
|
221
|
+
)
|
|
222
|
+
|
|
223
|
+
const overlay = screen.getByRole('button', { name: /click to interact with component/i })
|
|
224
|
+
expect(container.querySelector('iframe')).not.toBeInTheDocument()
|
|
225
|
+
|
|
226
|
+
fireEvent.click(overlay)
|
|
227
|
+
|
|
228
|
+
expect(container.querySelector('iframe')).toBeInTheDocument()
|
|
229
|
+
|
|
230
|
+
fireEvent.pointerDown(document.body)
|
|
231
|
+
expect(screen.getByRole('button', { name: /click to interact with component/i })).toBeInTheDocument()
|
|
232
|
+
expect(container.querySelector('iframe')).not.toBeInTheDocument()
|
|
233
|
+
})
|
|
234
|
+
})
|
|
235
|
+
})
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared styles for embed interaction overlays.
|
|
3
|
+
* Used by PrototypeEmbed, FigmaEmbed, and ComponentWidget.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
.interactOverlay {
|
|
7
|
+
position: absolute;
|
|
8
|
+
inset: 0;
|
|
9
|
+
z-index: 1;
|
|
10
|
+
cursor: pointer;
|
|
11
|
+
display: flex;
|
|
12
|
+
align-items: center;
|
|
13
|
+
justify-content: center;
|
|
14
|
+
transition: background-color 150ms ease;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
.interactOverlay:hover {
|
|
18
|
+
background-color: rgba(0, 0, 0, 0.15);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
.interactHint {
|
|
22
|
+
opacity: 0;
|
|
23
|
+
color: var(--fgColor-onInverse);
|
|
24
|
+
background-color: var(--bgColor-inverse);
|
|
25
|
+
padding: var(--base-size-12) var(--base-size-16);
|
|
26
|
+
border-radius: var(--base-size-6);
|
|
27
|
+
font-size: 14px;
|
|
28
|
+
font-weight: 600;
|
|
29
|
+
pointer-events: none;
|
|
30
|
+
transition: opacity 150ms ease;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
.interactOverlay:hover .interactHint {
|
|
34
|
+
opacity: 1;
|
|
35
|
+
}
|
|
@@ -1,3 +1,59 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Resolve the effective canvas theme from localStorage + sync settings.
|
|
3
|
+
* Respects the canvas-specific theme sync toggle.
|
|
4
|
+
*/
|
|
5
|
+
export function resolveCanvasTheme() {
|
|
6
|
+
if (typeof localStorage === 'undefined') return 'light'
|
|
7
|
+
let sync = { prototype: true, toolbar: false, codeBoxes: true, canvas: true }
|
|
8
|
+
try {
|
|
9
|
+
const rawSync = localStorage.getItem('sb-theme-sync')
|
|
10
|
+
if (rawSync) sync = { ...sync, ...JSON.parse(rawSync) }
|
|
11
|
+
} catch { /* ignore */ }
|
|
12
|
+
if (!sync.canvas) return 'light'
|
|
13
|
+
const attrTheme = document.documentElement.getAttribute('data-sb-canvas-theme')
|
|
14
|
+
if (attrTheme) return attrTheme
|
|
15
|
+
const stored = localStorage.getItem('sb-color-scheme') || 'system'
|
|
16
|
+
if (stored !== 'system') return stored
|
|
17
|
+
return typeof window.matchMedia === 'function' &&
|
|
18
|
+
window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Subscribe to canvas theme changes for embed widget snapshot switching.
|
|
23
|
+
*
|
|
24
|
+
* Reads `data-sb-canvas-theme` from the nearest ancestor set by CanvasPage.
|
|
25
|
+
* With canvas sync ON (the default), this attribute follows the global theme.
|
|
26
|
+
* Uses MutationObserver for immediate reaction + event backup.
|
|
27
|
+
*/
|
|
28
|
+
export function subscribeCanvasTheme({ anchorRef, onTheme }) {
|
|
29
|
+
if (typeof onTheme !== 'function') return () => {}
|
|
30
|
+
|
|
31
|
+
let observed = null
|
|
32
|
+
let observer = null
|
|
33
|
+
|
|
34
|
+
function readAndEmit() {
|
|
35
|
+
const el = anchorRef?.current?.closest?.('[data-sb-canvas-theme]') || null
|
|
36
|
+
if (el !== observed) {
|
|
37
|
+
if (observer) observer.disconnect()
|
|
38
|
+
observer = null
|
|
39
|
+
observed = el
|
|
40
|
+
if (el && typeof MutationObserver !== 'undefined') {
|
|
41
|
+
observer = new MutationObserver(readAndEmit)
|
|
42
|
+
observer.observe(el, { attributes: true, attributeFilter: ['data-sb-canvas-theme'] })
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
onTheme(el?.getAttribute('data-sb-canvas-theme') || 'light')
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
readAndEmit()
|
|
49
|
+
document.addEventListener('storyboard:theme:changed', readAndEmit)
|
|
50
|
+
|
|
51
|
+
return () => {
|
|
52
|
+
document.removeEventListener('storyboard:theme:changed', readAndEmit)
|
|
53
|
+
if (observer) observer.disconnect()
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
1
57
|
export function getEmbedChromeVars(theme) {
|
|
2
58
|
const value = String(theme || 'light')
|
|
3
59
|
if (value === 'dark_dimmed') {
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
const GH_HOST_RE = /^(www\.)?github\.com$/i
|
|
2
|
+
const ISSUE_PATH_RE = /^\/([^/]+)\/([^/]+)\/issues\/(\d+)\/?$/
|
|
3
|
+
const DISCUSSION_PATH_RE = /^\/([^/]+)\/([^/]+)\/discussions\/(\d+)\/?$/
|
|
4
|
+
const PULL_REQUEST_PATH_RE = /^\/([^/]+)\/([^/]+)\/pull\/(\d+)\/?$/
|
|
5
|
+
const ISSUE_COMMENT_HASH_RE = /^#issuecomment-(\d+)$/i
|
|
6
|
+
const DISCUSSION_COMMENT_HASH_RE = /^#discussioncomment-(\d+)$/i
|
|
7
|
+
|
|
8
|
+
function toNumber(raw) {
|
|
9
|
+
const value = Number.parseInt(raw, 10)
|
|
10
|
+
return Number.isFinite(value) ? value : null
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Parse supported GitHub embed URLs (issues, discussions, comments).
|
|
15
|
+
* @param {string} rawUrl
|
|
16
|
+
* @returns {null | {
|
|
17
|
+
* kind: 'issue' | 'discussion' | 'comment',
|
|
18
|
+
* parentKind: 'issue' | 'discussion',
|
|
19
|
+
* owner: string,
|
|
20
|
+
* repo: string,
|
|
21
|
+
* number: number,
|
|
22
|
+
* commentId?: number
|
|
23
|
+
* }}
|
|
24
|
+
*/
|
|
25
|
+
export function parseGitHubUrl(rawUrl) {
|
|
26
|
+
try {
|
|
27
|
+
const parsed = new URL(rawUrl)
|
|
28
|
+
if (!GH_HOST_RE.test(parsed.hostname)) return null
|
|
29
|
+
|
|
30
|
+
const issueMatch = parsed.pathname.match(ISSUE_PATH_RE)
|
|
31
|
+
if (issueMatch) {
|
|
32
|
+
const [, owner, repo, numberRaw] = issueMatch
|
|
33
|
+
const number = toNumber(numberRaw)
|
|
34
|
+
if (!number) return null
|
|
35
|
+
|
|
36
|
+
const commentMatch = parsed.hash.match(ISSUE_COMMENT_HASH_RE)
|
|
37
|
+
if (commentMatch) {
|
|
38
|
+
const commentId = toNumber(commentMatch[1])
|
|
39
|
+
if (!commentId) return null
|
|
40
|
+
return { kind: 'comment', parentKind: 'issue', owner, repo, number, commentId }
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
if (parsed.hash) return null
|
|
44
|
+
return { kind: 'issue', parentKind: 'issue', owner, repo, number }
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const discussionMatch = parsed.pathname.match(DISCUSSION_PATH_RE)
|
|
48
|
+
if (discussionMatch) {
|
|
49
|
+
const [, owner, repo, numberRaw] = discussionMatch
|
|
50
|
+
const number = toNumber(numberRaw)
|
|
51
|
+
if (!number) return null
|
|
52
|
+
|
|
53
|
+
const commentMatch = parsed.hash.match(DISCUSSION_COMMENT_HASH_RE)
|
|
54
|
+
if (commentMatch) {
|
|
55
|
+
const commentId = toNumber(commentMatch[1])
|
|
56
|
+
if (!commentId) return null
|
|
57
|
+
return { kind: 'comment', parentKind: 'discussion', owner, repo, number, commentId }
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
if (parsed.hash) return null
|
|
61
|
+
return { kind: 'discussion', parentKind: 'discussion', owner, repo, number }
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const prMatch = parsed.pathname.match(PULL_REQUEST_PATH_RE)
|
|
65
|
+
if (prMatch) {
|
|
66
|
+
const [, owner, repo, numberRaw] = prMatch
|
|
67
|
+
const number = toNumber(numberRaw)
|
|
68
|
+
if (!number) return null
|
|
69
|
+
|
|
70
|
+
if (parsed.hash) return null
|
|
71
|
+
return { kind: 'pull_request', parentKind: 'pull_request', owner, repo, number }
|
|
72
|
+
}
|
|
73
|
+
} catch {
|
|
74
|
+
return null
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
return null
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export function isGitHubEmbedUrl(rawUrl) {
|
|
81
|
+
return parseGitHubUrl(rawUrl) !== null
|
|
82
|
+
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
import { isGitHubEmbedUrl, parseGitHubUrl } from './githubUrl.js'
|
|
3
|
+
|
|
4
|
+
describe('parseGitHubUrl', () => {
|
|
5
|
+
it('classifies issue URLs', () => {
|
|
6
|
+
expect(parseGitHubUrl('https://github.com/dfosco/storyboard/issues/12')).toEqual({
|
|
7
|
+
kind: 'issue',
|
|
8
|
+
parentKind: 'issue',
|
|
9
|
+
owner: 'dfosco',
|
|
10
|
+
repo: 'storyboard',
|
|
11
|
+
number: 12,
|
|
12
|
+
})
|
|
13
|
+
})
|
|
14
|
+
|
|
15
|
+
it('classifies discussion URLs', () => {
|
|
16
|
+
expect(parseGitHubUrl('https://github.com/dfosco/storyboard/discussions/99')).toEqual({
|
|
17
|
+
kind: 'discussion',
|
|
18
|
+
parentKind: 'discussion',
|
|
19
|
+
owner: 'dfosco',
|
|
20
|
+
repo: 'storyboard',
|
|
21
|
+
number: 99,
|
|
22
|
+
})
|
|
23
|
+
})
|
|
24
|
+
|
|
25
|
+
it('classifies issue comment URLs', () => {
|
|
26
|
+
expect(parseGitHubUrl('https://github.com/dfosco/storyboard/issues/12#issuecomment-345')).toEqual({
|
|
27
|
+
kind: 'comment',
|
|
28
|
+
parentKind: 'issue',
|
|
29
|
+
owner: 'dfosco',
|
|
30
|
+
repo: 'storyboard',
|
|
31
|
+
number: 12,
|
|
32
|
+
commentId: 345,
|
|
33
|
+
})
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
it('classifies discussion comment URLs', () => {
|
|
37
|
+
expect(parseGitHubUrl('https://github.com/dfosco/storyboard/discussions/99#discussioncomment-888')).toEqual({
|
|
38
|
+
kind: 'comment',
|
|
39
|
+
parentKind: 'discussion',
|
|
40
|
+
owner: 'dfosco',
|
|
41
|
+
repo: 'storyboard',
|
|
42
|
+
number: 99,
|
|
43
|
+
commentId: 888,
|
|
44
|
+
})
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
it('classifies pull request URLs', () => {
|
|
48
|
+
expect(parseGitHubUrl('https://github.com/dfosco/storyboard/pull/12')).toEqual({
|
|
49
|
+
kind: 'pull_request',
|
|
50
|
+
parentKind: 'pull_request',
|
|
51
|
+
owner: 'dfosco',
|
|
52
|
+
repo: 'storyboard',
|
|
53
|
+
number: 12,
|
|
54
|
+
})
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
it('rejects unsupported paths and hashes', () => {
|
|
58
|
+
expect(parseGitHubUrl('https://github.com/dfosco/storyboard/issues/12#random')).toBeNull()
|
|
59
|
+
expect(parseGitHubUrl('https://example.com/dfosco/storyboard/issues/12')).toBeNull()
|
|
60
|
+
expect(parseGitHubUrl('not a url')).toBeNull()
|
|
61
|
+
})
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
describe('isGitHubEmbedUrl', () => {
|
|
65
|
+
it('returns true for supported GitHub URLs', () => {
|
|
66
|
+
expect(isGitHubEmbedUrl('https://github.com/dfosco/storyboard/issues/12')).toBe(true)
|
|
67
|
+
expect(isGitHubEmbedUrl('https://github.com/dfosco/storyboard/discussions/99#discussioncomment-888')).toBe(true)
|
|
68
|
+
expect(isGitHubEmbedUrl('https://github.com/dfosco/storyboard/pull/1')).toBe(true)
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
it('returns false for unsupported URLs', () => {
|
|
72
|
+
expect(isGitHubEmbedUrl('https://example.com')).toBe(false)
|
|
73
|
+
})
|
|
74
|
+
})
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { useEffect, useRef } from 'react'
|
|
2
|
+
|
|
3
|
+
let loadedIframeCount = 0
|
|
4
|
+
|
|
5
|
+
function isDevRuntime() {
|
|
6
|
+
return typeof window !== 'undefined' && window.__SB_LOCAL_DEV__ === true
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
function toText(value) {
|
|
10
|
+
return value ? String(value) : '(no src)'
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function logIframeEvent(event, count, meta) {
|
|
14
|
+
console.log(`[storyboard][iframe] ${event} | count=${count} | ${meta.widget}`, {
|
|
15
|
+
event,
|
|
16
|
+
count,
|
|
17
|
+
widget: meta.widget,
|
|
18
|
+
src: toText(meta.src),
|
|
19
|
+
})
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Dev-only iframe load/unload logging with a live count of mounted iframes.
|
|
24
|
+
*/
|
|
25
|
+
export function useIframeDevLogs({ widget, loaded, src }) {
|
|
26
|
+
const metaRef = useRef({ widget, src })
|
|
27
|
+
|
|
28
|
+
useEffect(() => {
|
|
29
|
+
metaRef.current = { widget, src }
|
|
30
|
+
}, [widget, src])
|
|
31
|
+
|
|
32
|
+
useEffect(() => {
|
|
33
|
+
if (!loaded) return
|
|
34
|
+
|
|
35
|
+
loadedIframeCount += 1
|
|
36
|
+
if (isDevRuntime()) {
|
|
37
|
+
const meta = metaRef.current
|
|
38
|
+
logIframeEvent('loaded', loadedIframeCount, meta)
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
return () => {
|
|
42
|
+
loadedIframeCount = Math.max(0, loadedIframeCount - 1)
|
|
43
|
+
if (isDevRuntime()) {
|
|
44
|
+
const meta = metaRef.current
|
|
45
|
+
logIframeEvent('unloaded', loadedIframeCount, meta)
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
}, [loaded])
|
|
49
|
+
}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
|
2
|
+
import { render } from '@testing-library/react'
|
|
3
|
+
import { useIframeDevLogs } from './iframeDevLogs.js'
|
|
4
|
+
|
|
5
|
+
function Probe({ widget = 'Probe', loaded = false, src = '/test' }) {
|
|
6
|
+
useIframeDevLogs({ widget, loaded, src })
|
|
7
|
+
return null
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
describe('useIframeDevLogs', () => {
|
|
11
|
+
let logSpy
|
|
12
|
+
|
|
13
|
+
beforeEach(() => {
|
|
14
|
+
window.__SB_LOCAL_DEV__ = true
|
|
15
|
+
logSpy = vi.spyOn(console, 'log').mockImplementation(() => {})
|
|
16
|
+
})
|
|
17
|
+
|
|
18
|
+
afterEach(() => {
|
|
19
|
+
logSpy.mockRestore()
|
|
20
|
+
delete window.__SB_LOCAL_DEV__
|
|
21
|
+
})
|
|
22
|
+
|
|
23
|
+
it('logs iframe load and unload with tally', () => {
|
|
24
|
+
const { rerender, unmount } = render(<Probe loaded={false} src="/alpha" />)
|
|
25
|
+
rerender(<Probe loaded src="/alpha" />)
|
|
26
|
+
rerender(<Probe loaded={false} src="/alpha" />)
|
|
27
|
+
|
|
28
|
+
expect(logSpy).toHaveBeenNthCalledWith(
|
|
29
|
+
1,
|
|
30
|
+
'[storyboard][iframe] loaded | count=1 | Probe',
|
|
31
|
+
{ event: 'loaded', count: 1, widget: 'Probe', src: '/alpha' },
|
|
32
|
+
)
|
|
33
|
+
expect(logSpy).toHaveBeenNthCalledWith(
|
|
34
|
+
2,
|
|
35
|
+
'[storyboard][iframe] unloaded | count=0 | Probe',
|
|
36
|
+
{ event: 'unloaded', count: 0, widget: 'Probe', src: '/alpha' },
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
unmount()
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
it('tracks tally across multiple loaded iframes', () => {
|
|
43
|
+
const first = render(<Probe widget="PrototypeEmbed" loaded src="/proto" />)
|
|
44
|
+
const second = render(<Probe widget="FigmaEmbed" loaded src="/figma" />)
|
|
45
|
+
|
|
46
|
+
expect(logSpy).toHaveBeenNthCalledWith(
|
|
47
|
+
1,
|
|
48
|
+
'[storyboard][iframe] loaded | count=1 | PrototypeEmbed',
|
|
49
|
+
{ event: 'loaded', count: 1, widget: 'PrototypeEmbed', src: '/proto' },
|
|
50
|
+
)
|
|
51
|
+
expect(logSpy).toHaveBeenNthCalledWith(
|
|
52
|
+
2,
|
|
53
|
+
'[storyboard][iframe] loaded | count=2 | FigmaEmbed',
|
|
54
|
+
{ event: 'loaded', count: 2, widget: 'FigmaEmbed', src: '/figma' },
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
first.unmount()
|
|
58
|
+
expect(logSpy).toHaveBeenNthCalledWith(
|
|
59
|
+
3,
|
|
60
|
+
'[storyboard][iframe] unloaded | count=1 | PrototypeEmbed',
|
|
61
|
+
{ event: 'unloaded', count: 1, widget: 'PrototypeEmbed', src: '/proto' },
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
second.unmount()
|
|
65
|
+
expect(logSpy).toHaveBeenNthCalledWith(
|
|
66
|
+
4,
|
|
67
|
+
'[storyboard][iframe] unloaded | count=0 | FigmaEmbed',
|
|
68
|
+
{ event: 'unloaded', count: 0, widget: 'FigmaEmbed', src: '/figma' },
|
|
69
|
+
)
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
it('does not log outside local dev runtime', () => {
|
|
73
|
+
window.__SB_LOCAL_DEV__ = false
|
|
74
|
+
const { rerender, unmount } = render(<Probe loaded={false} src="/off" />)
|
|
75
|
+
rerender(<Probe loaded src="/off" />)
|
|
76
|
+
rerender(<Probe loaded={false} src="/off" />)
|
|
77
|
+
|
|
78
|
+
expect(logSpy).not.toHaveBeenCalled()
|
|
79
|
+
unmount()
|
|
80
|
+
})
|
|
81
|
+
})
|
|
@@ -4,6 +4,8 @@ import PrototypeEmbed from './PrototypeEmbed.jsx'
|
|
|
4
4
|
import LinkPreview from './LinkPreview.jsx'
|
|
5
5
|
import ImageWidget from './ImageWidget.jsx'
|
|
6
6
|
import FigmaEmbed from './FigmaEmbed.jsx'
|
|
7
|
+
import CodePenEmbed from './CodePenEmbed.jsx'
|
|
8
|
+
import StoryWidget from './StoryWidget.jsx'
|
|
7
9
|
|
|
8
10
|
/**
|
|
9
11
|
* Maps widget type strings to their React components.
|
|
@@ -16,6 +18,8 @@ export const widgetRegistry = {
|
|
|
16
18
|
'link-preview': LinkPreview,
|
|
17
19
|
'image': ImageWidget,
|
|
18
20
|
'figma-embed': FigmaEmbed,
|
|
21
|
+
'codepen-embed': CodePenEmbed,
|
|
22
|
+
'story': StoryWidget,
|
|
19
23
|
}
|
|
20
24
|
|
|
21
25
|
/**
|