@dfosco/storyboard-react 4.0.0-beta.2 → 4.0.0-beta.21
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.dragdrop.test.jsx +346 -0
- package/src/canvas/CanvasPage.jsx +512 -235
- package/src/canvas/CanvasPage.module.css +9 -47
- 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 +4 -0
- 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 +6 -2
- package/src/canvas/widgets/ComponentWidget.jsx +67 -9
- package/src/canvas/widgets/ComponentWidget.module.css +9 -6
- package/src/canvas/widgets/FigmaEmbed.jsx +26 -4
- package/src/canvas/widgets/FigmaEmbed.module.css +0 -7
- package/src/canvas/widgets/MarkdownBlock.jsx +94 -21
- package/src/canvas/widgets/MarkdownBlock.module.css +110 -2
- package/src/canvas/widgets/MarkdownBlock.test.jsx +39 -0
- package/src/canvas/widgets/PrototypeEmbed.jsx +196 -40
- package/src/canvas/widgets/PrototypeEmbed.module.css +30 -3
- package/src/canvas/widgets/StickyNote.module.css +5 -0
- package/src/canvas/widgets/StickyNote.test.jsx +9 -9
- package/src/canvas/widgets/StoryWidget.jsx +471 -0
- package/src/canvas/widgets/StoryWidget.module.css +200 -0
- package/src/canvas/widgets/WidgetChrome.jsx +54 -18
- package/src/canvas/widgets/WidgetChrome.module.css +4 -7
- package/src/canvas/widgets/WidgetWrapper.module.css +2 -0
- package/src/canvas/widgets/embedInteraction.test.jsx +155 -0
- package/src/canvas/widgets/embedOverlay.module.css +35 -0
- package/src/canvas/widgets/index.js +2 -0
- package/src/canvas/widgets/pasteRules.js +295 -0
- package/src/canvas/widgets/pasteRules.test.js +474 -0
- package/src/canvas/widgets/widgetConfig.js +16 -5
- package/src/canvas/widgets/widgetConfig.test.js +31 -9
- package/src/context.jsx +138 -13
- package/src/hooks/useSceneData.js +4 -2
- package/src/story/StoryPage.jsx +152 -0
- package/src/story/StoryPage.module.css +73 -0
- package/src/vite/data-plugin.js +441 -58
- package/src/vite/data-plugin.test.js +405 -5
|
@@ -44,58 +44,14 @@
|
|
|
44
44
|
gap: 8px;
|
|
45
45
|
}
|
|
46
46
|
|
|
47
|
-
.
|
|
48
|
-
display: inline-grid;
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
.canvasTitleWrap > * {
|
|
52
|
-
grid-area: 1 / 1;
|
|
53
|
-
}
|
|
54
|
-
|
|
55
|
-
.canvasTitleMeasure {
|
|
56
|
-
visibility: hidden;
|
|
57
|
-
white-space: pre;
|
|
58
|
-
font-family: var(--tc-font-stack, system-ui, -apple-system, sans-serif);
|
|
59
|
-
font-size: 14px;
|
|
60
|
-
font-weight: 600;
|
|
61
|
-
padding: 4px 8px;
|
|
62
|
-
border: 1px solid transparent;
|
|
63
|
-
min-width: 80px;
|
|
64
|
-
pointer-events: none;
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
.canvasTitleInput {
|
|
47
|
+
.canvasTitleStatic {
|
|
68
48
|
font-family: var(--tc-font-stack, system-ui, -apple-system, sans-serif);
|
|
69
49
|
font-size: 14px;
|
|
70
50
|
font-weight: 600;
|
|
71
51
|
color: var(--fgColor-muted, #656d76);
|
|
72
|
-
background: transparent;
|
|
73
|
-
border: 1px solid transparent;
|
|
74
|
-
border-radius: 6px;
|
|
75
|
-
padding: 4px 8px;
|
|
76
52
|
margin: 0;
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
min-width: 0;
|
|
80
|
-
transition: border-color 150ms, background-color 150ms, color 150ms;
|
|
81
|
-
}
|
|
82
|
-
|
|
83
|
-
.canvasTitleInput:hover {
|
|
84
|
-
color: var(--fgColor-default, #1f2328);
|
|
85
|
-
border-color: var(--borderColor-default, #d1d9e0);
|
|
86
|
-
background: var(--bgColor-default, #ffffff);
|
|
87
|
-
}
|
|
88
|
-
|
|
89
|
-
.canvasTitleInput:focus {
|
|
90
|
-
color: var(--fgColor-default, #1f2328);
|
|
91
|
-
border-color: var(--bgColor-accent-emphasis, #2f81f7);
|
|
92
|
-
background: var(--bgColor-default, #ffffff);
|
|
93
|
-
}
|
|
94
|
-
|
|
95
|
-
.canvasTitleStatic {
|
|
96
|
-
composes: canvasTitleInput;
|
|
97
|
-
cursor: default;
|
|
98
|
-
pointer-events: none;
|
|
53
|
+
padding: 4px 8px;
|
|
54
|
+
white-space: nowrap;
|
|
99
55
|
}
|
|
100
56
|
|
|
101
57
|
/* Remove tiny-canvas wrapper clipping — widgets handle their own overflow/radius */
|
|
@@ -103,6 +59,12 @@
|
|
|
103
59
|
overflow: visible;
|
|
104
60
|
}
|
|
105
61
|
|
|
62
|
+
/* Elevate stacking context for hovered/selected widgets so their chrome
|
|
63
|
+
(toolbar, menus, selection outline) renders above sibling widgets. */
|
|
64
|
+
:global(.tc-drag:has([data-tc-elevated])) {
|
|
65
|
+
z-index: 1;
|
|
66
|
+
}
|
|
67
|
+
|
|
106
68
|
.localEditingLabel {
|
|
107
69
|
display: inline-flex;
|
|
108
70
|
align-items: center;
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { Component } from 'react'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Error boundary for canvas component widgets.
|
|
5
|
+
* Catches render-time errors so a single broken component
|
|
6
|
+
* doesn't crash the entire canvas page.
|
|
7
|
+
*
|
|
8
|
+
* Used as a production fallback when iframe isolation is not available.
|
|
9
|
+
*/
|
|
10
|
+
export default class ComponentErrorBoundary extends Component {
|
|
11
|
+
constructor(props) {
|
|
12
|
+
super(props)
|
|
13
|
+
this.state = { error: null }
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
static getDerivedStateFromError(error) {
|
|
17
|
+
return { error }
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
componentDidCatch(error, info) {
|
|
21
|
+
console.error(
|
|
22
|
+
`[storyboard] Component widget "${this.props.name || 'unknown'}" crashed:`,
|
|
23
|
+
error,
|
|
24
|
+
info?.componentStack,
|
|
25
|
+
)
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
render() {
|
|
29
|
+
if (this.state.error) {
|
|
30
|
+
return (
|
|
31
|
+
<div style={{
|
|
32
|
+
padding: '16px',
|
|
33
|
+
color: '#cf222e',
|
|
34
|
+
fontFamily: 'system-ui, -apple-system, sans-serif',
|
|
35
|
+
fontSize: '13px',
|
|
36
|
+
lineHeight: 1.5,
|
|
37
|
+
whiteSpace: 'pre-wrap',
|
|
38
|
+
wordBreak: 'break-word',
|
|
39
|
+
minWidth: 200,
|
|
40
|
+
minHeight: 60,
|
|
41
|
+
}}>
|
|
42
|
+
<strong>{this.props.name || 'Component'}</strong>
|
|
43
|
+
<br />
|
|
44
|
+
{String(this.state.error.message || this.state.error)}
|
|
45
|
+
</div>
|
|
46
|
+
)
|
|
47
|
+
}
|
|
48
|
+
return this.props.children
|
|
49
|
+
}
|
|
50
|
+
}
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import { useCallback, useRef, useState, useEffect } from 'react'
|
|
2
|
+
import styles from './PageSelector.module.css'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* In-canvas page selector — shows sibling pages in the same canvas group.
|
|
6
|
+
* Only renders when 2+ sibling pages exist.
|
|
7
|
+
* Uses window.location for navigation to avoid requiring a Router context.
|
|
8
|
+
*
|
|
9
|
+
* @param {{ currentName: string, pages: Array<{ name: string, route: string, title: string }> }} props
|
|
10
|
+
*/
|
|
11
|
+
export default function PageSelector({ currentName, pages }) {
|
|
12
|
+
const [open, setOpen] = useState(false)
|
|
13
|
+
const containerRef = useRef(null)
|
|
14
|
+
|
|
15
|
+
const currentPage = pages.find((p) => p.name === currentName)
|
|
16
|
+
const currentLabel = currentPage?.title || currentName.split('/').pop()
|
|
17
|
+
const currentIndex = pages.findIndex((p) => p.name === currentName)
|
|
18
|
+
|
|
19
|
+
const handleSelect = useCallback(
|
|
20
|
+
(page) => {
|
|
21
|
+
if (page.name !== currentName) {
|
|
22
|
+
const base = (import.meta.env?.BASE_URL || '/').replace(/\/$/, '')
|
|
23
|
+
window.location.href = base + page.route
|
|
24
|
+
}
|
|
25
|
+
setOpen(false)
|
|
26
|
+
},
|
|
27
|
+
[currentName],
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
// Close on outside click
|
|
31
|
+
useEffect(() => {
|
|
32
|
+
if (!open) return
|
|
33
|
+
function handleClick(e) {
|
|
34
|
+
if (containerRef.current && !containerRef.current.contains(e.target)) {
|
|
35
|
+
setOpen(false)
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
document.addEventListener('mousedown', handleClick)
|
|
39
|
+
return () => document.removeEventListener('mousedown', handleClick)
|
|
40
|
+
}, [open])
|
|
41
|
+
|
|
42
|
+
// Close on Escape
|
|
43
|
+
useEffect(() => {
|
|
44
|
+
if (!open) return
|
|
45
|
+
function handleKey(e) {
|
|
46
|
+
if (e.key === 'Escape') setOpen(false)
|
|
47
|
+
}
|
|
48
|
+
document.addEventListener('keydown', handleKey)
|
|
49
|
+
return () => document.removeEventListener('keydown', handleKey)
|
|
50
|
+
}, [open])
|
|
51
|
+
|
|
52
|
+
if (!pages || pages.length < 2) return null
|
|
53
|
+
|
|
54
|
+
return (
|
|
55
|
+
<nav ref={containerRef} className={styles.container} aria-label="Canvas pages">
|
|
56
|
+
<button
|
|
57
|
+
className={styles.trigger}
|
|
58
|
+
onClick={() => setOpen((v) => !v)}
|
|
59
|
+
aria-expanded={open}
|
|
60
|
+
aria-haspopup="listbox"
|
|
61
|
+
title="Switch canvas page"
|
|
62
|
+
>
|
|
63
|
+
<span className={styles.label}>{currentLabel}</span>
|
|
64
|
+
<span className={styles.badge}>
|
|
65
|
+
{currentIndex + 1}/{pages.length}
|
|
66
|
+
</span>
|
|
67
|
+
<svg
|
|
68
|
+
className={`${styles.chevron} ${open ? styles.chevronOpen : ''}`}
|
|
69
|
+
width="12"
|
|
70
|
+
height="12"
|
|
71
|
+
viewBox="0 0 12 12"
|
|
72
|
+
fill="none"
|
|
73
|
+
aria-hidden="true"
|
|
74
|
+
>
|
|
75
|
+
<path d="M3 4.5L6 7.5L9 4.5" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
|
|
76
|
+
</svg>
|
|
77
|
+
</button>
|
|
78
|
+
{open && (
|
|
79
|
+
<ul className={styles.menu} role="listbox" aria-label="Canvas pages">
|
|
80
|
+
{pages.map((page) => (
|
|
81
|
+
<li
|
|
82
|
+
key={page.name}
|
|
83
|
+
role="option"
|
|
84
|
+
aria-selected={page.name === currentName}
|
|
85
|
+
className={`${styles.item} ${page.name === currentName ? styles.itemActive : ''}`}
|
|
86
|
+
onClick={() => handleSelect(page)}
|
|
87
|
+
onKeyDown={(e) => {
|
|
88
|
+
if (e.key === 'Enter' || e.key === ' ') {
|
|
89
|
+
e.preventDefault()
|
|
90
|
+
handleSelect(page)
|
|
91
|
+
}
|
|
92
|
+
}}
|
|
93
|
+
tabIndex={0}
|
|
94
|
+
>
|
|
95
|
+
{page.title}
|
|
96
|
+
</li>
|
|
97
|
+
))}
|
|
98
|
+
</ul>
|
|
99
|
+
)}
|
|
100
|
+
</nav>
|
|
101
|
+
)
|
|
102
|
+
}
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
.container {
|
|
2
|
+
position: relative;
|
|
3
|
+
font-size: 13px;
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
.trigger {
|
|
7
|
+
display: inline-flex;
|
|
8
|
+
align-items: center;
|
|
9
|
+
gap: 6px;
|
|
10
|
+
padding: 5px 10px;
|
|
11
|
+
border: 1px solid var(--borderColor-default, rgba(0, 0, 0, 0.15));
|
|
12
|
+
border-radius: 6px;
|
|
13
|
+
background: var(--bgColor-default, #fff);
|
|
14
|
+
color: var(--fgColor-default, #1f2328);
|
|
15
|
+
cursor: pointer;
|
|
16
|
+
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.08);
|
|
17
|
+
transition: border-color 0.15s, box-shadow 0.15s;
|
|
18
|
+
line-height: 1;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
.trigger:hover {
|
|
22
|
+
border-color: var(--borderColor-emphasis, rgba(0, 0, 0, 0.3));
|
|
23
|
+
box-shadow: 0 1px 5px rgba(0, 0, 0, 0.12);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
.label {
|
|
27
|
+
font-weight: 600;
|
|
28
|
+
max-width: 200px;
|
|
29
|
+
overflow: hidden;
|
|
30
|
+
text-overflow: ellipsis;
|
|
31
|
+
white-space: nowrap;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
.badge {
|
|
35
|
+
font-size: 11px;
|
|
36
|
+
color: var(--fgColor-muted, #656d76);
|
|
37
|
+
font-variant-numeric: tabular-nums;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
.chevron {
|
|
41
|
+
color: var(--fgColor-muted, #656d76);
|
|
42
|
+
transition: transform 0.15s;
|
|
43
|
+
flex-shrink: 0;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
.chevronOpen {
|
|
47
|
+
transform: rotate(180deg);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
.menu {
|
|
51
|
+
position: absolute;
|
|
52
|
+
top: calc(100% + 4px);
|
|
53
|
+
left: 0;
|
|
54
|
+
min-width: 180px;
|
|
55
|
+
max-width: 300px;
|
|
56
|
+
max-height: 320px;
|
|
57
|
+
overflow-y: auto;
|
|
58
|
+
margin: 0;
|
|
59
|
+
padding: 4px;
|
|
60
|
+
list-style: none;
|
|
61
|
+
background: var(--bgColor-default, #fff);
|
|
62
|
+
border: 1px solid var(--borderColor-default, rgba(0, 0, 0, 0.15));
|
|
63
|
+
border-radius: 8px;
|
|
64
|
+
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.12);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
.item {
|
|
68
|
+
padding: 6px 10px;
|
|
69
|
+
border-radius: 4px;
|
|
70
|
+
cursor: pointer;
|
|
71
|
+
white-space: nowrap;
|
|
72
|
+
overflow: hidden;
|
|
73
|
+
text-overflow: ellipsis;
|
|
74
|
+
color: var(--fgColor-default, #1f2328);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
.item:hover {
|
|
78
|
+
background: var(--bgColor-muted, #f6f8fa);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
.item:focus-visible {
|
|
82
|
+
outline: 2px solid var(--focus-outlineColor, #0969da);
|
|
83
|
+
outline-offset: -2px;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
.itemActive {
|
|
87
|
+
font-weight: 600;
|
|
88
|
+
background: var(--bgColor-accent-muted, #ddf4ff);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
.itemActive:hover {
|
|
92
|
+
background: var(--bgColor-accent-muted, #ddf4ff);
|
|
93
|
+
}
|
|
@@ -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
|
@@ -47,3 +47,7 @@ export function uploadImage(dataUrl, canvasName) {
|
|
|
47
47
|
export function toggleImagePrivacy(filename) {
|
|
48
48
|
return request('/image/toggle-private', 'POST', { filename })
|
|
49
49
|
}
|
|
50
|
+
|
|
51
|
+
export function getCanvas(name) {
|
|
52
|
+
return request(`/read?name=${encodeURIComponent(name)}`, 'GET')
|
|
53
|
+
}
|
|
@@ -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
|
@@ -32,12 +32,13 @@ export function resolveCanvasModuleImport(modulePath, baseUrl = import.meta.env?
|
|
|
32
32
|
* fresh widget data from the server to pick up persisted edits.
|
|
33
33
|
*
|
|
34
34
|
* @param {string} name - Canvas name as indexed by the data plugin
|
|
35
|
-
* @returns {{ canvas: object|null, jsxExports: object|null, loading: boolean }}
|
|
35
|
+
* @returns {{ canvas: object|null, jsxExports: object|null, jsxError: boolean, loading: boolean }}
|
|
36
36
|
*/
|
|
37
37
|
export function useCanvas(name) {
|
|
38
38
|
const buildTimeCanvas = useMemo(() => getCanvasData(name), [name])
|
|
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
|
|
@@ -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
|
|
|
@@ -109,5 +113,5 @@ export function useCanvas(name) {
|
|
|
109
113
|
}
|
|
110
114
|
}, [name, buildTimeCanvas])
|
|
111
115
|
|
|
112
|
-
return { canvas, jsxExports, loading }
|
|
116
|
+
return { canvas, jsxExports, jsxError, loading }
|
|
113
117
|
}
|