@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.
Files changed (63) hide show
  1. package/package.json +7 -4
  2. package/src/canvas/CanvasControls.jsx +51 -2
  3. package/src/canvas/CanvasControls.module.css +31 -0
  4. package/src/canvas/CanvasPage.bridge.test.jsx +95 -10
  5. package/src/canvas/CanvasPage.dragdrop.test.jsx +346 -0
  6. package/src/canvas/CanvasPage.jsx +790 -302
  7. package/src/canvas/CanvasPage.module.css +70 -47
  8. package/src/canvas/CanvasPage.multiselect.test.jsx +13 -11
  9. package/src/canvas/CanvasToolbar.jsx +2 -2
  10. package/src/canvas/ComponentErrorBoundary.jsx +50 -0
  11. package/src/canvas/PageSelector.jsx +102 -0
  12. package/src/canvas/PageSelector.module.css +93 -0
  13. package/src/canvas/PageSelector.test.jsx +104 -0
  14. package/src/canvas/canvasApi.js +22 -8
  15. package/src/canvas/canvasReloadGuard.js +37 -0
  16. package/src/canvas/canvasReloadGuard.test.js +27 -0
  17. package/src/canvas/componentIsolate.jsx +135 -0
  18. package/src/canvas/useCanvas.js +15 -10
  19. package/src/canvas/widgets/CodePenEmbed.jsx +292 -0
  20. package/src/canvas/widgets/CodePenEmbed.module.css +161 -0
  21. package/src/canvas/widgets/ComponentWidget.jsx +82 -9
  22. package/src/canvas/widgets/ComponentWidget.module.css +14 -6
  23. package/src/canvas/widgets/FigmaEmbed.jsx +110 -24
  24. package/src/canvas/widgets/FigmaEmbed.module.css +21 -7
  25. package/src/canvas/widgets/LinkPreview.jsx +247 -18
  26. package/src/canvas/widgets/LinkPreview.module.css +349 -8
  27. package/src/canvas/widgets/LinkPreview.test.jsx +71 -0
  28. package/src/canvas/widgets/MarkdownBlock.jsx +95 -21
  29. package/src/canvas/widgets/MarkdownBlock.module.css +133 -2
  30. package/src/canvas/widgets/MarkdownBlock.test.jsx +39 -0
  31. package/src/canvas/widgets/PrototypeEmbed.jsx +319 -70
  32. package/src/canvas/widgets/PrototypeEmbed.module.css +74 -4
  33. package/src/canvas/widgets/StickyNote.module.css +5 -0
  34. package/src/canvas/widgets/StickyNote.test.jsx +9 -9
  35. package/src/canvas/widgets/StoryWidget.jsx +512 -0
  36. package/src/canvas/widgets/StoryWidget.module.css +211 -0
  37. package/src/canvas/widgets/WidgetChrome.jsx +76 -20
  38. package/src/canvas/widgets/WidgetChrome.module.css +4 -7
  39. package/src/canvas/widgets/WidgetWrapper.module.css +2 -0
  40. package/src/canvas/widgets/codepenUrl.js +75 -0
  41. package/src/canvas/widgets/codepenUrl.test.js +76 -0
  42. package/src/canvas/widgets/embedInteraction.test.jsx +235 -0
  43. package/src/canvas/widgets/embedOverlay.module.css +35 -0
  44. package/src/canvas/widgets/embedTheme.js +56 -0
  45. package/src/canvas/widgets/githubUrl.js +82 -0
  46. package/src/canvas/widgets/githubUrl.test.js +74 -0
  47. package/src/canvas/widgets/iframeDevLogs.js +49 -0
  48. package/src/canvas/widgets/iframeDevLogs.test.jsx +81 -0
  49. package/src/canvas/widgets/index.js +4 -0
  50. package/src/canvas/widgets/pasteRules.js +295 -0
  51. package/src/canvas/widgets/pasteRules.test.js +474 -0
  52. package/src/canvas/widgets/refreshQueue.js +108 -0
  53. package/src/canvas/widgets/snapshotDisplay.test.jsx +259 -0
  54. package/src/canvas/widgets/useSnapshotCapture.js +157 -0
  55. package/src/canvas/widgets/useSnapshotCapture.test.jsx +164 -0
  56. package/src/canvas/widgets/widgetConfig.js +16 -5
  57. package/src/canvas/widgets/widgetConfig.test.js +34 -12
  58. package/src/context.jsx +141 -16
  59. package/src/hooks/useSceneData.js +4 -2
  60. package/src/story/StoryPage.jsx +117 -0
  61. package/src/story/StoryPage.module.css +18 -0
  62. package/src/vite/data-plugin.js +458 -71
  63. 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",
3
+ "version": "4.0.0-beta.31",
4
4
  "type": "module",
5
5
  "dependencies": {
6
- "@dfosco/storyboard-core": "4.0.0-beta.3",
7
- "@dfosco/tiny-canvas": "4.0.0-beta.3",
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 name="design-overview" />)
127
+ const { unmount } = render(<CanvasPage canvasId="design-overview" />)
109
128
 
110
129
  expect(window.__storyboardCanvasBridgeState).toEqual({
111
130
  active: true,
112
- name: 'design-overview',
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
- name: 'design-overview',
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 name="design-overview" />)
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
- name: '',
160
+ canvasId: '',
142
161
  zoom: 100,
143
162
  })
144
163
 
145
164
  document.removeEventListener('storyboard:canvas:unmounted', unmountedHandler)
146
165
  })
147
166
 
148
- it.skip('persists dragged JSON widgets and JSX sources to canvas JSONL via update API', async () => {
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 name="design-overview" />)
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 name="design-overview" />)
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
+ })