@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
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.21",
|
|
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.21",
|
|
7
|
+
"@dfosco/tiny-canvas": "4.0.0-beta.21",
|
|
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
|
+
}
|
|
@@ -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 name="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 name="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 name="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 name="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 name="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 name="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 name="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 name="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
|
+
})
|