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