@dfosco/storyboard-react 4.0.0-beta.10 → 4.0.0-beta.12
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 +3 -3
- package/src/canvas/CanvasPage.jsx +2 -2
- package/src/canvas/componentIsolate.jsx +28 -2
- package/src/canvas/widgets/ComponentWidget.jsx +20 -3
- package/src/canvas/widgets/ComponentWidget.module.css +3 -7
- package/src/canvas/widgets/FigmaEmbed.jsx +20 -3
- package/src/canvas/widgets/FigmaEmbed.module.css +0 -7
- package/src/canvas/widgets/PrototypeEmbed.jsx +20 -3
- package/src/canvas/widgets/PrototypeEmbed.module.css +0 -7
- package/src/canvas/widgets/WidgetChrome.jsx +24 -16
- package/src/canvas/widgets/WidgetChrome.module.css +2 -6
- 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/widgetConfig.js +15 -4
- package/src/canvas/widgets/widgetConfig.test.js +19 -0
- package/src/vite/data-plugin.js +69 -26
- package/src/vite/data-plugin.test.js +222 -3
package/package.json
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@dfosco/storyboard-react",
|
|
3
|
-
"version": "4.0.0-beta.
|
|
3
|
+
"version": "4.0.0-beta.12",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"dependencies": {
|
|
6
|
-
"@dfosco/storyboard-core": "4.0.0-beta.
|
|
7
|
-
"@dfosco/tiny-canvas": "4.0.0-beta.
|
|
6
|
+
"@dfosco/storyboard-core": "4.0.0-beta.12",
|
|
7
|
+
"@dfosco/tiny-canvas": "4.0.0-beta.12",
|
|
8
8
|
"@neodrag/react": "^2.3.1",
|
|
9
9
|
"glob": "^11.0.0",
|
|
10
10
|
"jsonc-parser": "^3.3.1",
|
|
@@ -257,7 +257,7 @@ function ChromeWrappedWidget({
|
|
|
257
257
|
readOnly,
|
|
258
258
|
}) {
|
|
259
259
|
const widgetRef = useRef(null)
|
|
260
|
-
const features = getFeatures(widget.type)
|
|
260
|
+
const features = getFeatures(widget.type, { isLocalDev: !readOnly })
|
|
261
261
|
|
|
262
262
|
const handleAction = useCallback((actionId) => {
|
|
263
263
|
if (actionId === 'delete') {
|
|
@@ -1502,7 +1502,7 @@ export default function CanvasPage({ name }) {
|
|
|
1502
1502
|
const allChildren = []
|
|
1503
1503
|
|
|
1504
1504
|
// 1. Component widgets (from jsxExports or sources fallback)
|
|
1505
|
-
const componentFeatures = getFeatures('component')
|
|
1505
|
+
const componentFeatures = getFeatures('component', { isLocalDev })
|
|
1506
1506
|
for (const entry of componentEntries) {
|
|
1507
1507
|
const { exportName, Component, sourceData } = entry
|
|
1508
1508
|
const sourcePosition = sourceData.position || { x: 0, y: 0 }
|
|
@@ -12,6 +12,25 @@
|
|
|
12
12
|
*/
|
|
13
13
|
import { createElement, Component as ReactComponent } from 'react'
|
|
14
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'
|
|
15
34
|
|
|
16
35
|
// ── Error Boundary ──────────────────────────────────────────────────
|
|
17
36
|
class IsolateErrorBoundary extends ReactComponent {
|
|
@@ -62,6 +81,9 @@ const modulePath = params.get('module')
|
|
|
62
81
|
const exportName = params.get('export')
|
|
63
82
|
const theme = params.get('theme') || 'light'
|
|
64
83
|
|
|
84
|
+
// Map theme to Primer colorMode
|
|
85
|
+
const colorMode = theme.startsWith('dark') ? 'night' : 'day'
|
|
86
|
+
|
|
65
87
|
// Apply theme to document for Primer / CSS-var inheritance
|
|
66
88
|
document.documentElement.setAttribute('data-color-mode', theme.startsWith('dark') ? 'dark' : 'light')
|
|
67
89
|
document.documentElement.setAttribute('data-dark-theme', theme.startsWith('dark') ? theme : '')
|
|
@@ -91,8 +113,12 @@ async function mount() {
|
|
|
91
113
|
}
|
|
92
114
|
|
|
93
115
|
root.render(
|
|
94
|
-
createElement(
|
|
95
|
-
createElement(
|
|
116
|
+
createElement(ThemeProvider, { colorMode },
|
|
117
|
+
createElement(BaseStyles, null,
|
|
118
|
+
createElement(IsolateErrorBoundary, { name: exportName },
|
|
119
|
+
createElement(Component),
|
|
120
|
+
),
|
|
121
|
+
),
|
|
96
122
|
),
|
|
97
123
|
)
|
|
98
124
|
} catch (err) {
|
|
@@ -3,6 +3,7 @@ import WidgetWrapper from './WidgetWrapper.jsx'
|
|
|
3
3
|
import ResizeHandle from './ResizeHandle.jsx'
|
|
4
4
|
import ComponentErrorBoundary from '../ComponentErrorBoundary.jsx'
|
|
5
5
|
import styles from './ComponentWidget.module.css'
|
|
6
|
+
import overlayStyles from './embedOverlay.module.css'
|
|
6
7
|
|
|
7
8
|
/**
|
|
8
9
|
* Renders a live JSX export from a .canvas.jsx companion file.
|
|
@@ -88,9 +89,25 @@ export default function ComponentWidget({
|
|
|
88
89
|
</div>
|
|
89
90
|
{!interactive && (
|
|
90
91
|
<div
|
|
91
|
-
className={
|
|
92
|
-
|
|
93
|
-
|
|
92
|
+
className={overlayStyles.interactOverlay}
|
|
93
|
+
onClick={(e) => {
|
|
94
|
+
// Don't enter interactive mode for modifier clicks (shift/meta/ctrl for multi-select)
|
|
95
|
+
if (e.shiftKey || e.metaKey || e.ctrlKey || e.altKey) return
|
|
96
|
+
enterInteractive()
|
|
97
|
+
}}
|
|
98
|
+
role="button"
|
|
99
|
+
tabIndex={0}
|
|
100
|
+
onKeyDown={(e) => {
|
|
101
|
+
if (e.key === 'Enter' || e.key === ' ') {
|
|
102
|
+
e.preventDefault()
|
|
103
|
+
e.stopPropagation()
|
|
104
|
+
enterInteractive()
|
|
105
|
+
}
|
|
106
|
+
}}
|
|
107
|
+
aria-label="Click to interact with component"
|
|
108
|
+
>
|
|
109
|
+
<span className={overlayStyles.interactHint}>Click to interact</span>
|
|
110
|
+
</div>
|
|
94
111
|
)}
|
|
95
112
|
{resizable && (
|
|
96
113
|
<ResizeHandle
|
|
@@ -3,6 +3,9 @@
|
|
|
3
3
|
overflow: hidden;
|
|
4
4
|
min-width: 100px;
|
|
5
5
|
min-height: 60px;
|
|
6
|
+
background: var(--bgColor-default, #ffffff);
|
|
7
|
+
width: 100%;
|
|
8
|
+
height: 100%;
|
|
6
9
|
}
|
|
7
10
|
|
|
8
11
|
.content {
|
|
@@ -16,10 +19,3 @@
|
|
|
16
19
|
height: 100%;
|
|
17
20
|
border: none;
|
|
18
21
|
}
|
|
19
|
-
|
|
20
|
-
.interactOverlay {
|
|
21
|
-
position: absolute;
|
|
22
|
-
inset: 0;
|
|
23
|
-
z-index: 1;
|
|
24
|
-
cursor: default;
|
|
25
|
-
}
|
|
@@ -5,6 +5,7 @@ import { readProp } from './widgetProps.js'
|
|
|
5
5
|
import { schemas } from './widgetConfig.js'
|
|
6
6
|
import { toFigmaEmbedUrl, getFigmaTitle, getFigmaType, isFigmaUrl } from './figmaUrl.js'
|
|
7
7
|
import styles from './FigmaEmbed.module.css'
|
|
8
|
+
import overlayStyles from './embedOverlay.module.css'
|
|
8
9
|
|
|
9
10
|
const figmaEmbedSchema = schemas['figma-embed']
|
|
10
11
|
|
|
@@ -126,9 +127,25 @@ export default forwardRef(function FigmaEmbed({ props, onUpdate, resizable }, re
|
|
|
126
127
|
</div>
|
|
127
128
|
{!interactive && !expanded && (
|
|
128
129
|
<div
|
|
129
|
-
className={
|
|
130
|
-
|
|
131
|
-
|
|
130
|
+
className={overlayStyles.interactOverlay}
|
|
131
|
+
onClick={(e) => {
|
|
132
|
+
// Don't enter interactive mode for modifier clicks (shift/meta/ctrl for multi-select)
|
|
133
|
+
if (e.shiftKey || e.metaKey || e.ctrlKey || e.altKey) return
|
|
134
|
+
enterInteractive()
|
|
135
|
+
}}
|
|
136
|
+
role="button"
|
|
137
|
+
tabIndex={0}
|
|
138
|
+
onKeyDown={(e) => {
|
|
139
|
+
if (e.key === 'Enter' || e.key === ' ') {
|
|
140
|
+
e.preventDefault()
|
|
141
|
+
e.stopPropagation()
|
|
142
|
+
enterInteractive()
|
|
143
|
+
}
|
|
144
|
+
}}
|
|
145
|
+
aria-label="Click to interact with Figma embed"
|
|
146
|
+
>
|
|
147
|
+
<span className={overlayStyles.interactHint}>Click to interact</span>
|
|
148
|
+
</div>
|
|
132
149
|
)}
|
|
133
150
|
</>
|
|
134
151
|
) : (
|
|
@@ -5,6 +5,7 @@ import WidgetWrapper from './WidgetWrapper.jsx'
|
|
|
5
5
|
import { readProp, prototypeEmbedSchema } from './widgetProps.js'
|
|
6
6
|
import { getEmbedChromeVars } from './embedTheme.js'
|
|
7
7
|
import styles from './PrototypeEmbed.module.css'
|
|
8
|
+
import overlayStyles from './embedOverlay.module.css'
|
|
8
9
|
|
|
9
10
|
function formatName(name) {
|
|
10
11
|
return name
|
|
@@ -401,9 +402,25 @@ export default forwardRef(function PrototypeEmbed({ props, onUpdate, resizable }
|
|
|
401
402
|
</div>
|
|
402
403
|
{!interactive && !expanded && (
|
|
403
404
|
<div
|
|
404
|
-
className={
|
|
405
|
-
|
|
406
|
-
|
|
405
|
+
className={overlayStyles.interactOverlay}
|
|
406
|
+
onClick={(e) => {
|
|
407
|
+
// Don't enter interactive mode for modifier clicks (shift/meta/ctrl for multi-select)
|
|
408
|
+
if (e.shiftKey || e.metaKey || e.ctrlKey || e.altKey) return
|
|
409
|
+
enterInteractive()
|
|
410
|
+
}}
|
|
411
|
+
role="button"
|
|
412
|
+
tabIndex={0}
|
|
413
|
+
onKeyDown={(e) => {
|
|
414
|
+
if (e.key === 'Enter' || e.key === ' ') {
|
|
415
|
+
e.preventDefault()
|
|
416
|
+
e.stopPropagation()
|
|
417
|
+
enterInteractive()
|
|
418
|
+
}
|
|
419
|
+
}}
|
|
420
|
+
aria-label="Click to interact with prototype"
|
|
421
|
+
>
|
|
422
|
+
<span className={overlayStyles.interactHint}>Click to interact</span>
|
|
423
|
+
</div>
|
|
407
424
|
)}
|
|
408
425
|
</>
|
|
409
426
|
) : (
|
|
@@ -411,14 +411,18 @@ export default function WidgetChrome({
|
|
|
411
411
|
onUpdate?.({ color })
|
|
412
412
|
}, [onUpdate])
|
|
413
413
|
|
|
414
|
-
|
|
414
|
+
// In readOnly mode, features are already filtered to prod-only by getFeatures.
|
|
415
|
+
// Show toolbar if there are prod features even when readOnly.
|
|
416
|
+
const hasFeatures = features.length > 0
|
|
417
|
+
const showToolbar = (hovered || selected) && (!readOnly || hasFeatures)
|
|
415
418
|
const showFeatures = showToolbar && !multiSelected
|
|
419
|
+
const menuFeatures = features.filter((f) => f.menu)
|
|
416
420
|
|
|
417
421
|
return (
|
|
418
422
|
<div
|
|
419
423
|
className={styles.chromeContainer}
|
|
420
|
-
onMouseEnter={readOnly ? undefined : handleMouseEnter}
|
|
421
|
-
onMouseLeave={readOnly ? undefined : handleMouseLeave}
|
|
424
|
+
onMouseEnter={(readOnly && !hasFeatures) ? undefined : handleMouseEnter}
|
|
425
|
+
onMouseLeave={(readOnly && !hasFeatures) ? undefined : handleMouseLeave}
|
|
422
426
|
>
|
|
423
427
|
<div className={`tc-drag-surface ${styles.widgetSlot} ${selected ? styles.widgetSlotSelected : ''} ${multiSelected ? styles.widgetSlotMultiSelected : ''}`}>
|
|
424
428
|
{children}
|
|
@@ -495,22 +499,26 @@ export default function WidgetChrome({
|
|
|
495
499
|
|
|
496
500
|
return null
|
|
497
501
|
})}
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
502
|
+
{menuFeatures.length > 0 && (
|
|
503
|
+
<WidgetOverflowMenu
|
|
504
|
+
widgetId={widgetId}
|
|
505
|
+
menuFeatures={menuFeatures}
|
|
506
|
+
onAction={onAction}
|
|
507
|
+
/>
|
|
508
|
+
)}
|
|
503
509
|
</div>
|
|
504
510
|
)}
|
|
505
511
|
|
|
506
|
-
|
|
507
|
-
<
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
512
|
+
{!readOnly && (
|
|
513
|
+
<Tooltip text={selected ? "Click and drag to move" : "Select"} direction="n">
|
|
514
|
+
<button
|
|
515
|
+
className={`tc-drag-handle ${styles.selectHandle} ${selected ? styles.selectHandleActive : ''}`}
|
|
516
|
+
onClick={handleHandleClick}
|
|
517
|
+
aria-label={selected ? "Drag to move widget" : "Select widget"}
|
|
518
|
+
aria-pressed={selected}
|
|
519
|
+
/>
|
|
520
|
+
</Tooltip>
|
|
521
|
+
)}
|
|
514
522
|
</div>
|
|
515
523
|
</div>
|
|
516
524
|
</div>
|
|
@@ -33,7 +33,7 @@
|
|
|
33
33
|
top: calc(100% + 10px);
|
|
34
34
|
}
|
|
35
35
|
|
|
36
|
-
/* Trigger dot —
|
|
36
|
+
/* Trigger dot — positioned in the toolbar, visible at rest */
|
|
37
37
|
.triggerDot {
|
|
38
38
|
width: 6px;
|
|
39
39
|
height: 6px;
|
|
@@ -41,10 +41,6 @@
|
|
|
41
41
|
background: var(--borderColor-muted, #d0d7de);
|
|
42
42
|
opacity: 0.5;
|
|
43
43
|
transition: opacity 120ms;
|
|
44
|
-
position: absolute;
|
|
45
|
-
left: 50%;
|
|
46
|
-
top: 50%;
|
|
47
|
-
transform: translate(-50%, -50%);
|
|
48
44
|
}
|
|
49
45
|
|
|
50
46
|
:global([data-sb-canvas-theme^='dark']) .triggerDot {
|
|
@@ -235,7 +231,7 @@
|
|
|
235
231
|
.overflowMenu {
|
|
236
232
|
position: absolute;
|
|
237
233
|
top: calc(100% + 10px);
|
|
238
|
-
|
|
234
|
+
left: 0;
|
|
239
235
|
min-width: max-content;
|
|
240
236
|
padding: 4px;
|
|
241
237
|
background: var(--bgColor-default, #ffffff);
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for embed interaction UX (click-to-interact overlay).
|
|
3
|
+
*/
|
|
4
|
+
import { describe, it, expect, vi, beforeEach } 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
|
+
|
|
10
|
+
// Mock buildPrototypeIndex for PrototypeEmbed
|
|
11
|
+
vi.mock('@dfosco/storyboard-core', () => ({
|
|
12
|
+
buildPrototypeIndex: () => ({ folders: [], prototypes: [], globalFlows: [], sorted: { title: { prototypes: [], folders: [] } } }),
|
|
13
|
+
}))
|
|
14
|
+
|
|
15
|
+
// Simple mock wrapper for WidgetWrapper
|
|
16
|
+
vi.mock('./WidgetWrapper.jsx', () => ({
|
|
17
|
+
default: ({ children }) => <div data-testid="widget-wrapper">{children}</div>,
|
|
18
|
+
}))
|
|
19
|
+
|
|
20
|
+
// Mock ResizeHandle
|
|
21
|
+
vi.mock('./ResizeHandle.jsx', () => ({
|
|
22
|
+
default: () => <div data-testid="resize-handle" />,
|
|
23
|
+
}))
|
|
24
|
+
|
|
25
|
+
// Mock ComponentErrorBoundary
|
|
26
|
+
vi.mock('../ComponentErrorBoundary.jsx', () => ({
|
|
27
|
+
default: ({ children }) => <div data-testid="error-boundary">{children}</div>,
|
|
28
|
+
}))
|
|
29
|
+
|
|
30
|
+
describe('Embed interaction overlay', () => {
|
|
31
|
+
describe('PrototypeEmbed', () => {
|
|
32
|
+
const defaultProps = {
|
|
33
|
+
props: { src: '/test', width: 400, height: 300, zoom: 100 },
|
|
34
|
+
onUpdate: vi.fn(),
|
|
35
|
+
resizable: false,
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
it('renders "Click to interact" hint on hover', () => {
|
|
39
|
+
render(<PrototypeEmbed {...defaultProps} />)
|
|
40
|
+
|
|
41
|
+
const hint = screen.getByText('Click to interact')
|
|
42
|
+
expect(hint).toBeInTheDocument()
|
|
43
|
+
// CSS modules mangle class names, just check the element exists
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
it('enters interactive mode on single click (not double-click)', async () => {
|
|
47
|
+
render(<PrototypeEmbed {...defaultProps} />)
|
|
48
|
+
|
|
49
|
+
// Overlay should exist before interaction
|
|
50
|
+
const overlay = screen.getByRole('button', { name: /click to interact/i })
|
|
51
|
+
expect(overlay).toBeInTheDocument()
|
|
52
|
+
|
|
53
|
+
// Single click should remove the overlay (enter interactive mode)
|
|
54
|
+
fireEvent.click(overlay)
|
|
55
|
+
|
|
56
|
+
// Overlay should no longer exist
|
|
57
|
+
expect(screen.queryByRole('button', { name: /click to interact/i })).not.toBeInTheDocument()
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
it('does not enter interactive mode on shift+click (preserves multi-select)', () => {
|
|
61
|
+
render(<PrototypeEmbed {...defaultProps} />)
|
|
62
|
+
|
|
63
|
+
const overlay = screen.getByRole('button', { name: /click to interact/i })
|
|
64
|
+
fireEvent.click(overlay, { shiftKey: true })
|
|
65
|
+
|
|
66
|
+
// Overlay should still exist (did not enter interactive mode)
|
|
67
|
+
expect(screen.getByRole('button', { name: /click to interact/i })).toBeInTheDocument()
|
|
68
|
+
})
|
|
69
|
+
|
|
70
|
+
it('does not enter interactive mode on meta+click (preserves multi-select)', () => {
|
|
71
|
+
render(<PrototypeEmbed {...defaultProps} />)
|
|
72
|
+
|
|
73
|
+
const overlay = screen.getByRole('button', { name: /click to interact/i })
|
|
74
|
+
fireEvent.click(overlay, { metaKey: true })
|
|
75
|
+
|
|
76
|
+
expect(screen.getByRole('button', { name: /click to interact/i })).toBeInTheDocument()
|
|
77
|
+
})
|
|
78
|
+
|
|
79
|
+
it('supports keyboard interaction (Enter key) with event prevention', () => {
|
|
80
|
+
render(<PrototypeEmbed {...defaultProps} />)
|
|
81
|
+
|
|
82
|
+
const overlay = screen.getByRole('button', { name: /click to interact/i })
|
|
83
|
+
const event = { key: 'Enter', preventDefault: vi.fn(), stopPropagation: vi.fn() }
|
|
84
|
+
fireEvent.keyDown(overlay, event)
|
|
85
|
+
|
|
86
|
+
expect(screen.queryByRole('button', { name: /click to interact/i })).not.toBeInTheDocument()
|
|
87
|
+
})
|
|
88
|
+
|
|
89
|
+
it('supports keyboard interaction (Space key) with event prevention', () => {
|
|
90
|
+
render(<PrototypeEmbed {...defaultProps} />)
|
|
91
|
+
|
|
92
|
+
const overlay = screen.getByRole('button', { name: /click to interact/i })
|
|
93
|
+
const event = { key: ' ', preventDefault: vi.fn(), stopPropagation: vi.fn() }
|
|
94
|
+
fireEvent.keyDown(overlay, event)
|
|
95
|
+
|
|
96
|
+
expect(screen.queryByRole('button', { name: /click to interact/i })).not.toBeInTheDocument()
|
|
97
|
+
})
|
|
98
|
+
})
|
|
99
|
+
|
|
100
|
+
describe('FigmaEmbed', () => {
|
|
101
|
+
const defaultProps = {
|
|
102
|
+
props: { url: 'https://www.figma.com/design/abc123/Test', width: 400, height: 300 },
|
|
103
|
+
onUpdate: vi.fn(),
|
|
104
|
+
resizable: false,
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
it('renders "Click to interact" hint', () => {
|
|
108
|
+
render(<FigmaEmbed {...defaultProps} />)
|
|
109
|
+
|
|
110
|
+
const hint = screen.getByText('Click to interact')
|
|
111
|
+
expect(hint).toBeInTheDocument()
|
|
112
|
+
})
|
|
113
|
+
|
|
114
|
+
it('enters interactive mode on single click', () => {
|
|
115
|
+
render(<FigmaEmbed {...defaultProps} />)
|
|
116
|
+
|
|
117
|
+
const overlay = screen.getByRole('button', { name: /click to interact/i })
|
|
118
|
+
fireEvent.click(overlay)
|
|
119
|
+
|
|
120
|
+
expect(screen.queryByRole('button', { name: /click to interact/i })).not.toBeInTheDocument()
|
|
121
|
+
})
|
|
122
|
+
})
|
|
123
|
+
|
|
124
|
+
describe('ComponentWidget', () => {
|
|
125
|
+
const MockComponent = () => <div>Mock Component</div>
|
|
126
|
+
|
|
127
|
+
const defaultProps = {
|
|
128
|
+
component: MockComponent,
|
|
129
|
+
jsxModule: null,
|
|
130
|
+
exportName: 'MockComponent',
|
|
131
|
+
canvasTheme: 'light',
|
|
132
|
+
isLocalDev: false,
|
|
133
|
+
width: 200,
|
|
134
|
+
height: 150,
|
|
135
|
+
onUpdate: vi.fn(),
|
|
136
|
+
resizable: false,
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
it('renders "Click to interact" hint', () => {
|
|
140
|
+
render(<ComponentWidget {...defaultProps} />)
|
|
141
|
+
|
|
142
|
+
const hint = screen.getByText('Click to interact')
|
|
143
|
+
expect(hint).toBeInTheDocument()
|
|
144
|
+
})
|
|
145
|
+
|
|
146
|
+
it('enters interactive mode on single click', () => {
|
|
147
|
+
render(<ComponentWidget {...defaultProps} />)
|
|
148
|
+
|
|
149
|
+
const overlay = screen.getByRole('button', { name: /click to interact/i })
|
|
150
|
+
fireEvent.click(overlay)
|
|
151
|
+
|
|
152
|
+
expect(screen.queryByRole('button', { name: /click to interact/i })).not.toBeInTheDocument()
|
|
153
|
+
})
|
|
154
|
+
})
|
|
155
|
+
})
|
|
@@ -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
|
+
}
|
|
@@ -34,7 +34,16 @@ function resolveFeature(feature) {
|
|
|
34
34
|
if (key === 'items' && Array.isArray(val)) {
|
|
35
35
|
resolved[key] = val.map((item) => {
|
|
36
36
|
const r = {}
|
|
37
|
-
for (const [k, v] of Object.entries(item))
|
|
37
|
+
for (const [k, v] of Object.entries(item)) {
|
|
38
|
+
// Resolve nested alt object inside items
|
|
39
|
+
if (k === 'alt' && v && typeof v === 'object') {
|
|
40
|
+
const altResolved = {}
|
|
41
|
+
for (const [ak, av] of Object.entries(v)) altResolved[ak] = resolveVar(av)
|
|
42
|
+
r[k] = altResolved
|
|
43
|
+
} else {
|
|
44
|
+
r[k] = resolveVar(v)
|
|
45
|
+
}
|
|
46
|
+
}
|
|
38
47
|
return r
|
|
39
48
|
})
|
|
40
49
|
} else if (key === 'alt' && val && typeof val === 'object') {
|
|
@@ -103,14 +112,16 @@ export const widgetTypes = buildWidgetTypes()
|
|
|
103
112
|
|
|
104
113
|
/**
|
|
105
114
|
* Get the feature list for a widget type.
|
|
106
|
-
* In production
|
|
115
|
+
* In production (or when isLocalDev is false, e.g. ?prodMode simulation),
|
|
116
|
+
* only features with `prod: true` are returned.
|
|
107
117
|
* In dev, all features are returned.
|
|
108
118
|
* @param {string} type — widget type string
|
|
119
|
+
* @param {{ isLocalDev?: boolean }} [options]
|
|
109
120
|
* @returns {Array} features array from config (variables resolved), or empty array
|
|
110
121
|
*/
|
|
111
|
-
export function getFeatures(type) {
|
|
122
|
+
export function getFeatures(type, { isLocalDev = true } = {}) {
|
|
112
123
|
const features = widgetTypes[type]?.features ?? []
|
|
113
|
-
if (import.meta.env?.PROD) {
|
|
124
|
+
if (import.meta.env?.PROD || !isLocalDev) {
|
|
114
125
|
return features.filter(f => f.prod)
|
|
115
126
|
}
|
|
116
127
|
return features
|
|
@@ -32,6 +32,25 @@ describe('getFeatures', () => {
|
|
|
32
32
|
it('returns empty array for unknown widget types', () => {
|
|
33
33
|
expect(getFeatures('nonexistent')).toEqual([])
|
|
34
34
|
})
|
|
35
|
+
|
|
36
|
+
it('returns only prod features when isLocalDev is false', () => {
|
|
37
|
+
const features = getFeatures('figma-embed', { isLocalDev: false })
|
|
38
|
+
expect(features.length).toBeGreaterThan(0)
|
|
39
|
+
expect(features.every(f => f.prod === true)).toBe(true)
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
it('returns all features when isLocalDev is true (default)', () => {
|
|
43
|
+
const allFeatures = getFeatures('figma-embed')
|
|
44
|
+
const prodFeatures = getFeatures('figma-embed', { isLocalDev: false })
|
|
45
|
+
expect(allFeatures.length).toBeGreaterThan(prodFeatures.length)
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
it('includes menu-only prod features when isLocalDev is false', () => {
|
|
49
|
+
const features = getFeatures('figma-embed', { isLocalDev: false })
|
|
50
|
+
const menuFeature = features.find(f => f.menu)
|
|
51
|
+
expect(menuFeature).toBeDefined()
|
|
52
|
+
expect(menuFeature.prod).toBe(true)
|
|
53
|
+
})
|
|
35
54
|
})
|
|
36
55
|
|
|
37
56
|
describe('getWidgetMeta', () => {
|
package/src/vite/data-plugin.js
CHANGED
|
@@ -633,6 +633,22 @@ function generateModule({ index, protoFolders, flowRoutes, canvasRoutes }, root)
|
|
|
633
633
|
`export { flows, scenes, objects, records, prototypes, folders, canvases }`,
|
|
634
634
|
`export const index = { flows, scenes, objects, records, prototypes, folders, canvases }`,
|
|
635
635
|
`export default index`,
|
|
636
|
+
'',
|
|
637
|
+
'// Live-patch canvas data on HMR events so SPA navigation shows fresh state',
|
|
638
|
+
'if (import.meta.hot) {',
|
|
639
|
+
' import.meta.hot.on("storyboard:canvas-file-changed", (data) => {',
|
|
640
|
+
' if (!data) return',
|
|
641
|
+
' if (data.removed) {',
|
|
642
|
+
' delete canvases[data.name]',
|
|
643
|
+
' } else if (data.metadata) {',
|
|
644
|
+
' // Merge into existing entry to preserve build-time fields (_jsxModule, _jsxImport, etc.)',
|
|
645
|
+
' canvases[data.name] = canvases[data.name]',
|
|
646
|
+
' ? Object.assign({}, canvases[data.name], data.metadata)',
|
|
647
|
+
' : data.metadata',
|
|
648
|
+
' }',
|
|
649
|
+
' init({ flows, objects, records, prototypes, folders, canvases })',
|
|
650
|
+
' })',
|
|
651
|
+
'}',
|
|
636
652
|
].join('\n')
|
|
637
653
|
}
|
|
638
654
|
|
|
@@ -655,10 +671,11 @@ export default function storyboardDataPlugin() {
|
|
|
655
671
|
config() {
|
|
656
672
|
return {
|
|
657
673
|
optimizeDeps: {
|
|
658
|
-
//
|
|
659
|
-
//
|
|
660
|
-
//
|
|
661
|
-
|
|
674
|
+
// @dfosco/storyboard-react is excluded (virtual module), so Vite
|
|
675
|
+
// can't trace into its deps. Include the remark entry points so
|
|
676
|
+
// Vite pre-bundles the full chain — covers all transitive CJS
|
|
677
|
+
// packages (debug, extend, etc.) without whack-a-mole.
|
|
678
|
+
include: ['remark', 'remark-gfm', 'remark-html'],
|
|
662
679
|
exclude: ['@dfosco/storyboard-react'],
|
|
663
680
|
},
|
|
664
681
|
}
|
|
@@ -696,7 +713,7 @@ export default function storyboardDataPlugin() {
|
|
|
696
713
|
const rawHtml = [
|
|
697
714
|
'<!DOCTYPE html>',
|
|
698
715
|
'<html><head>',
|
|
699
|
-
'<style>html,body{margin:0;padding:0;width:100%;height:100
|
|
716
|
+
'<style>html,body{margin:0;padding:0;width:100%;height:100%;background:var(--bgColor-default,transparent)}#root{width:100%;height:100%}</style>',
|
|
700
717
|
'</head><body>',
|
|
701
718
|
'<div id="root"></div>',
|
|
702
719
|
`<script type="module" src="/@fs${isolateEntryPath}"></script>`,
|
|
@@ -729,22 +746,50 @@ export default function storyboardDataPlugin() {
|
|
|
729
746
|
}
|
|
730
747
|
}
|
|
731
748
|
|
|
749
|
+
// Mark the virtual module as stale so the next page load rebuilds it,
|
|
750
|
+
// but do NOT trigger a full-reload (avoids losing canvas editing state).
|
|
751
|
+
const softInvalidate = () => {
|
|
752
|
+
buildResult = null
|
|
753
|
+
const mod = server.moduleGraph.getModuleById(RESOLVED_ID)
|
|
754
|
+
if (mod) server.moduleGraph.invalidateModule(mod)
|
|
755
|
+
}
|
|
756
|
+
|
|
757
|
+
// Read a canvas file and build HMR metadata for the client-side listener.
|
|
758
|
+
const readCanvasMetadata = (filePath, parsed) => {
|
|
759
|
+
try {
|
|
760
|
+
const absPath = path.resolve(root, filePath)
|
|
761
|
+
const raw = fs.readFileSync(absPath, 'utf-8')
|
|
762
|
+
const materialized = materializeFromText(raw)
|
|
763
|
+
const result = { ...materialized }
|
|
764
|
+
// Inject _route and _folder the same way generateModule does
|
|
765
|
+
if (parsed.inferredRoute) result._route = parsed.inferredRoute
|
|
766
|
+
const folderDirMatch = path.relative(root, absPath).replace(/\\/g, '/').match(/(?:^|\/)src\/(?:prototypes|canvas)\/([^/]+)\.folder\//)
|
|
767
|
+
if (folderDirMatch) result._folder = folderDirMatch[1]
|
|
768
|
+
return result
|
|
769
|
+
} catch {
|
|
770
|
+
return null
|
|
771
|
+
}
|
|
772
|
+
}
|
|
773
|
+
|
|
732
774
|
const invalidate = (filePath) => {
|
|
733
775
|
const normalized = filePath.replace(/\\/g, '/')
|
|
734
|
-
//
|
|
735
|
-
//
|
|
736
|
-
//
|
|
737
|
-
//
|
|
738
|
-
//
|
|
776
|
+
// Canvas .jsonl content changes are mutated at runtime by the canvas
|
|
777
|
+
// server API. A full-reload would create a feedback loop (save →
|
|
778
|
+
// file change → reload → lose editing state). Instead, soft-invalidate
|
|
779
|
+
// the virtual module (so page refresh picks up changes) and send a
|
|
780
|
+
// custom HMR event with updated metadata so the canvas page and
|
|
781
|
+
// viewfinder can react in place.
|
|
739
782
|
if (/\.canvas\.jsonl$/.test(normalized)) {
|
|
740
783
|
const parsed = parseDataFile(filePath)
|
|
741
784
|
if (parsed?.suffix === 'canvas' && parsed?.name) {
|
|
785
|
+
const metadata = readCanvasMetadata(filePath, parsed)
|
|
742
786
|
server.ws.send({
|
|
743
787
|
type: 'custom',
|
|
744
788
|
event: 'storyboard:canvas-file-changed',
|
|
745
|
-
data: { name: parsed.name },
|
|
789
|
+
data: { name: parsed.name, ...(metadata ? { metadata } : {}) },
|
|
746
790
|
})
|
|
747
791
|
}
|
|
792
|
+
softInvalidate()
|
|
748
793
|
return
|
|
749
794
|
}
|
|
750
795
|
|
|
@@ -789,23 +834,27 @@ export default function storyboardDataPlugin() {
|
|
|
789
834
|
server.ws.send({
|
|
790
835
|
type: 'custom',
|
|
791
836
|
event: 'storyboard:canvas-file-changed',
|
|
792
|
-
data: { name },
|
|
837
|
+
data: { name, removed: true },
|
|
793
838
|
})
|
|
839
|
+
softInvalidate()
|
|
794
840
|
}, 1500)
|
|
795
841
|
pendingCanvasUnlinks.set(name, timer)
|
|
796
842
|
return
|
|
797
843
|
}
|
|
798
844
|
|
|
799
845
|
if (eventType === 'add') {
|
|
846
|
+
const metadata = readCanvasMetadata(filePath, parsed)
|
|
800
847
|
const pending = pendingCanvasUnlinks.get(name)
|
|
801
848
|
if (pending) {
|
|
849
|
+
// unlink+add pair = in-place save (atomic write), not a real remove
|
|
802
850
|
clearTimeout(pending)
|
|
803
851
|
pendingCanvasUnlinks.delete(name)
|
|
804
852
|
server.ws.send({
|
|
805
853
|
type: 'custom',
|
|
806
854
|
event: 'storyboard:canvas-file-changed',
|
|
807
|
-
data: { name },
|
|
855
|
+
data: { name, ...(metadata ? { metadata } : {}) },
|
|
808
856
|
})
|
|
857
|
+
softInvalidate()
|
|
809
858
|
return
|
|
810
859
|
}
|
|
811
860
|
|
|
@@ -813,8 +862,9 @@ export default function storyboardDataPlugin() {
|
|
|
813
862
|
server.ws.send({
|
|
814
863
|
type: 'custom',
|
|
815
864
|
event: 'storyboard:canvas-file-changed',
|
|
816
|
-
data: { name },
|
|
865
|
+
data: { name, ...(metadata ? { metadata } : {}) },
|
|
817
866
|
})
|
|
867
|
+
softInvalidate()
|
|
818
868
|
return
|
|
819
869
|
}
|
|
820
870
|
|
|
@@ -822,8 +872,9 @@ export default function storyboardDataPlugin() {
|
|
|
822
872
|
server.ws.send({
|
|
823
873
|
type: 'custom',
|
|
824
874
|
event: 'storyboard:canvas-file-changed',
|
|
825
|
-
data: { name },
|
|
875
|
+
data: { name, ...(metadata ? { metadata } : {}) },
|
|
826
876
|
})
|
|
877
|
+
softInvalidate()
|
|
827
878
|
return
|
|
828
879
|
}
|
|
829
880
|
}
|
|
@@ -858,18 +909,10 @@ export default function storyboardDataPlugin() {
|
|
|
858
909
|
const normalized = ctx.file.replace(/\\/g, '/')
|
|
859
910
|
if (!/\.canvas\.jsonl$/.test(normalized)) return
|
|
860
911
|
|
|
861
|
-
const parsed = parseDataFile(ctx.file)
|
|
862
|
-
if (parsed?.suffix === 'canvas' && parsed?.name) {
|
|
863
|
-
ctx.server.ws.send({
|
|
864
|
-
type: 'custom',
|
|
865
|
-
event: 'storyboard:canvas-file-changed',
|
|
866
|
-
data: { name: parsed.name },
|
|
867
|
-
})
|
|
868
|
-
}
|
|
869
|
-
|
|
870
912
|
// Prevent Vite's default fallback behavior (full page reload) for
|
|
871
|
-
// non-module .canvas.jsonl edits.
|
|
872
|
-
//
|
|
913
|
+
// non-module .canvas.jsonl edits. The watcher 'change' handler
|
|
914
|
+
// (invalidate) already sends the custom HMR event and soft-invalidates
|
|
915
|
+
// the virtual module — no duplicate event needed here.
|
|
873
916
|
return []
|
|
874
917
|
},
|
|
875
918
|
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { mkdtempSync, writeFileSync, mkdirSync, rmSync } from 'node:fs'
|
|
1
|
+
import { mkdtempSync, writeFileSync, mkdirSync, rmSync, readFileSync } from 'node:fs'
|
|
2
2
|
import { tmpdir } from 'node:os'
|
|
3
3
|
import path from 'node:path'
|
|
4
4
|
import storyboardDataPlugin, { resolveTemplateVars, computeTemplateVars } from './data-plugin.js'
|
|
@@ -53,10 +53,12 @@ describe('storyboardDataPlugin', () => {
|
|
|
53
53
|
expect(config.optimizeDeps.exclude).toContain('@dfosco/storyboard-react')
|
|
54
54
|
})
|
|
55
55
|
|
|
56
|
-
it('config() includes
|
|
56
|
+
it('config() includes remark stack in optimizeDeps so Vite pre-bundles transitive CJS deps', () => {
|
|
57
57
|
const plugin = storyboardDataPlugin()
|
|
58
58
|
const config = plugin.config()
|
|
59
|
-
expect(config.optimizeDeps.include).toContain('
|
|
59
|
+
expect(config.optimizeDeps.include).toContain('remark')
|
|
60
|
+
expect(config.optimizeDeps.include).toContain('remark-gfm')
|
|
61
|
+
expect(config.optimizeDeps.include).toContain('remark-html')
|
|
60
62
|
})
|
|
61
63
|
|
|
62
64
|
it("resolveId returns resolved ID for 'virtual:storyboard-data-index'", () => {
|
|
@@ -827,3 +829,220 @@ describe('template variable integration', () => {
|
|
|
827
829
|
warnSpy.mockRestore()
|
|
828
830
|
})
|
|
829
831
|
})
|
|
832
|
+
|
|
833
|
+
// ── Canvas watcher / HMR tests ──────────────────────────────────────
|
|
834
|
+
|
|
835
|
+
describe('canvas watcher behavior', () => {
|
|
836
|
+
/** Helper: create a mock Vite dev server for configureServer */
|
|
837
|
+
function createMockServer(root) {
|
|
838
|
+
const listeners = {}
|
|
839
|
+
const wsSent = []
|
|
840
|
+
const invalidatedModules = []
|
|
841
|
+
|
|
842
|
+
return {
|
|
843
|
+
wsSent,
|
|
844
|
+
invalidatedModules,
|
|
845
|
+
listeners,
|
|
846
|
+
config: { root, base: '/' },
|
|
847
|
+
watcher: {
|
|
848
|
+
add: vi.fn(),
|
|
849
|
+
on(event, fn) {
|
|
850
|
+
if (!listeners[event]) listeners[event] = []
|
|
851
|
+
listeners[event].push(fn)
|
|
852
|
+
},
|
|
853
|
+
},
|
|
854
|
+
moduleGraph: {
|
|
855
|
+
getModuleById(id) {
|
|
856
|
+
if (id === RESOLVED_ID) return { id: RESOLVED_ID }
|
|
857
|
+
return null
|
|
858
|
+
},
|
|
859
|
+
invalidateModule(mod) {
|
|
860
|
+
invalidatedModules.push(mod.id)
|
|
861
|
+
},
|
|
862
|
+
},
|
|
863
|
+
ws: {
|
|
864
|
+
send(msg) { wsSent.push(msg) },
|
|
865
|
+
},
|
|
866
|
+
middlewares: {
|
|
867
|
+
use: vi.fn(),
|
|
868
|
+
},
|
|
869
|
+
}
|
|
870
|
+
}
|
|
871
|
+
|
|
872
|
+
/** Emit a watcher event on the mock server */
|
|
873
|
+
function emit(server, event, filePath) {
|
|
874
|
+
for (const fn of (server.listeners[event] || [])) {
|
|
875
|
+
fn(filePath)
|
|
876
|
+
}
|
|
877
|
+
}
|
|
878
|
+
|
|
879
|
+
function writeCanvasFile(dir, name, title) {
|
|
880
|
+
const canvasDir = path.join(dir, 'src', 'canvas')
|
|
881
|
+
mkdirSync(canvasDir, { recursive: true })
|
|
882
|
+
const evt = { event: 'canvas_created', title: title || name, timestamp: Date.now() }
|
|
883
|
+
writeFileSync(path.join(canvasDir, `${name}.canvas.jsonl`), JSON.stringify(evt) + '\n')
|
|
884
|
+
}
|
|
885
|
+
|
|
886
|
+
it('soft-invalidates virtual module on canvas content change (no full-reload)', () => {
|
|
887
|
+
writeCanvasFile(tmpDir, 'test-canvas', 'Original Title')
|
|
888
|
+
const plugin = createPlugin()
|
|
889
|
+
// Force initial buildResult
|
|
890
|
+
plugin.load(RESOLVED_ID)
|
|
891
|
+
|
|
892
|
+
const server = createMockServer(tmpDir)
|
|
893
|
+
plugin.configureServer(server)
|
|
894
|
+
|
|
895
|
+
// Simulate a canvas file content change
|
|
896
|
+
const canvasPath = path.join(tmpDir, 'src', 'canvas', 'test-canvas.canvas.jsonl')
|
|
897
|
+
emit(server, 'change', canvasPath)
|
|
898
|
+
|
|
899
|
+
// Should have sent custom HMR event (not full-reload)
|
|
900
|
+
const customEvents = server.wsSent.filter(m => m.type === 'custom')
|
|
901
|
+
const fullReloads = server.wsSent.filter(m => m.type === 'full-reload')
|
|
902
|
+
|
|
903
|
+
expect(customEvents.length).toBe(1)
|
|
904
|
+
expect(customEvents[0].event).toBe('storyboard:canvas-file-changed')
|
|
905
|
+
expect(customEvents[0].data.name).toBe('test-canvas')
|
|
906
|
+
expect(fullReloads.length).toBe(0)
|
|
907
|
+
|
|
908
|
+
// Should have invalidated the virtual module
|
|
909
|
+
expect(server.invalidatedModules).toContain(RESOLVED_ID)
|
|
910
|
+
})
|
|
911
|
+
|
|
912
|
+
it('includes metadata in HMR event for canvas content changes', () => {
|
|
913
|
+
writeCanvasFile(tmpDir, 'meta-canvas', 'My Canvas Title')
|
|
914
|
+
const plugin = createPlugin()
|
|
915
|
+
plugin.load(RESOLVED_ID)
|
|
916
|
+
|
|
917
|
+
const server = createMockServer(tmpDir)
|
|
918
|
+
plugin.configureServer(server)
|
|
919
|
+
|
|
920
|
+
emit(server, 'change', path.join(tmpDir, 'src', 'canvas', 'meta-canvas.canvas.jsonl'))
|
|
921
|
+
|
|
922
|
+
const event = server.wsSent.find(m => m.type === 'custom')
|
|
923
|
+
expect(event.data.metadata).toBeDefined()
|
|
924
|
+
expect(event.data.metadata.title).toBe('My Canvas Title')
|
|
925
|
+
})
|
|
926
|
+
|
|
927
|
+
it('soft-invalidates on canvas file add (new canvas)', () => {
|
|
928
|
+
const plugin = createPlugin()
|
|
929
|
+
plugin.load(RESOLVED_ID)
|
|
930
|
+
|
|
931
|
+
const server = createMockServer(tmpDir)
|
|
932
|
+
plugin.configureServer(server)
|
|
933
|
+
|
|
934
|
+
// Create the file after the server is configured
|
|
935
|
+
writeCanvasFile(tmpDir, 'new-canvas', 'Brand New')
|
|
936
|
+
emit(server, 'add', path.join(tmpDir, 'src', 'canvas', 'new-canvas.canvas.jsonl'))
|
|
937
|
+
|
|
938
|
+
const customEvents = server.wsSent.filter(m => m.type === 'custom')
|
|
939
|
+
const fullReloads = server.wsSent.filter(m => m.type === 'full-reload')
|
|
940
|
+
|
|
941
|
+
expect(customEvents.length).toBe(1)
|
|
942
|
+
expect(customEvents[0].data.name).toBe('new-canvas')
|
|
943
|
+
expect(customEvents[0].data.metadata).toBeDefined()
|
|
944
|
+
expect(fullReloads.length).toBe(0)
|
|
945
|
+
expect(server.invalidatedModules).toContain(RESOLVED_ID)
|
|
946
|
+
})
|
|
947
|
+
|
|
948
|
+
it('soft-invalidates on canvas file unlink after timeout (true delete)', async () => {
|
|
949
|
+
writeCanvasFile(tmpDir, 'doomed-canvas', 'Gone Soon')
|
|
950
|
+
const plugin = createPlugin()
|
|
951
|
+
plugin.load(RESOLVED_ID)
|
|
952
|
+
|
|
953
|
+
const server = createMockServer(tmpDir)
|
|
954
|
+
plugin.configureServer(server)
|
|
955
|
+
|
|
956
|
+
emit(server, 'unlink', path.join(tmpDir, 'src', 'canvas', 'doomed-canvas.canvas.jsonl'))
|
|
957
|
+
|
|
958
|
+
// Immediately after unlink — no event yet (deferred by 1500ms)
|
|
959
|
+
expect(server.wsSent.length).toBe(0)
|
|
960
|
+
|
|
961
|
+
// Wait for deferred timer
|
|
962
|
+
await new Promise(resolve => setTimeout(resolve, 1600))
|
|
963
|
+
|
|
964
|
+
const customEvents = server.wsSent.filter(m => m.type === 'custom')
|
|
965
|
+
expect(customEvents.length).toBe(1)
|
|
966
|
+
expect(customEvents[0].data.name).toBe('doomed-canvas')
|
|
967
|
+
expect(customEvents[0].data.removed).toBe(true)
|
|
968
|
+
expect(server.invalidatedModules).toContain(RESOLVED_ID)
|
|
969
|
+
})
|
|
970
|
+
|
|
971
|
+
it('cancels deferred unlink on add (atomic write / in-place save)', async () => {
|
|
972
|
+
writeCanvasFile(tmpDir, 'saved-canvas', 'Saved')
|
|
973
|
+
const plugin = createPlugin()
|
|
974
|
+
plugin.load(RESOLVED_ID)
|
|
975
|
+
|
|
976
|
+
const server = createMockServer(tmpDir)
|
|
977
|
+
plugin.configureServer(server)
|
|
978
|
+
|
|
979
|
+
const canvasPath = path.join(tmpDir, 'src', 'canvas', 'saved-canvas.canvas.jsonl')
|
|
980
|
+
|
|
981
|
+
// Simulate atomic write: unlink then add within 1500ms
|
|
982
|
+
emit(server, 'unlink', canvasPath)
|
|
983
|
+
emit(server, 'add', canvasPath)
|
|
984
|
+
|
|
985
|
+
// Should have sent one event immediately (the add cancelling the unlink)
|
|
986
|
+
const customEvents = server.wsSent.filter(m => m.type === 'custom')
|
|
987
|
+
expect(customEvents.length).toBe(1)
|
|
988
|
+
expect(customEvents[0].data.name).toBe('saved-canvas')
|
|
989
|
+
expect(customEvents[0].data.removed).toBeUndefined()
|
|
990
|
+
expect(server.invalidatedModules).toContain(RESOLVED_ID)
|
|
991
|
+
|
|
992
|
+
// Wait past the unlink timer — should NOT get a second event
|
|
993
|
+
await new Promise(resolve => setTimeout(resolve, 1600))
|
|
994
|
+
const allCustom = server.wsSent.filter(m => m.type === 'custom')
|
|
995
|
+
expect(allCustom.length).toBe(1)
|
|
996
|
+
})
|
|
997
|
+
|
|
998
|
+
it('handleHotUpdate returns empty array for canvas files (suppresses full-reload)', () => {
|
|
999
|
+
const plugin = createPlugin()
|
|
1000
|
+
const result = plugin.handleHotUpdate({
|
|
1001
|
+
file: path.join(tmpDir, 'src', 'canvas', 'test.canvas.jsonl'),
|
|
1002
|
+
server: createMockServer(tmpDir),
|
|
1003
|
+
modules: [],
|
|
1004
|
+
})
|
|
1005
|
+
expect(result).toEqual([])
|
|
1006
|
+
})
|
|
1007
|
+
|
|
1008
|
+
it('handleHotUpdate does not send duplicate HMR events', () => {
|
|
1009
|
+
const plugin = createPlugin()
|
|
1010
|
+
const server = createMockServer(tmpDir)
|
|
1011
|
+
plugin.handleHotUpdate({
|
|
1012
|
+
file: path.join(tmpDir, 'src', 'canvas', 'test.canvas.jsonl'),
|
|
1013
|
+
server,
|
|
1014
|
+
modules: [],
|
|
1015
|
+
})
|
|
1016
|
+
// handleHotUpdate should NOT send events (invalidate() handles it)
|
|
1017
|
+
expect(server.wsSent.length).toBe(0)
|
|
1018
|
+
})
|
|
1019
|
+
|
|
1020
|
+
it('generated virtual module includes HMR listener for canvas updates', () => {
|
|
1021
|
+
writeCanvasFile(tmpDir, 'hmr-canvas', 'HMR Test')
|
|
1022
|
+
const plugin = createPlugin()
|
|
1023
|
+
const code = plugin.load(RESOLVED_ID)
|
|
1024
|
+
|
|
1025
|
+
expect(code).toContain('import.meta.hot')
|
|
1026
|
+
expect(code).toContain('storyboard:canvas-file-changed')
|
|
1027
|
+
expect(code).toContain('data.removed')
|
|
1028
|
+
expect(code).toContain('data.metadata')
|
|
1029
|
+
// Should merge into existing entries to preserve build-time fields
|
|
1030
|
+
expect(code).toContain('Object.assign')
|
|
1031
|
+
})
|
|
1032
|
+
|
|
1033
|
+
it('page refresh after canvas add yields updated module with new canvas', () => {
|
|
1034
|
+
const plugin = createPlugin()
|
|
1035
|
+
// First load — no canvases
|
|
1036
|
+
const code1 = plugin.load(RESOLVED_ID)
|
|
1037
|
+
expect(code1).not.toContain('"refresh-canvas"')
|
|
1038
|
+
|
|
1039
|
+
// Simulate adding a canvas and clearing buildResult (what softInvalidate does)
|
|
1040
|
+
writeCanvasFile(tmpDir, 'refresh-canvas', 'After Refresh')
|
|
1041
|
+
|
|
1042
|
+
// Manually clear buildResult by loading a fresh plugin instance with the same root
|
|
1043
|
+
const plugin2 = createPlugin()
|
|
1044
|
+
const code2 = plugin2.load(RESOLVED_ID)
|
|
1045
|
+
expect(code2).toContain('"refresh-canvas"')
|
|
1046
|
+
expect(code2).toContain('After Refresh')
|
|
1047
|
+
})
|
|
1048
|
+
})
|