@dfosco/storyboard-react 4.0.0-beta.4 → 4.0.0-beta.41
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 +9 -4
- package/src/Icon.jsx +179 -0
- package/src/Viewfinder.jsx +1030 -57
- package/src/Viewfinder.module.css +1524 -155
- 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 +843 -301
- package/src/canvas/CanvasPage.module.css +73 -50
- 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 +198 -0
- package/src/canvas/PageSelector.module.css +158 -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 +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 +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 +95 -144
- 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 +276 -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/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 +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 +375 -57
- package/src/vite/data-plugin.test.js +405 -5
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CodePen URL utilities — parse, validate, and convert CodePen URLs
|
|
3
|
+
* to their embeddable format.
|
|
4
|
+
*
|
|
5
|
+
* Supported URL formats:
|
|
6
|
+
* https://codepen.io/{user}/pen/{penId}
|
|
7
|
+
* https://codepen.io/{user}/full/{penId}
|
|
8
|
+
* https://codepen.io/{user}/details/{penId}
|
|
9
|
+
* https://codepen.io/{user}/embed/{penId}
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
const CODEPEN_RE = /^https?:\/\/codepen\.io\/([^/]+)\/(pen|full|details|embed)\/([A-Za-z0-9]+)/
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Check if a URL is a valid CodePen pen URL.
|
|
16
|
+
*/
|
|
17
|
+
export function isCodePenUrl(url) {
|
|
18
|
+
if (!url) return false
|
|
19
|
+
return CODEPEN_RE.test(url)
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Convert any CodePen pen URL to the embed format.
|
|
24
|
+
* Defaults to showing the result tab with a dark theme.
|
|
25
|
+
*/
|
|
26
|
+
export function toCodePenEmbedUrl(url) {
|
|
27
|
+
const m = url?.match(CODEPEN_RE)
|
|
28
|
+
if (!m) return ''
|
|
29
|
+
const [, user, , penId] = m
|
|
30
|
+
return `https://codepen.io/${user}/embed/${penId}?default-tab=result&editable=true`
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Extract a fallback title from a CodePen URL (user/penId).
|
|
35
|
+
*/
|
|
36
|
+
export function getCodePenTitle(url) {
|
|
37
|
+
const m = url?.match(CODEPEN_RE)
|
|
38
|
+
if (!m) return 'CodePen'
|
|
39
|
+
return `${m[1]}/${m[3]}`
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Extract the username from a CodePen URL.
|
|
44
|
+
*/
|
|
45
|
+
export function getCodePenUser(url) {
|
|
46
|
+
const m = url?.match(CODEPEN_RE)
|
|
47
|
+
return m?.[1] || ''
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** In-memory cache for oEmbed results keyed by pen URL. */
|
|
51
|
+
const _oembedCache = new Map()
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Fetch pen metadata (title, author_name) via CodePen's oEmbed API.
|
|
55
|
+
* Returns `{ title, author }` or null on failure. Results are cached.
|
|
56
|
+
*/
|
|
57
|
+
export async function fetchCodePenMeta(url) {
|
|
58
|
+
if (!url || !isCodePenUrl(url)) return null
|
|
59
|
+
if (_oembedCache.has(url)) return _oembedCache.get(url)
|
|
60
|
+
|
|
61
|
+
try {
|
|
62
|
+
const endpoint = `https://codepen.io/api/oembed?url=${encodeURIComponent(url)}&format=json`
|
|
63
|
+
const res = await fetch(endpoint)
|
|
64
|
+
if (!res.ok) return null
|
|
65
|
+
const data = await res.json()
|
|
66
|
+
const meta = {
|
|
67
|
+
title: data.title || '',
|
|
68
|
+
author: data.author_name || '',
|
|
69
|
+
}
|
|
70
|
+
_oembedCache.set(url, meta)
|
|
71
|
+
return meta
|
|
72
|
+
} catch {
|
|
73
|
+
return null
|
|
74
|
+
}
|
|
75
|
+
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for CodePen URL utilities.
|
|
3
|
+
*/
|
|
4
|
+
import { describe, it, expect } from 'vitest'
|
|
5
|
+
import { isCodePenUrl, toCodePenEmbedUrl, getCodePenTitle, getCodePenUser } from './codepenUrl.js'
|
|
6
|
+
|
|
7
|
+
describe('isCodePenUrl', () => {
|
|
8
|
+
it('returns true for pen URLs', () => {
|
|
9
|
+
expect(isCodePenUrl('https://codepen.io/Calleb/pen/jEMXgvq')).toBe(true)
|
|
10
|
+
})
|
|
11
|
+
|
|
12
|
+
it('returns true for full view URLs', () => {
|
|
13
|
+
expect(isCodePenUrl('https://codepen.io/Calleb/full/jEMXgvq')).toBe(true)
|
|
14
|
+
})
|
|
15
|
+
|
|
16
|
+
it('returns true for details URLs', () => {
|
|
17
|
+
expect(isCodePenUrl('https://codepen.io/Calleb/details/jEMXgvq')).toBe(true)
|
|
18
|
+
})
|
|
19
|
+
|
|
20
|
+
it('returns true for embed URLs', () => {
|
|
21
|
+
expect(isCodePenUrl('https://codepen.io/Calleb/embed/jEMXgvq')).toBe(true)
|
|
22
|
+
})
|
|
23
|
+
|
|
24
|
+
it('returns false for non-CodePen URLs', () => {
|
|
25
|
+
expect(isCodePenUrl('https://example.com')).toBe(false)
|
|
26
|
+
expect(isCodePenUrl('https://figma.com/design/abc')).toBe(false)
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
it('returns false for CodePen homepage', () => {
|
|
30
|
+
expect(isCodePenUrl('https://codepen.io')).toBe(false)
|
|
31
|
+
expect(isCodePenUrl('https://codepen.io/Calleb')).toBe(false)
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
it('returns false for null/empty', () => {
|
|
35
|
+
expect(isCodePenUrl(null)).toBe(false)
|
|
36
|
+
expect(isCodePenUrl('')).toBe(false)
|
|
37
|
+
})
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
describe('toCodePenEmbedUrl', () => {
|
|
41
|
+
it('converts pen URL to embed format', () => {
|
|
42
|
+
const result = toCodePenEmbedUrl('https://codepen.io/Calleb/pen/jEMXgvq')
|
|
43
|
+
expect(result).toBe('https://codepen.io/Calleb/embed/jEMXgvq?default-tab=result&editable=true')
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
it('converts full URL to embed format', () => {
|
|
47
|
+
const result = toCodePenEmbedUrl('https://codepen.io/Calleb/full/jEMXgvq')
|
|
48
|
+
expect(result).toBe('https://codepen.io/Calleb/embed/jEMXgvq?default-tab=result&editable=true')
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
it('returns empty string for invalid URL', () => {
|
|
52
|
+
expect(toCodePenEmbedUrl('https://example.com')).toBe('')
|
|
53
|
+
expect(toCodePenEmbedUrl(null)).toBe('')
|
|
54
|
+
})
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
describe('getCodePenTitle', () => {
|
|
58
|
+
it('extracts user/penId from URL', () => {
|
|
59
|
+
expect(getCodePenTitle('https://codepen.io/Calleb/pen/jEMXgvq')).toBe('Calleb/jEMXgvq')
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
it('returns "CodePen" for invalid URL', () => {
|
|
63
|
+
expect(getCodePenTitle('https://example.com')).toBe('CodePen')
|
|
64
|
+
expect(getCodePenTitle(null)).toBe('CodePen')
|
|
65
|
+
})
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
describe('getCodePenUser', () => {
|
|
69
|
+
it('extracts username', () => {
|
|
70
|
+
expect(getCodePenUser('https://codepen.io/Calleb/pen/jEMXgvq')).toBe('Calleb')
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
it('returns empty for invalid URL', () => {
|
|
74
|
+
expect(getCodePenUser('https://example.com')).toBe('')
|
|
75
|
+
})
|
|
76
|
+
})
|
|
@@ -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
|
+
}
|