@dfosco/storyboard-react 4.0.0-beta.9 → 4.0.0

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 (75) hide show
  1. package/package.json +6 -3
  2. package/src/AuthModal/AuthModal.jsx +134 -0
  3. package/src/AuthModal/AuthModal.module.css +221 -0
  4. package/src/BranchBar/BranchBar.jsx +56 -0
  5. package/src/BranchBar/BranchBar.module.css +230 -0
  6. package/src/BranchBar/useBranches.js +79 -0
  7. package/src/CommandPalette/CommandPalette.jsx +936 -0
  8. package/src/CommandPalette/CreateDialog.jsx +219 -0
  9. package/src/CommandPalette/command-palette.css +111 -0
  10. package/src/Icon.jsx +180 -0
  11. package/src/Viewfinder.jsx +1104 -57
  12. package/src/Viewfinder.module.css +1107 -149
  13. package/src/canvas/CanvasControls.jsx +51 -2
  14. package/src/canvas/CanvasControls.module.css +31 -0
  15. package/src/canvas/CanvasPage.bridge.test.jsx +142 -19
  16. package/src/canvas/CanvasPage.dragdrop.test.jsx +346 -0
  17. package/src/canvas/CanvasPage.jsx +807 -251
  18. package/src/canvas/CanvasPage.module.css +98 -50
  19. package/src/canvas/CanvasPage.multiselect.test.jsx +13 -11
  20. package/src/canvas/CanvasToolbar.jsx +2 -2
  21. package/src/canvas/MarqueeOverlay.jsx +20 -0
  22. package/src/canvas/PageSelector.jsx +239 -0
  23. package/src/canvas/PageSelector.module.css +165 -0
  24. package/src/canvas/PageSelector.test.jsx +104 -0
  25. package/src/canvas/canvasApi.js +22 -8
  26. package/src/canvas/canvasTheme.js +96 -52
  27. package/src/canvas/componentIsolate.jsx +33 -7
  28. package/src/canvas/useCanvas.js +9 -8
  29. package/src/canvas/useCanvas.test.js +4 -4
  30. package/src/canvas/useMarqueeSelect.js +187 -0
  31. package/src/canvas/useMarqueeSelect.test.js +78 -0
  32. package/src/canvas/widgets/CodePenEmbed.jsx +292 -0
  33. package/src/canvas/widgets/CodePenEmbed.module.css +161 -0
  34. package/src/canvas/widgets/ComponentWidget.jsx +42 -10
  35. package/src/canvas/widgets/ComponentWidget.module.css +6 -5
  36. package/src/canvas/widgets/FigmaEmbed.jsx +110 -24
  37. package/src/canvas/widgets/FigmaEmbed.module.css +21 -7
  38. package/src/canvas/widgets/LinkPreview.jsx +297 -11
  39. package/src/canvas/widgets/LinkPreview.module.css +386 -18
  40. package/src/canvas/widgets/LinkPreview.test.jsx +193 -0
  41. package/src/canvas/widgets/MarkdownBlock.jsx +86 -5
  42. package/src/canvas/widgets/MarkdownBlock.module.css +64 -15
  43. package/src/canvas/widgets/PrototypeEmbed.jsx +96 -145
  44. package/src/canvas/widgets/PrototypeEmbed.module.css +74 -4
  45. package/src/canvas/widgets/StickyNote.module.css +5 -0
  46. package/src/canvas/widgets/StickyNote.test.jsx +9 -9
  47. package/src/canvas/widgets/StoryWidget.jsx +277 -0
  48. package/src/canvas/widgets/StoryWidget.module.css +211 -0
  49. package/src/canvas/widgets/WidgetChrome.jsx +76 -20
  50. package/src/canvas/widgets/WidgetChrome.module.css +2 -6
  51. package/src/canvas/widgets/WidgetWrapper.module.css +2 -0
  52. package/src/canvas/widgets/codepenUrl.js +75 -0
  53. package/src/canvas/widgets/codepenUrl.test.js +76 -0
  54. package/src/canvas/widgets/embedInteraction.test.jsx +235 -0
  55. package/src/canvas/widgets/embedOverlay.module.css +35 -0
  56. package/src/canvas/widgets/embedTheme.js +138 -39
  57. package/src/canvas/widgets/githubUrl.js +82 -0
  58. package/src/canvas/widgets/githubUrl.test.js +74 -0
  59. package/src/canvas/widgets/iframeDevLogs.js +49 -0
  60. package/src/canvas/widgets/iframeDevLogs.test.jsx +81 -0
  61. package/src/canvas/widgets/index.js +4 -0
  62. package/src/canvas/widgets/pasteRules.js +295 -0
  63. package/src/canvas/widgets/pasteRules.test.js +474 -0
  64. package/src/canvas/widgets/snapshotDisplay.test.jsx +259 -0
  65. package/src/canvas/widgets/widgetConfig.js +16 -5
  66. package/src/canvas/widgets/widgetConfig.test.js +34 -12
  67. package/src/context.jsx +145 -16
  68. package/src/hooks/useSceneData.js +4 -2
  69. package/src/hooks/useThemeState.js +61 -0
  70. package/src/hooks/useThemeState.test.js +66 -0
  71. package/src/index.js +10 -0
  72. package/src/story/StoryPage.jsx +117 -0
  73. package/src/story/StoryPage.module.css +18 -0
  74. package/src/vite/data-plugin.js +348 -66
  75. package/src/vite/data-plugin.test.js +405 -5
@@ -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
+ })