@dfosco/storyboard-react 3.11.0-beta.8 → 3.11.0-beta.9

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 CHANGED
@@ -1,10 +1,10 @@
1
1
  {
2
2
  "name": "@dfosco/storyboard-react",
3
- "version": "3.11.0-beta.8",
3
+ "version": "3.11.0-beta.9",
4
4
  "type": "module",
5
5
  "dependencies": {
6
- "@dfosco/storyboard-core": "3.11.0-beta.8",
7
- "@dfosco/tiny-canvas": "3.11.0-beta.8",
6
+ "@dfosco/storyboard-core": "3.11.0-beta.9",
7
+ "@dfosco/tiny-canvas": "3.11.0-beta.9",
8
8
  "@neodrag/react": "^2.3.1",
9
9
  "glob": "^11.0.0",
10
10
  "jsonc-parser": "^3.3.1"
@@ -321,8 +321,11 @@ export default function CanvasPage({ name }) {
321
321
  /**
322
322
  * Selection handler — shift+click toggles in/out of multi-select set,
323
323
  * plain click single-selects (clears others).
324
+ * Suppressed immediately after a multi-drag to prevent the post-drag
325
+ * click from collapsing the selection.
324
326
  */
325
327
  const handleWidgetSelect = useCallback((widgetId, shiftKey) => {
328
+ if (justDraggedRef.current) return
326
329
  if (shiftKey) {
327
330
  setSelectedWidgetIds(prev => {
328
331
  const next = new Set(prev)
@@ -338,62 +341,53 @@ export default function CanvasPage({ name }) {
338
341
  }
339
342
  }, [])
340
343
 
341
- // --- Multi-select live drag preview via imperative DOM transforms ---
342
- // On drag start, snapshot ALL articles' translate values (same coord space).
343
- // On each tick, read dragged article's current translate, compute delta
344
- // from its snapshot, apply same delta to all peers.
345
- const draggedArticleRef = useRef(null)
346
- const draggedStartTranslate = useRef({ x: 0, y: 0 })
347
- const peerSnapshots = useRef(new Map())
348
-
349
- function parseTranslate(article) {
350
- const raw = article?.style.translate || '0px 0px'
351
- const parts = raw.match(/-?[\d.]+/g) || [0, 0]
352
- return { x: parseFloat(parts[0]) || 0, y: parseFloat(parts[1]) || 0 }
353
- }
344
+ // --- Multi-select drag: peers animate to new positions on drag end ---
345
+ // During drag, only the dragged widget moves (via neodrag). On drag end,
346
+ // peer widget positions are updated via React state, and we add the
347
+ // tc-on-translation class so they animate smoothly to their new spots.
348
+ const peerArticlesRef = useRef(new Map())
349
+ // Flag to suppress the click-based selection reset that fires after a drag
350
+ const justDraggedRef = useRef(false)
354
351
 
355
- const handleItemDragStart = useCallback((dragId) => {
352
+ const handleItemDragStart = useCallback((dragId, position) => {
356
353
  const ids = selectedIdsRef.current
357
- peerSnapshots.current.clear()
358
- draggedArticleRef.current = null
354
+ peerArticlesRef.current.clear()
359
355
  if (ids.size <= 1 || !ids.has(dragId)) return
360
356
 
361
- // Snapshot dragged widget's article translate
362
- const draggedEl = document.getElementById(dragId)
363
- const draggedArticle = draggedEl?.closest('article')
364
- if (!draggedArticle) return
365
- draggedArticleRef.current = draggedArticle
366
- draggedStartTranslate.current = parseTranslate(draggedArticle)
357
+ // Suppress selection changes for the duration of the drag
358
+ justDraggedRef.current = true
367
359
 
368
- // Snapshot each peer's article translate
360
+ // Collect peer article elements for transition on drag end
369
361
  for (const id of ids) {
370
362
  if (id === dragId) continue
371
363
  const widgetEl = document.getElementById(id)
372
364
  const article = widgetEl?.closest('article')
373
365
  if (!article) continue
374
- peerSnapshots.current.set(id, {
375
- article,
376
- ...parseTranslate(article),
377
- })
366
+ peerArticlesRef.current.set(id, article)
378
367
  }
379
368
  }, [])
380
369
 
381
370
  const handleItemDrag = useCallback(() => {
382
- if (!draggedArticleRef.current) return
383
-
384
- // Read dragged article's CURRENT translate (set by neodrag)
385
- const current = parseTranslate(draggedArticleRef.current)
386
- const dx = current.x - draggedStartTranslate.current.x
387
- const dy = current.y - draggedStartTranslate.current.y
371
+ // Peers stay put during drag — they animate on drag end
372
+ }, [])
388
373
 
389
- for (const [, peer] of peerSnapshots.current) {
390
- peer.article.style.translate = `${peer.x + dx}px ${peer.y + dy}px`
374
+ /** Add transition class to peer articles so they animate to new positions. */
375
+ const transitionPeers = useCallback(() => {
376
+ for (const [, article] of peerArticlesRef.current) {
377
+ article.classList.add('tc-on-translation')
391
378
  }
379
+ // Remove class after animation completes
380
+ const articles = [...peerArticlesRef.current.values()]
381
+ setTimeout(() => {
382
+ for (const article of articles) {
383
+ article.classList.remove('tc-on-translation')
384
+ }
385
+ }, 250 * 4)
386
+ peerArticlesRef.current.clear()
392
387
  }, [])
393
388
 
394
389
  const clearDragPreview = useCallback(() => {
395
- peerSnapshots.current.clear()
396
- draggedArticleRef.current = null
390
+ peerArticlesRef.current.clear()
397
391
  }, [])
398
392
 
399
393
  if (canvas !== trackedCanvas) {
@@ -515,40 +509,39 @@ export default function CanvasPage({ name }) {
515
509
  }, [name, debouncedSourceSave, undoRedo, snapEnabled, snapGridSize])
516
510
 
517
511
  const handleItemDragEnd = useCallback((dragId, position) => {
518
- if (!dragId || !position) return
519
- const rounded = { x: Math.max(0, roundPosition(position.x)), y: Math.max(0, roundPosition(position.y)) }
520
-
521
- if (dragId.startsWith('jsx-')) {
522
- undoRedo.snapshot(stateRef.current, 'move', dragId)
523
- const sourceExport = dragId.replace(/^jsx-/, '')
524
- setLocalSources((prev) => {
525
- const current = Array.isArray(prev) ? prev : []
526
- const next = current.some((s) => s?.export === sourceExport)
527
- ? current.map((s) => (s?.export === sourceExport ? { ...s, position: rounded } : s))
528
- : [...current, { export: sourceExport, position: rounded }]
529
- queueWrite(() =>
530
- updateCanvas(name, { sources: next }).catch((err) =>
531
- console.error('[canvas] Failed to save source position:', err)
532
- )
533
- )
534
- return next
535
- })
512
+ if (!dragId || !position) {
513
+ clearDragPreview()
536
514
  return
537
515
  }
516
+ const rounded = { x: Math.max(0, roundPosition(position.x)), y: Math.max(0, roundPosition(position.y)) }
538
517
 
539
518
  const ids = selectedIdsRef.current
540
519
  // Multi-select move: apply same delta to all selected widgets
520
+ // Checked BEFORE the jsx- early return so mixed selections work
541
521
  if (ids.size > 1 && ids.has(dragId)) {
542
- clearDragPreview()
522
+ transitionPeers()
523
+ // Suppress the click-based selection reset that fires after pointerup
524
+ justDraggedRef.current = true
525
+ requestAnimationFrame(() => { justDraggedRef.current = false })
543
526
  undoRedo.snapshot(stateRef.current, 'multi-move')
544
- const currentWidgets = stateRef.current.widgets ?? []
545
- const draggedWidget = currentWidgets.find(w => w.id === dragId)
546
- if (!draggedWidget) return
547
- const oldPos = draggedWidget.position || { x: 0, y: 0 }
527
+
528
+ // Compute delta from the dragged widget's old position
529
+ const isJsx = dragId.startsWith('jsx-')
530
+ let oldPos = { x: 0, y: 0 }
531
+ if (isJsx) {
532
+ const sourceExport = dragId.replace(/^jsx-/, '')
533
+ const source = (stateRef.current.sources ?? []).find(s => s?.export === sourceExport)
534
+ oldPos = source?.position || { x: 0, y: 0 }
535
+ } else {
536
+ const draggedWidget = (stateRef.current.widgets ?? []).find(w => w.id === dragId)
537
+ oldPos = draggedWidget?.position || { x: 0, y: 0 }
538
+ }
548
539
  const dx = rounded.x - oldPos.x
549
540
  const dy = rounded.y - oldPos.y
550
541
 
551
542
  debouncedSave.cancel()
543
+
544
+ // Update JSON widget positions
552
545
  setLocalWidgets((prev) => {
553
546
  if (!prev) return prev
554
547
  const next = prev.map((w) => {
@@ -571,6 +564,57 @@ export default function CanvasPage({ name }) {
571
564
  )
572
565
  return next
573
566
  })
567
+
568
+ // Update JSX source positions
569
+ setLocalSources((prev) => {
570
+ const current = Array.isArray(prev) ? prev : []
571
+ let changed = false
572
+ const next = current.map((s) => {
573
+ if (!s?.export) return s
574
+ const sid = `jsx-${s.export}`
575
+ if (sid === dragId) {
576
+ changed = true
577
+ return { ...s, position: rounded }
578
+ }
579
+ if (ids.has(sid)) {
580
+ changed = true
581
+ return {
582
+ ...s,
583
+ position: {
584
+ x: Math.max(0, roundPosition((s.position?.x ?? 0) + dx)),
585
+ y: Math.max(0, roundPosition((s.position?.y ?? 0) + dy)),
586
+ },
587
+ }
588
+ }
589
+ return s
590
+ })
591
+ if (changed) {
592
+ queueWrite(() =>
593
+ updateCanvas(name, { sources: next }).catch((err) =>
594
+ console.error('[canvas] Failed to save multi-move sources:', err)
595
+ )
596
+ )
597
+ }
598
+ return changed ? next : current
599
+ })
600
+ return
601
+ }
602
+
603
+ if (dragId.startsWith('jsx-')) {
604
+ undoRedo.snapshot(stateRef.current, 'move', dragId)
605
+ const sourceExport = dragId.replace(/^jsx-/, '')
606
+ setLocalSources((prev) => {
607
+ const current = Array.isArray(prev) ? prev : []
608
+ const next = current.some((s) => s?.export === sourceExport)
609
+ ? current.map((s) => (s?.export === sourceExport ? { ...s, position: rounded } : s))
610
+ : [...current, { export: sourceExport, position: rounded }]
611
+ queueWrite(() =>
612
+ updateCanvas(name, { sources: next }).catch((err) =>
613
+ console.error('[canvas] Failed to save source position:', err)
614
+ )
615
+ )
616
+ return next
617
+ })
574
618
  return
575
619
  }
576
620
 
@@ -587,7 +631,7 @@ export default function CanvasPage({ name }) {
587
631
  )
588
632
  return next
589
633
  })
590
- }, [name, undoRedo, debouncedSave, clearDragPreview])
634
+ }, [name, undoRedo, debouncedSave, transitionPeers, clearDragPreview])
591
635
 
592
636
  useEffect(() => {
593
637
  zoomRef.current = zoom
@@ -15,10 +15,14 @@ vi.mock('./useUndoRedo.js', () => ({
15
15
  default: () => MOCK_UNDO_REDO,
16
16
  }))
17
17
 
18
- // Expose onDragEnd so tests can trigger drags with specific IDs
18
+ // Expose drag callbacks so tests can trigger drags with specific IDs
19
+ let capturedOnDragStart = null
20
+ let capturedOnDrag = null
19
21
  let capturedOnDragEnd = null
20
22
  vi.mock('@dfosco/tiny-canvas', () => ({
21
- Canvas: ({ children, onDragEnd }) => {
23
+ Canvas: ({ children, onDragStart, onDrag, onDragEnd }) => {
24
+ capturedOnDragStart = onDragStart
25
+ capturedOnDrag = onDrag
22
26
  capturedOnDragEnd = onDragEnd
23
27
  return <div data-testid="tiny-canvas">{children}</div>
24
28
  },
@@ -109,6 +113,8 @@ describe('CanvasPage multi-select', () => {
109
113
  delete window.__storyboardCanvasBridgeState
110
114
  window.__SB_LOCAL_DEV__ = true
111
115
  vi.clearAllMocks()
116
+ capturedOnDragStart = null
117
+ capturedOnDrag = null
112
118
  capturedOnDragEnd = null
113
119
  })
114
120
 
@@ -257,4 +263,83 @@ describe('CanvasPage multi-select', () => {
257
263
  })
258
264
  )
259
265
  })
266
+
267
+ it('multi-select drag captures peer articles on drag start', async () => {
268
+ render(<CanvasPage name="test-canvas" />)
269
+
270
+ // Multi-select w1 and w2
271
+ fireEvent.click(screen.getByTestId('select-w1'))
272
+ fireEvent.click(screen.getByTestId('shift-select-w2'))
273
+ await act(async () => { await new Promise((r) => setTimeout(r, 0)) })
274
+
275
+ // All drag callbacks should be captured
276
+ expect(capturedOnDragStart).toBeTruthy()
277
+ expect(capturedOnDrag).toBeTruthy()
278
+
279
+ // Trigger drag start — should not throw
280
+ act(() => {
281
+ capturedOnDragStart('w1', { x: 100, y: 100 })
282
+ })
283
+
284
+ // Drag tick — peers stay put (no live preview), should not throw
285
+ act(() => {
286
+ capturedOnDrag('w1', { x: 150, y: 200 })
287
+ })
288
+ })
289
+
290
+ it('multi-select drag preserves selection after drag end', async () => {
291
+ render(<CanvasPage name="test-canvas" />)
292
+
293
+ // Multi-select w1 and w2
294
+ fireEvent.click(screen.getByTestId('select-w1'))
295
+ fireEvent.click(screen.getByTestId('shift-select-w2'))
296
+ await act(async () => { await new Promise((r) => setTimeout(r, 0)) })
297
+
298
+ expect(screen.getByTestId('chrome-w1').dataset.selected).toBeDefined()
299
+ expect(screen.getByTestId('chrome-w2').dataset.selected).toBeDefined()
300
+
301
+ // Simulate full drag: start → drag → end
302
+ act(() => {
303
+ capturedOnDragStart('w1', { x: 100, y: 100 })
304
+ })
305
+ act(() => {
306
+ capturedOnDragEnd('w1', { x: 150, y: 200 })
307
+ })
308
+ await act(async () => { await new Promise((r) => setTimeout(r, 0)) })
309
+
310
+ // Selection should still include both widgets (justDraggedRef prevents collapse)
311
+ expect(screen.getByTestId('chrome-w1').dataset.selected).toBeDefined()
312
+ expect(screen.getByTestId('chrome-w2').dataset.selected).toBeDefined()
313
+ })
314
+
315
+ it('any selected widget can serve as drag handler for the group', async () => {
316
+ render(<CanvasPage name="test-canvas" />)
317
+
318
+ // Multi-select w1 (100,100), w2 (300,100), w3 (500,200)
319
+ fireEvent.click(screen.getByTestId('select-w1'))
320
+ fireEvent.click(screen.getByTestId('shift-select-w2'))
321
+ fireEvent.click(screen.getByTestId('shift-select-w3'))
322
+ await act(async () => { await new Promise((r) => setTimeout(r, 0)) })
323
+
324
+ // Drag w2 (the middle one) to (350, 150) → delta (+50, +50)
325
+ act(() => {
326
+ capturedOnDragStart('w2', { x: 300, y: 100 })
327
+ })
328
+ act(() => {
329
+ capturedOnDragEnd('w2', { x: 350, y: 150 })
330
+ })
331
+ await act(async () => { await new Promise((r) => setTimeout(r, 0)) })
332
+
333
+ // All selected widgets should move by the same delta (+50, +50)
334
+ expect(updateCanvas).toHaveBeenCalledWith(
335
+ 'test-canvas',
336
+ expect.objectContaining({
337
+ widgets: expect.arrayContaining([
338
+ expect.objectContaining({ id: 'w1', position: { x: 150, y: 150 } }),
339
+ expect.objectContaining({ id: 'w2', position: { x: 350, y: 150 } }),
340
+ expect.objectContaining({ id: 'w3', position: { x: 550, y: 250 } }),
341
+ ]),
342
+ })
343
+ )
344
+ })
260
345
  })