@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.
Files changed (44) 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.dragdrop.test.jsx +346 -0
  5. package/src/canvas/CanvasPage.jsx +512 -235
  6. package/src/canvas/CanvasPage.module.css +9 -47
  7. package/src/canvas/ComponentErrorBoundary.jsx +50 -0
  8. package/src/canvas/PageSelector.jsx +102 -0
  9. package/src/canvas/PageSelector.module.css +93 -0
  10. package/src/canvas/PageSelector.test.jsx +104 -0
  11. package/src/canvas/canvasApi.js +4 -0
  12. package/src/canvas/canvasReloadGuard.js +37 -0
  13. package/src/canvas/canvasReloadGuard.test.js +27 -0
  14. package/src/canvas/componentIsolate.jsx +135 -0
  15. package/src/canvas/useCanvas.js +6 -2
  16. package/src/canvas/widgets/ComponentWidget.jsx +67 -9
  17. package/src/canvas/widgets/ComponentWidget.module.css +9 -6
  18. package/src/canvas/widgets/FigmaEmbed.jsx +26 -4
  19. package/src/canvas/widgets/FigmaEmbed.module.css +0 -7
  20. package/src/canvas/widgets/MarkdownBlock.jsx +94 -21
  21. package/src/canvas/widgets/MarkdownBlock.module.css +110 -2
  22. package/src/canvas/widgets/MarkdownBlock.test.jsx +39 -0
  23. package/src/canvas/widgets/PrototypeEmbed.jsx +196 -40
  24. package/src/canvas/widgets/PrototypeEmbed.module.css +30 -3
  25. package/src/canvas/widgets/StickyNote.module.css +5 -0
  26. package/src/canvas/widgets/StickyNote.test.jsx +9 -9
  27. package/src/canvas/widgets/StoryWidget.jsx +471 -0
  28. package/src/canvas/widgets/StoryWidget.module.css +200 -0
  29. package/src/canvas/widgets/WidgetChrome.jsx +54 -18
  30. package/src/canvas/widgets/WidgetChrome.module.css +4 -7
  31. package/src/canvas/widgets/WidgetWrapper.module.css +2 -0
  32. package/src/canvas/widgets/embedInteraction.test.jsx +155 -0
  33. package/src/canvas/widgets/embedOverlay.module.css +35 -0
  34. package/src/canvas/widgets/index.js +2 -0
  35. package/src/canvas/widgets/pasteRules.js +295 -0
  36. package/src/canvas/widgets/pasteRules.test.js +474 -0
  37. package/src/canvas/widgets/widgetConfig.js +16 -5
  38. package/src/canvas/widgets/widgetConfig.test.js +31 -9
  39. package/src/context.jsx +138 -13
  40. package/src/hooks/useSceneData.js +4 -2
  41. package/src/story/StoryPage.jsx +152 -0
  42. package/src/story/StoryPage.module.css +73 -0
  43. package/src/vite/data-plugin.js +441 -58
  44. 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.2",
3
+ "version": "4.0.0-beta.21",
4
4
  "type": "module",
5
5
  "dependencies": {
6
- "@dfosco/storyboard-core": "4.0.0-beta.2",
7
- "@dfosco/tiny-canvas": "4.0.0-beta.2",
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
+ })