@dfosco/storyboard-react 4.0.0-beta.3 → 4.0.0-beta.31
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +7 -4
- package/src/canvas/CanvasControls.jsx +51 -2
- package/src/canvas/CanvasControls.module.css +31 -0
- package/src/canvas/CanvasPage.bridge.test.jsx +95 -10
- package/src/canvas/CanvasPage.dragdrop.test.jsx +346 -0
- package/src/canvas/CanvasPage.jsx +790 -302
- package/src/canvas/CanvasPage.module.css +70 -47
- package/src/canvas/CanvasPage.multiselect.test.jsx +13 -11
- package/src/canvas/CanvasToolbar.jsx +2 -2
- package/src/canvas/ComponentErrorBoundary.jsx +50 -0
- package/src/canvas/PageSelector.jsx +102 -0
- package/src/canvas/PageSelector.module.css +93 -0
- package/src/canvas/PageSelector.test.jsx +104 -0
- package/src/canvas/canvasApi.js +22 -8
- package/src/canvas/canvasReloadGuard.js +37 -0
- package/src/canvas/canvasReloadGuard.test.js +27 -0
- package/src/canvas/componentIsolate.jsx +135 -0
- package/src/canvas/useCanvas.js +15 -10
- package/src/canvas/widgets/CodePenEmbed.jsx +292 -0
- package/src/canvas/widgets/CodePenEmbed.module.css +161 -0
- package/src/canvas/widgets/ComponentWidget.jsx +82 -9
- package/src/canvas/widgets/ComponentWidget.module.css +14 -6
- package/src/canvas/widgets/FigmaEmbed.jsx +110 -24
- package/src/canvas/widgets/FigmaEmbed.module.css +21 -7
- package/src/canvas/widgets/LinkPreview.jsx +247 -18
- package/src/canvas/widgets/LinkPreview.module.css +349 -8
- package/src/canvas/widgets/LinkPreview.test.jsx +71 -0
- package/src/canvas/widgets/MarkdownBlock.jsx +95 -21
- package/src/canvas/widgets/MarkdownBlock.module.css +133 -2
- package/src/canvas/widgets/MarkdownBlock.test.jsx +39 -0
- package/src/canvas/widgets/PrototypeEmbed.jsx +319 -70
- package/src/canvas/widgets/PrototypeEmbed.module.css +74 -4
- package/src/canvas/widgets/StickyNote.module.css +5 -0
- package/src/canvas/widgets/StickyNote.test.jsx +9 -9
- package/src/canvas/widgets/StoryWidget.jsx +512 -0
- package/src/canvas/widgets/StoryWidget.module.css +211 -0
- package/src/canvas/widgets/WidgetChrome.jsx +76 -20
- package/src/canvas/widgets/WidgetChrome.module.css +4 -7
- package/src/canvas/widgets/WidgetWrapper.module.css +2 -0
- package/src/canvas/widgets/codepenUrl.js +75 -0
- package/src/canvas/widgets/codepenUrl.test.js +76 -0
- package/src/canvas/widgets/embedInteraction.test.jsx +235 -0
- package/src/canvas/widgets/embedOverlay.module.css +35 -0
- package/src/canvas/widgets/embedTheme.js +56 -0
- package/src/canvas/widgets/githubUrl.js +82 -0
- package/src/canvas/widgets/githubUrl.test.js +74 -0
- package/src/canvas/widgets/iframeDevLogs.js +49 -0
- package/src/canvas/widgets/iframeDevLogs.test.jsx +81 -0
- package/src/canvas/widgets/index.js +4 -0
- package/src/canvas/widgets/pasteRules.js +295 -0
- package/src/canvas/widgets/pasteRules.test.js +474 -0
- package/src/canvas/widgets/refreshQueue.js +108 -0
- package/src/canvas/widgets/snapshotDisplay.test.jsx +259 -0
- package/src/canvas/widgets/useSnapshotCapture.js +157 -0
- package/src/canvas/widgets/useSnapshotCapture.test.jsx +164 -0
- package/src/canvas/widgets/widgetConfig.js +16 -5
- package/src/canvas/widgets/widgetConfig.test.js +34 -12
- package/src/context.jsx +141 -16
- package/src/hooks/useSceneData.js +4 -2
- package/src/story/StoryPage.jsx +117 -0
- package/src/story/StoryPage.module.css +18 -0
- package/src/vite/data-plugin.js +458 -71
- package/src/vite/data-plugin.test.js +405 -5
package/package.json
CHANGED
|
@@ -1,13 +1,16 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@dfosco/storyboard-react",
|
|
3
|
-
"version": "4.0.0-beta.
|
|
3
|
+
"version": "4.0.0-beta.31",
|
|
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.31",
|
|
7
|
+
"@dfosco/tiny-canvas": "4.0.0-beta.31",
|
|
8
8
|
"@neodrag/react": "^2.3.1",
|
|
9
9
|
"glob": "^11.0.0",
|
|
10
|
-
"jsonc-parser": "^3.3.1"
|
|
10
|
+
"jsonc-parser": "^3.3.1",
|
|
11
|
+
"remark": "^15.0.1",
|
|
12
|
+
"remark-gfm": "^4.0.1",
|
|
13
|
+
"remark-html": "^16.0.1"
|
|
11
14
|
},
|
|
12
15
|
"license": "MIT",
|
|
13
16
|
"repository": {
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { useState, useRef, useEffect, useCallback } from 'react'
|
|
2
2
|
import { getMenuWidgetTypes } from './widgets/widgetConfig.js'
|
|
3
|
+
import { listStories, getStoryData } from '@dfosco/storyboard-core'
|
|
3
4
|
import styles from './CanvasControls.module.css'
|
|
4
5
|
|
|
5
6
|
const WIDGET_TYPES = getMenuWidgetTypes()
|
|
@@ -9,6 +10,7 @@ const WIDGET_TYPES = getMenuWidgetTypes()
|
|
|
9
10
|
*/
|
|
10
11
|
export default function CanvasControls({ onAddWidget }) {
|
|
11
12
|
const [menuOpen, setMenuOpen] = useState(false)
|
|
13
|
+
const [storyPicker, setStoryPicker] = useState(false)
|
|
12
14
|
const menuRef = useRef(null)
|
|
13
15
|
|
|
14
16
|
// Close menu on outside click
|
|
@@ -17,6 +19,7 @@ export default function CanvasControls({ onAddWidget }) {
|
|
|
17
19
|
function handlePointerDown(e) {
|
|
18
20
|
if (menuRef.current && !menuRef.current.contains(e.target)) {
|
|
19
21
|
setMenuOpen(false)
|
|
22
|
+
setStoryPicker(false)
|
|
20
23
|
}
|
|
21
24
|
}
|
|
22
25
|
document.addEventListener('pointerdown', handlePointerDown)
|
|
@@ -26,14 +29,23 @@ export default function CanvasControls({ onAddWidget }) {
|
|
|
26
29
|
const handleAddWidget = useCallback((type) => {
|
|
27
30
|
onAddWidget(type)
|
|
28
31
|
setMenuOpen(false)
|
|
32
|
+
setStoryPicker(false)
|
|
29
33
|
}, [onAddWidget])
|
|
30
34
|
|
|
35
|
+
const handleAddStory = useCallback((storyId) => {
|
|
36
|
+
document.dispatchEvent(new CustomEvent('storyboard:canvas:add-story-widget', { detail: { storyId } }))
|
|
37
|
+
setMenuOpen(false)
|
|
38
|
+
setStoryPicker(false)
|
|
39
|
+
}, [])
|
|
40
|
+
|
|
41
|
+
const storyNames = storyPicker ? listStories() : []
|
|
42
|
+
|
|
31
43
|
return (
|
|
32
44
|
<div className={styles.toolbar} role="toolbar" aria-label="Canvas controls">
|
|
33
45
|
<div ref={menuRef} className={styles.createGroup}>
|
|
34
46
|
<button
|
|
35
47
|
className={styles.btn}
|
|
36
|
-
onClick={() => setMenuOpen((v) => !v)}
|
|
48
|
+
onClick={() => { setMenuOpen((v) => !v); setStoryPicker(false) }}
|
|
37
49
|
aria-label="Add widget"
|
|
38
50
|
aria-expanded={menuOpen}
|
|
39
51
|
title="Add widget"
|
|
@@ -42,7 +54,7 @@ export default function CanvasControls({ onAddWidget }) {
|
|
|
42
54
|
<path d="M7.75 2a.75.75 0 0 1 .75.75V7h4.25a.75.75 0 0 1 0 1.5H8.5v4.25a.75.75 0 0 1-1.5 0V8.5H2.75a.75.75 0 0 1 0-1.5H7V2.75A.75.75 0 0 1 7.75 2Z" />
|
|
43
55
|
</svg>
|
|
44
56
|
</button>
|
|
45
|
-
{menuOpen && (
|
|
57
|
+
{menuOpen && !storyPicker && (
|
|
46
58
|
<div className={styles.menu} role="menu">
|
|
47
59
|
<div className={styles.menuLabel}>Add to canvas</div>
|
|
48
60
|
{WIDGET_TYPES.map((wt) => (
|
|
@@ -55,6 +67,43 @@ export default function CanvasControls({ onAddWidget }) {
|
|
|
55
67
|
{wt.label}
|
|
56
68
|
</button>
|
|
57
69
|
))}
|
|
70
|
+
<div className={styles.menuDivider} />
|
|
71
|
+
<button
|
|
72
|
+
className={styles.menuItem}
|
|
73
|
+
role="menuitem"
|
|
74
|
+
onClick={() => setStoryPicker(true)}
|
|
75
|
+
>
|
|
76
|
+
📖 Component
|
|
77
|
+
</button>
|
|
78
|
+
</div>
|
|
79
|
+
)}
|
|
80
|
+
{menuOpen && storyPicker && (
|
|
81
|
+
<div className={styles.menu} role="menu">
|
|
82
|
+
<div className={styles.menuLabel}>
|
|
83
|
+
<button
|
|
84
|
+
className={styles.backBtn}
|
|
85
|
+
onClick={() => setStoryPicker(false)}
|
|
86
|
+
aria-label="Back"
|
|
87
|
+
>←</button>
|
|
88
|
+
Select component
|
|
89
|
+
</div>
|
|
90
|
+
{storyNames.length === 0 && (
|
|
91
|
+
<div className={styles.menuEmpty}>No stories found</div>
|
|
92
|
+
)}
|
|
93
|
+
{storyNames.map((name) => {
|
|
94
|
+
const story = getStoryData(name)
|
|
95
|
+
return (
|
|
96
|
+
<button
|
|
97
|
+
key={name}
|
|
98
|
+
className={styles.menuItem}
|
|
99
|
+
role="menuitem"
|
|
100
|
+
onClick={() => handleAddStory(name)}
|
|
101
|
+
>
|
|
102
|
+
{name}
|
|
103
|
+
{story?._route && <span className={styles.menuHint}>{story._route}</span>}
|
|
104
|
+
</button>
|
|
105
|
+
)
|
|
106
|
+
})}
|
|
58
107
|
</div>
|
|
59
108
|
)}
|
|
60
109
|
</div>
|
|
@@ -102,3 +102,34 @@
|
|
|
102
102
|
.menuItem:hover {
|
|
103
103
|
background: var(--bgColor-muted, #f6f8fa);
|
|
104
104
|
}
|
|
105
|
+
|
|
106
|
+
.menuDivider {
|
|
107
|
+
height: 1px;
|
|
108
|
+
margin: 4px 8px;
|
|
109
|
+
background: var(--borderColor-muted, rgba(0, 0, 0, 0.1));
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
.menuHint {
|
|
113
|
+
font-size: 11px;
|
|
114
|
+
color: var(--fgColor-muted, #656d76);
|
|
115
|
+
margin-left: 8px;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
.menuEmpty {
|
|
119
|
+
padding: 8px 10px;
|
|
120
|
+
font-size: 12px;
|
|
121
|
+
color: var(--fgColor-muted, #656d76);
|
|
122
|
+
font-style: italic;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
.backBtn {
|
|
126
|
+
all: unset;
|
|
127
|
+
cursor: pointer;
|
|
128
|
+
margin-right: 4px;
|
|
129
|
+
font-size: 13px;
|
|
130
|
+
color: var(--fgColor-muted, #656d76);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
.backBtn:hover {
|
|
134
|
+
color: var(--fgColor-default, #1f2328);
|
|
135
|
+
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
|
-
import { fireEvent, render, screen, act } from '@testing-library/react'
|
|
1
|
+
import { fireEvent, render, screen, act, waitFor } from '@testing-library/react'
|
|
2
2
|
import CanvasPage from './CanvasPage.jsx'
|
|
3
3
|
import { getCanvasPrimerAttrs, getCanvasThemeVars } from './canvasTheme.js'
|
|
4
|
-
import { updateCanvas } from './canvasApi.js'
|
|
4
|
+
import { addWidget, checkGitHubCliAvailable, fetchGitHubEmbed, updateCanvas } from './canvasApi.js'
|
|
5
5
|
|
|
6
6
|
vi.mock('@dfosco/tiny-canvas', () => ({
|
|
7
7
|
Canvas: ({ children, onDragEnd }) => (
|
|
@@ -77,6 +77,8 @@ vi.mock('./widgets/figmaUrl.js', () => ({
|
|
|
77
77
|
|
|
78
78
|
vi.mock('./canvasApi.js', () => ({
|
|
79
79
|
addWidget: vi.fn(),
|
|
80
|
+
checkGitHubCliAvailable: vi.fn(),
|
|
81
|
+
fetchGitHubEmbed: vi.fn(),
|
|
80
82
|
updateCanvas: vi.fn(() => Promise.resolve({ success: true })),
|
|
81
83
|
removeWidget: vi.fn(),
|
|
82
84
|
uploadImage: vi.fn(),
|
|
@@ -94,9 +96,26 @@ vi.mock('./useUndoRedo.js', () => ({
|
|
|
94
96
|
}))
|
|
95
97
|
|
|
96
98
|
describe('CanvasPage canvas bridge', () => {
|
|
99
|
+
function dispatchTextPaste(text) {
|
|
100
|
+
const event = new Event('paste', { bubbles: true, cancelable: true })
|
|
101
|
+
Object.defineProperty(event, 'clipboardData', {
|
|
102
|
+
value: {
|
|
103
|
+
getData: (type) => (type === 'text/plain' ? text : ''),
|
|
104
|
+
items: [],
|
|
105
|
+
},
|
|
106
|
+
})
|
|
107
|
+
document.dispatchEvent(event)
|
|
108
|
+
}
|
|
109
|
+
|
|
97
110
|
beforeEach(() => {
|
|
98
111
|
delete window.__storyboardCanvasBridgeState
|
|
99
112
|
vi.clearAllMocks()
|
|
113
|
+
addWidget.mockResolvedValue({
|
|
114
|
+
success: true,
|
|
115
|
+
widget: { id: 'widget-link', type: 'link-preview', position: { x: 0, y: 0 }, props: {} },
|
|
116
|
+
})
|
|
117
|
+
checkGitHubCliAvailable.mockResolvedValue({ available: true })
|
|
118
|
+
fetchGitHubEmbed.mockResolvedValue({ success: false })
|
|
100
119
|
})
|
|
101
120
|
|
|
102
121
|
it('publishes bridge state and responds to status requests', () => {
|
|
@@ -105,11 +124,11 @@ describe('CanvasPage canvas bridge', () => {
|
|
|
105
124
|
document.addEventListener('storyboard:canvas:mounted', mountedHandler)
|
|
106
125
|
document.addEventListener('storyboard:canvas:status', statusHandler)
|
|
107
126
|
|
|
108
|
-
const { unmount } = render(<CanvasPage
|
|
127
|
+
const { unmount } = render(<CanvasPage canvasId="design-overview" />)
|
|
109
128
|
|
|
110
129
|
expect(window.__storyboardCanvasBridgeState).toEqual({
|
|
111
130
|
active: true,
|
|
112
|
-
|
|
131
|
+
canvasId: 'design-overview',
|
|
113
132
|
zoom: 100,
|
|
114
133
|
})
|
|
115
134
|
expect(mountedHandler).toHaveBeenCalled()
|
|
@@ -118,7 +137,7 @@ describe('CanvasPage canvas bridge', () => {
|
|
|
118
137
|
expect(statusHandler).toHaveBeenCalled()
|
|
119
138
|
expect(statusHandler.mock.calls.at(-1)?.[0]?.detail).toEqual({
|
|
120
139
|
active: true,
|
|
121
|
-
|
|
140
|
+
canvasId: 'design-overview',
|
|
122
141
|
zoom: 100,
|
|
123
142
|
})
|
|
124
143
|
|
|
@@ -132,22 +151,88 @@ describe('CanvasPage canvas bridge', () => {
|
|
|
132
151
|
const unmountedHandler = vi.fn()
|
|
133
152
|
document.addEventListener('storyboard:canvas:unmounted', unmountedHandler)
|
|
134
153
|
|
|
135
|
-
const { unmount } = render(<CanvasPage
|
|
154
|
+
const { unmount } = render(<CanvasPage canvasId="design-overview" />)
|
|
136
155
|
unmount()
|
|
137
156
|
|
|
138
157
|
expect(unmountedHandler).toHaveBeenCalled()
|
|
139
158
|
expect(window.__storyboardCanvasBridgeState).toEqual({
|
|
140
159
|
active: false,
|
|
141
|
-
|
|
160
|
+
canvasId: '',
|
|
142
161
|
zoom: 100,
|
|
143
162
|
})
|
|
144
163
|
|
|
145
164
|
document.removeEventListener('storyboard:canvas:unmounted', unmountedHandler)
|
|
146
165
|
})
|
|
147
166
|
|
|
148
|
-
it
|
|
167
|
+
it('shows gh install banner when gh is unavailable during GitHub URL paste', async () => {
|
|
168
|
+
checkGitHubCliAvailable.mockResolvedValue({
|
|
169
|
+
available: false,
|
|
170
|
+
installUrl: 'https://github.com/cli/cli',
|
|
171
|
+
})
|
|
172
|
+
|
|
149
173
|
render(<CanvasPage name="design-overview" />)
|
|
150
174
|
|
|
175
|
+
await act(async () => {
|
|
176
|
+
dispatchTextPaste('https://github.com/dfosco/storyboard/issues/42')
|
|
177
|
+
await Promise.resolve()
|
|
178
|
+
})
|
|
179
|
+
|
|
180
|
+
await waitFor(() => {
|
|
181
|
+
expect(addWidget).toHaveBeenCalled()
|
|
182
|
+
})
|
|
183
|
+
expect(fetchGitHubEmbed).not.toHaveBeenCalled()
|
|
184
|
+
expect(screen.getByRole('link', { name: 'Install GitHub CLI' })).toHaveAttribute(
|
|
185
|
+
'href',
|
|
186
|
+
'https://github.com/cli/cli',
|
|
187
|
+
)
|
|
188
|
+
})
|
|
189
|
+
|
|
190
|
+
it('hydrates GitHub metadata when gh is available during paste', async () => {
|
|
191
|
+
checkGitHubCliAvailable.mockResolvedValue({ available: true })
|
|
192
|
+
fetchGitHubEmbed.mockResolvedValue({
|
|
193
|
+
success: true,
|
|
194
|
+
snapshot: {
|
|
195
|
+
kind: 'issue',
|
|
196
|
+
parentKind: 'issue',
|
|
197
|
+
context: 'GitHub · dfosco/storyboard · Issue #42',
|
|
198
|
+
title: '#42 Ship GitHub embeds',
|
|
199
|
+
body: 'Details from GitHub',
|
|
200
|
+
authors: ['dfosco'],
|
|
201
|
+
createdAt: '2026-01-01T00:00:00Z',
|
|
202
|
+
updatedAt: '2026-01-02T00:00:00Z',
|
|
203
|
+
},
|
|
204
|
+
})
|
|
205
|
+
|
|
206
|
+
render(<CanvasPage name="design-overview" />)
|
|
207
|
+
|
|
208
|
+
await act(async () => {
|
|
209
|
+
dispatchTextPaste('https://github.com/dfosco/storyboard/issues/42')
|
|
210
|
+
await Promise.resolve()
|
|
211
|
+
})
|
|
212
|
+
|
|
213
|
+
await waitFor(() => {
|
|
214
|
+
expect(fetchGitHubEmbed).toHaveBeenCalledWith('https://github.com/dfosco/storyboard/issues/42')
|
|
215
|
+
})
|
|
216
|
+
expect(addWidget).toHaveBeenCalledWith(
|
|
217
|
+
'design-overview',
|
|
218
|
+
expect.objectContaining({
|
|
219
|
+
type: 'link-preview',
|
|
220
|
+
props: expect.objectContaining({
|
|
221
|
+
title: '#42 Ship GitHub embeds',
|
|
222
|
+
width: 580,
|
|
223
|
+
height: 400,
|
|
224
|
+
github: expect.objectContaining({
|
|
225
|
+
context: 'GitHub · dfosco/storyboard · Issue #42',
|
|
226
|
+
body: 'Details from GitHub',
|
|
227
|
+
}),
|
|
228
|
+
}),
|
|
229
|
+
}),
|
|
230
|
+
)
|
|
231
|
+
})
|
|
232
|
+
|
|
233
|
+
it.skip('persists dragged JSON widgets and JSX sources to canvas JSONL via update API', async () => {
|
|
234
|
+
render(<CanvasPage canvasId="design-overview" />)
|
|
235
|
+
|
|
151
236
|
fireEvent.click(screen.getByTestId('drag-widget'))
|
|
152
237
|
// Flush the promise-based write queue
|
|
153
238
|
await act(async () => { await new Promise((r) => setTimeout(r, 0)) })
|
|
@@ -179,7 +264,7 @@ describe('CanvasPage canvas bridge', () => {
|
|
|
179
264
|
})
|
|
180
265
|
|
|
181
266
|
it.skip('clamps negative drag positions to zero', async () => {
|
|
182
|
-
render(<CanvasPage
|
|
267
|
+
render(<CanvasPage canvasId="design-overview" />)
|
|
183
268
|
|
|
184
269
|
fireEvent.click(screen.getByTestId('drag-widget-negative'))
|
|
185
270
|
await act(async () => { await new Promise((r) => setTimeout(r, 0)) })
|
|
@@ -243,7 +328,7 @@ describe('canvas target fallback', () => {
|
|
|
243
328
|
localStorage.setItem('sb-color-scheme', 'dark')
|
|
244
329
|
document.documentElement.setAttribute('data-sb-canvas-theme', 'dark')
|
|
245
330
|
|
|
246
|
-
render(<CanvasPage
|
|
331
|
+
render(<CanvasPage canvasId="design-overview" />)
|
|
247
332
|
|
|
248
333
|
const scroll = document.querySelector('[data-storyboard-canvas-scroll]')
|
|
249
334
|
const jsxWidget = document.getElementById('jsx-PrimaryButtons')
|
|
@@ -0,0 +1,346 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for canvas image drag-and-drop functionality.
|
|
3
|
+
*
|
|
4
|
+
* Tests the event handling for dropping images from Finder/file manager
|
|
5
|
+
* onto the canvas, including coordinate conversion and file filtering.
|
|
6
|
+
*/
|
|
7
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
|
8
|
+
import { fireEvent, render, act, waitFor } from '@testing-library/react'
|
|
9
|
+
import CanvasPage from './CanvasPage.jsx'
|
|
10
|
+
import { addWidget, uploadImage } from './canvasApi.js'
|
|
11
|
+
|
|
12
|
+
// Mock dependencies
|
|
13
|
+
vi.mock('@dfosco/tiny-canvas', () => ({
|
|
14
|
+
Canvas: ({ children }) => <div data-testid="tiny-canvas">{children}</div>,
|
|
15
|
+
}))
|
|
16
|
+
|
|
17
|
+
const mockCanvas = {
|
|
18
|
+
title: 'Drag Drop Test Canvas',
|
|
19
|
+
widgets: [],
|
|
20
|
+
sources: [],
|
|
21
|
+
centered: false,
|
|
22
|
+
dotted: false,
|
|
23
|
+
grid: true,
|
|
24
|
+
gridSize: 24,
|
|
25
|
+
colorMode: 'auto',
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
vi.mock('./useCanvas.js', () => ({
|
|
29
|
+
useCanvas: () => ({
|
|
30
|
+
canvas: mockCanvas,
|
|
31
|
+
jsxExports: {},
|
|
32
|
+
loading: false,
|
|
33
|
+
}),
|
|
34
|
+
}))
|
|
35
|
+
|
|
36
|
+
vi.mock('./widgets/index.js', () => ({
|
|
37
|
+
getWidgetComponent: () => function MockWidget() {
|
|
38
|
+
return <div>mock widget</div>
|
|
39
|
+
},
|
|
40
|
+
}))
|
|
41
|
+
|
|
42
|
+
vi.mock('./widgets/WidgetChrome.jsx', () => ({
|
|
43
|
+
default: ({ children }) => <div data-testid="widget-chrome">{children}</div>,
|
|
44
|
+
}))
|
|
45
|
+
|
|
46
|
+
vi.mock('./widgets/widgetProps.js', () => ({
|
|
47
|
+
schemas: {},
|
|
48
|
+
getDefaults: () => ({}),
|
|
49
|
+
}))
|
|
50
|
+
|
|
51
|
+
vi.mock('./widgets/widgetConfig.js', () => ({
|
|
52
|
+
getFeatures: () => [],
|
|
53
|
+
isResizable: () => false,
|
|
54
|
+
schemas: {},
|
|
55
|
+
getMenuWidgetTypes: () => [],
|
|
56
|
+
}))
|
|
57
|
+
|
|
58
|
+
vi.mock('./widgets/figmaUrl.js', () => ({
|
|
59
|
+
isFigmaUrl: () => false,
|
|
60
|
+
sanitizeFigmaUrl: (url) => url,
|
|
61
|
+
}))
|
|
62
|
+
|
|
63
|
+
vi.mock('./canvasApi.js', () => ({
|
|
64
|
+
addWidget: vi.fn(() => Promise.resolve({ success: true, widget: { id: 'image-abc', type: 'image' } })),
|
|
65
|
+
updateCanvas: vi.fn(() => Promise.resolve({ success: true })),
|
|
66
|
+
removeWidget: vi.fn(),
|
|
67
|
+
uploadImage: vi.fn(() => Promise.resolve({ success: true, filename: 'test-image.png' })),
|
|
68
|
+
}))
|
|
69
|
+
|
|
70
|
+
vi.mock('./useUndoRedo.js', () => ({
|
|
71
|
+
default: () => ({
|
|
72
|
+
snapshot: vi.fn(),
|
|
73
|
+
undo: vi.fn(),
|
|
74
|
+
redo: vi.fn(),
|
|
75
|
+
reset: vi.fn(),
|
|
76
|
+
canUndo: false,
|
|
77
|
+
canRedo: false,
|
|
78
|
+
}),
|
|
79
|
+
}))
|
|
80
|
+
|
|
81
|
+
// Helper to create a mock File
|
|
82
|
+
function createMockImageFile(name = 'test.png', type = 'image/png') {
|
|
83
|
+
const blob = new Blob(['fake image data'], { type })
|
|
84
|
+
return new File([blob], name, { type })
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// Helper to create a DataTransfer-like object
|
|
88
|
+
function createDataTransfer(files, types = ['Files']) {
|
|
89
|
+
return {
|
|
90
|
+
files,
|
|
91
|
+
types,
|
|
92
|
+
dropEffect: 'none',
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
describe('CanvasPage image drag-and-drop', () => {
|
|
97
|
+
beforeEach(() => {
|
|
98
|
+
vi.clearAllMocks()
|
|
99
|
+
// Mock FileReader for blobToDataUrl
|
|
100
|
+
global.FileReader = class {
|
|
101
|
+
readAsDataURL() {
|
|
102
|
+
setTimeout(() => {
|
|
103
|
+
this.result = 'data:image/png;base64,fakedata'
|
|
104
|
+
this.onload?.()
|
|
105
|
+
}, 0)
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
// Mock Image for getImageDimensions
|
|
109
|
+
global.Image = class {
|
|
110
|
+
set src(val) {
|
|
111
|
+
setTimeout(() => {
|
|
112
|
+
this.naturalWidth = 800
|
|
113
|
+
this.naturalHeight = 600
|
|
114
|
+
this.onload?.()
|
|
115
|
+
}, 0)
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
})
|
|
119
|
+
|
|
120
|
+
afterEach(() => {
|
|
121
|
+
delete global.FileReader
|
|
122
|
+
delete global.Image
|
|
123
|
+
})
|
|
124
|
+
|
|
125
|
+
it('allows drop by preventing default on dragover with Files', () => {
|
|
126
|
+
render(<CanvasPage canvasId="test-canvas" />)
|
|
127
|
+
const scrollContainer = document.querySelector('[data-storyboard-canvas-scroll]')
|
|
128
|
+
|
|
129
|
+
const dragOverEvent = new Event('dragover', { bubbles: true, cancelable: true })
|
|
130
|
+
dragOverEvent.dataTransfer = createDataTransfer([], ['Files'])
|
|
131
|
+
dragOverEvent.preventDefault = vi.fn()
|
|
132
|
+
|
|
133
|
+
scrollContainer.dispatchEvent(dragOverEvent)
|
|
134
|
+
|
|
135
|
+
expect(dragOverEvent.preventDefault).toHaveBeenCalled()
|
|
136
|
+
expect(dragOverEvent.dataTransfer.dropEffect).toBe('copy')
|
|
137
|
+
})
|
|
138
|
+
|
|
139
|
+
it('ignores dragover without Files type (internal widget drag)', () => {
|
|
140
|
+
render(<CanvasPage canvasId="test-canvas" />)
|
|
141
|
+
const scrollContainer = document.querySelector('[data-storyboard-canvas-scroll]')
|
|
142
|
+
|
|
143
|
+
const dragOverEvent = new Event('dragover', { bubbles: true, cancelable: true })
|
|
144
|
+
dragOverEvent.dataTransfer = createDataTransfer([], ['text/plain'])
|
|
145
|
+
dragOverEvent.preventDefault = vi.fn()
|
|
146
|
+
|
|
147
|
+
scrollContainer.dispatchEvent(dragOverEvent)
|
|
148
|
+
|
|
149
|
+
expect(dragOverEvent.preventDefault).not.toHaveBeenCalled()
|
|
150
|
+
})
|
|
151
|
+
|
|
152
|
+
it('uploads image and creates widget on drop', async () => {
|
|
153
|
+
render(<CanvasPage canvasId="test-canvas" />)
|
|
154
|
+
const scrollContainer = document.querySelector('[data-storyboard-canvas-scroll]')
|
|
155
|
+
|
|
156
|
+
const imageFile = createMockImageFile('photo.png', 'image/png')
|
|
157
|
+
const dropEvent = new Event('drop', { bubbles: true, cancelable: true })
|
|
158
|
+
dropEvent.dataTransfer = createDataTransfer([imageFile], ['Files'])
|
|
159
|
+
dropEvent.clientX = 200
|
|
160
|
+
dropEvent.clientY = 150
|
|
161
|
+
dropEvent.preventDefault = vi.fn()
|
|
162
|
+
dropEvent.stopPropagation = vi.fn()
|
|
163
|
+
|
|
164
|
+
// Mock getBoundingClientRect for coordinate calculation
|
|
165
|
+
scrollContainer.getBoundingClientRect = () => ({
|
|
166
|
+
left: 0,
|
|
167
|
+
top: 0,
|
|
168
|
+
width: 1000,
|
|
169
|
+
height: 800,
|
|
170
|
+
})
|
|
171
|
+
scrollContainer.scrollLeft = 0
|
|
172
|
+
scrollContainer.scrollTop = 0
|
|
173
|
+
|
|
174
|
+
await act(async () => {
|
|
175
|
+
scrollContainer.dispatchEvent(dropEvent)
|
|
176
|
+
// Wait for async processing
|
|
177
|
+
await new Promise((r) => setTimeout(r, 50))
|
|
178
|
+
})
|
|
179
|
+
|
|
180
|
+
expect(dropEvent.preventDefault).toHaveBeenCalled()
|
|
181
|
+
expect(uploadImage).toHaveBeenCalledWith(
|
|
182
|
+
expect.stringContaining('data:image/png'),
|
|
183
|
+
'test-canvas'
|
|
184
|
+
)
|
|
185
|
+
expect(addWidget).toHaveBeenCalledWith(
|
|
186
|
+
'test-canvas',
|
|
187
|
+
expect.objectContaining({
|
|
188
|
+
type: 'image',
|
|
189
|
+
props: expect.objectContaining({
|
|
190
|
+
src: 'test-image.png',
|
|
191
|
+
private: false,
|
|
192
|
+
}),
|
|
193
|
+
position: expect.objectContaining({
|
|
194
|
+
x: expect.any(Number),
|
|
195
|
+
y: expect.any(Number),
|
|
196
|
+
}),
|
|
197
|
+
})
|
|
198
|
+
)
|
|
199
|
+
})
|
|
200
|
+
|
|
201
|
+
it('ignores non-image files but prevents browser default', async () => {
|
|
202
|
+
render(<CanvasPage canvasId="test-canvas" />)
|
|
203
|
+
const scrollContainer = document.querySelector('[data-storyboard-canvas-scroll]')
|
|
204
|
+
|
|
205
|
+
const textFile = new File(['text content'], 'readme.txt', { type: 'text/plain' })
|
|
206
|
+
const dropEvent = new Event('drop', { bubbles: true, cancelable: true })
|
|
207
|
+
dropEvent.dataTransfer = createDataTransfer([textFile], ['Files'])
|
|
208
|
+
dropEvent.clientX = 200
|
|
209
|
+
dropEvent.clientY = 150
|
|
210
|
+
dropEvent.preventDefault = vi.fn()
|
|
211
|
+
dropEvent.stopPropagation = vi.fn()
|
|
212
|
+
|
|
213
|
+
scrollContainer.getBoundingClientRect = () => ({ left: 0, top: 0 })
|
|
214
|
+
|
|
215
|
+
await act(async () => {
|
|
216
|
+
scrollContainer.dispatchEvent(dropEvent)
|
|
217
|
+
await new Promise((r) => setTimeout(r, 50))
|
|
218
|
+
})
|
|
219
|
+
|
|
220
|
+
// Should prevent default (stops browser from opening the file)
|
|
221
|
+
expect(dropEvent.preventDefault).toHaveBeenCalled()
|
|
222
|
+
// But should not call upload or add widget for non-image files
|
|
223
|
+
expect(uploadImage).not.toHaveBeenCalled()
|
|
224
|
+
expect(addWidget).not.toHaveBeenCalled()
|
|
225
|
+
})
|
|
226
|
+
|
|
227
|
+
it('processes multiple image files on drop', async () => {
|
|
228
|
+
render(<CanvasPage canvasId="test-canvas" />)
|
|
229
|
+
const scrollContainer = document.querySelector('[data-storyboard-canvas-scroll]')
|
|
230
|
+
|
|
231
|
+
const image1 = createMockImageFile('photo1.png', 'image/png')
|
|
232
|
+
const image2 = createMockImageFile('photo2.jpg', 'image/jpeg')
|
|
233
|
+
const dropEvent = new Event('drop', { bubbles: true, cancelable: true })
|
|
234
|
+
dropEvent.dataTransfer = createDataTransfer([image1, image2], ['Files'])
|
|
235
|
+
dropEvent.clientX = 100
|
|
236
|
+
dropEvent.clientY = 100
|
|
237
|
+
dropEvent.preventDefault = vi.fn()
|
|
238
|
+
dropEvent.stopPropagation = vi.fn()
|
|
239
|
+
|
|
240
|
+
scrollContainer.getBoundingClientRect = () => ({ left: 0, top: 0, width: 1000, height: 800 })
|
|
241
|
+
scrollContainer.scrollLeft = 0
|
|
242
|
+
scrollContainer.scrollTop = 0
|
|
243
|
+
|
|
244
|
+
await act(async () => {
|
|
245
|
+
scrollContainer.dispatchEvent(dropEvent)
|
|
246
|
+
// Wait longer for multiple async operations
|
|
247
|
+
await new Promise((r) => setTimeout(r, 150))
|
|
248
|
+
})
|
|
249
|
+
|
|
250
|
+
// Should call upload for each image
|
|
251
|
+
expect(uploadImage).toHaveBeenCalledTimes(2)
|
|
252
|
+
expect(addWidget).toHaveBeenCalledTimes(2)
|
|
253
|
+
})
|
|
254
|
+
|
|
255
|
+
it('ignores drop without Files type', async () => {
|
|
256
|
+
render(<CanvasPage canvasId="test-canvas" />)
|
|
257
|
+
const scrollContainer = document.querySelector('[data-storyboard-canvas-scroll]')
|
|
258
|
+
|
|
259
|
+
const dropEvent = new Event('drop', { bubbles: true, cancelable: true })
|
|
260
|
+
dropEvent.dataTransfer = createDataTransfer([], ['text/html'])
|
|
261
|
+
dropEvent.preventDefault = vi.fn()
|
|
262
|
+
|
|
263
|
+
await act(async () => {
|
|
264
|
+
scrollContainer.dispatchEvent(dropEvent)
|
|
265
|
+
})
|
|
266
|
+
|
|
267
|
+
expect(dropEvent.preventDefault).not.toHaveBeenCalled()
|
|
268
|
+
expect(uploadImage).not.toHaveBeenCalled()
|
|
269
|
+
})
|
|
270
|
+
|
|
271
|
+
it('snaps drop position to grid when snap is enabled', async () => {
|
|
272
|
+
// Enable snap in mock data
|
|
273
|
+
const originalSnapToGrid = mockCanvas.snapToGrid
|
|
274
|
+
mockCanvas.snapToGrid = true
|
|
275
|
+
|
|
276
|
+
render(<CanvasPage canvasId="test-canvas" />)
|
|
277
|
+
const scrollContainer = document.querySelector('[data-storyboard-canvas-scroll]')
|
|
278
|
+
|
|
279
|
+
const imageFile = createMockImageFile()
|
|
280
|
+
const dropEvent = new Event('drop', { bubbles: true, cancelable: true })
|
|
281
|
+
dropEvent.dataTransfer = createDataTransfer([imageFile], ['Files'])
|
|
282
|
+
// Position that should snap: 137 → 144 (gridSize 24: round(137/24)*24 = 144)
|
|
283
|
+
dropEvent.clientX = 137
|
|
284
|
+
dropEvent.clientY = 85 // 85 → 96
|
|
285
|
+
|
|
286
|
+
dropEvent.preventDefault = vi.fn()
|
|
287
|
+
dropEvent.stopPropagation = vi.fn()
|
|
288
|
+
|
|
289
|
+
scrollContainer.getBoundingClientRect = () => ({ left: 0, top: 0, width: 1000, height: 800 })
|
|
290
|
+
scrollContainer.scrollLeft = 0
|
|
291
|
+
scrollContainer.scrollTop = 0
|
|
292
|
+
|
|
293
|
+
await act(async () => {
|
|
294
|
+
scrollContainer.dispatchEvent(dropEvent)
|
|
295
|
+
await new Promise((r) => setTimeout(r, 50))
|
|
296
|
+
})
|
|
297
|
+
|
|
298
|
+
expect(addWidget).toHaveBeenCalledWith(
|
|
299
|
+
'test-canvas',
|
|
300
|
+
expect.objectContaining({
|
|
301
|
+
position: { x: 144, y: 96 },
|
|
302
|
+
})
|
|
303
|
+
)
|
|
304
|
+
|
|
305
|
+
// Restore
|
|
306
|
+
mockCanvas.snapToGrid = originalSnapToGrid
|
|
307
|
+
})
|
|
308
|
+
|
|
309
|
+
it('does not snap drop position when snap is disabled', async () => {
|
|
310
|
+
// Ensure snap is disabled (default from mock)
|
|
311
|
+
const originalSnapToGrid = mockCanvas.snapToGrid
|
|
312
|
+
mockCanvas.snapToGrid = false
|
|
313
|
+
|
|
314
|
+
render(<CanvasPage canvasId="test-canvas" />)
|
|
315
|
+
const scrollContainer = document.querySelector('[data-storyboard-canvas-scroll]')
|
|
316
|
+
|
|
317
|
+
const imageFile = createMockImageFile()
|
|
318
|
+
const dropEvent = new Event('drop', { bubbles: true, cancelable: true })
|
|
319
|
+
dropEvent.dataTransfer = createDataTransfer([imageFile], ['Files'])
|
|
320
|
+
// Position should round to nearest integer, not snap to grid
|
|
321
|
+
dropEvent.clientX = 137
|
|
322
|
+
dropEvent.clientY = 85
|
|
323
|
+
|
|
324
|
+
dropEvent.preventDefault = vi.fn()
|
|
325
|
+
dropEvent.stopPropagation = vi.fn()
|
|
326
|
+
|
|
327
|
+
scrollContainer.getBoundingClientRect = () => ({ left: 0, top: 0, width: 1000, height: 800 })
|
|
328
|
+
scrollContainer.scrollLeft = 0
|
|
329
|
+
scrollContainer.scrollTop = 0
|
|
330
|
+
|
|
331
|
+
await act(async () => {
|
|
332
|
+
scrollContainer.dispatchEvent(dropEvent)
|
|
333
|
+
await new Promise((r) => setTimeout(r, 50))
|
|
334
|
+
})
|
|
335
|
+
|
|
336
|
+
expect(addWidget).toHaveBeenCalledWith(
|
|
337
|
+
'test-canvas',
|
|
338
|
+
expect.objectContaining({
|
|
339
|
+
position: { x: 137, y: 85 }, // No snapping, just rounded integers
|
|
340
|
+
})
|
|
341
|
+
)
|
|
342
|
+
|
|
343
|
+
// Restore
|
|
344
|
+
mockCanvas.snapToGrid = originalSnapToGrid
|
|
345
|
+
})
|
|
346
|
+
})
|