dnd-block-tree 0.4.0 → 1.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.
package/README.md CHANGED
@@ -13,9 +13,10 @@ A headless React library for building hierarchical drag-and-drop interfaces. Bri
13
13
  ## Features
14
14
 
15
15
  - **Stable Drop Zones** - Zones render based on original block positions, not preview state, ensuring consistent drop targets during drag
16
- - **Ghost Preview** - Semi-transparent preview shows where blocks will land without affecting zone positions
17
- - **Depth-Aware Collision** - Smart algorithm prefers nested zones when cursor is at indented levels, with hysteresis to prevent flickering
18
- - **Mobile & Touch Support** - Separate touch/pointer activation constraints prevent interference with scrolling on mobile devices
16
+ - **Ghost Preview** - In-flow semi-transparent preview shows where blocks will land with accurate layout
17
+ - **Snapshotted Collision** - Zone rects are frozen on drag start and re-measured after each ghost commit, preventing layout-shift feedback loops
18
+ - **Depth-Aware Collision** - Smart algorithm prefers nested zones when cursor is at indented levels, with cross-depth-aware hysteresis
19
+ - **Mobile & Touch Support** - Separate touch/pointer activation constraints with configurable `longPressDelay` and optional `hapticFeedback`
19
20
  - **Snapshot-Based Computation** - State captured at drag start. All preview computations use snapshot, ensuring consistent behavior
20
21
  - **Debounced Preview** - 150ms debounced virtual state for smooth drag previews without jitter
21
22
  - **Customizable Drag Rules** - `canDrag` and `canDrop` filters for fine-grained control over drag behavior
@@ -25,6 +26,10 @@ A headless React library for building hierarchical drag-and-drop interfaces. Bri
25
26
  - **Undo/Redo** - Composable `useBlockHistory` hook with past/future stacks for state history management
26
27
  - **Lifecycle Callbacks** - `onBlockAdd`, `onBlockDelete`, `onBlockMove`, `onBeforeMove` middleware, and more
27
28
  - **Fractional Indexing** - Opt-in CRDT-compatible ordering via `orderingStrategy: 'fractional'`
29
+ - **Serialization** - `flatToNested` / `nestedToFlat` converters for flat array ↔ nested tree transforms
30
+ - **SSR Compatible** - `BlockTreeSSR` wrapper for hydration-safe rendering in Next.js and other SSR environments
31
+ - **Animation Support** - CSS expand/collapse transitions via `AnimationConfig` and FLIP reorder animations via `useLayoutAnimation`
32
+ - **Virtual Scrolling** - Windowed rendering for large trees (1000+ blocks) via `virtualize` prop
28
33
 
29
34
  ## Installation
30
35
 
@@ -83,12 +88,17 @@ function App() {
83
88
  | `showDropPreview` | `boolean` | `true` | Show live preview of block at drop position |
84
89
  | `canDrag` | `(block: T) => boolean` | - | Filter which blocks can be dragged |
85
90
  | `canDrop` | `(block, zone, target) => boolean` | - | Filter valid drop targets |
91
+ | `collisionDetection` | `CollisionDetection` | sticky | Custom collision detection algorithm (from `@dnd-kit/core`) |
92
+ | `sensors` | `SensorConfig` | - | Sensor configuration (see [Sensor Config](#sensor-config)) |
93
+ | `animation` | `AnimationConfig` | - | Animation configuration (see [Animation](#animation--transitions)) |
86
94
  | `maxDepth` | `number` | - | Maximum nesting depth (1 = flat, 2 = one level, etc.) |
87
95
  | `keyboardNavigation` | `boolean` | `false` | Enable keyboard navigation with arrow keys |
88
96
  | `multiSelect` | `boolean` | `false` | Enable multi-select with Cmd/Ctrl+Click and Shift+Click |
89
97
  | `selectedIds` | `Set<string>` | - | Externally-controlled selected IDs (for multi-select) |
90
98
  | `onSelectionChange` | `(ids: Set<string>) => void` | - | Called when selection changes |
91
99
  | `orderingStrategy` | `'integer' \| 'fractional'` | `'integer'` | Sibling ordering strategy |
100
+ | `initialExpanded` | `string[] \| 'all' \| 'none'` | `'all'` | Initially expanded container IDs |
101
+ | `virtualize` | `{ itemHeight: number; overscan?: number }` | - | Enable virtual scrolling (see [Virtual Scrolling](#virtual-scrolling)) |
92
102
  | `className` | `string` | - | Root container class |
93
103
  | `dropZoneClassName` | `string` | - | Drop zone class |
94
104
  | `dropZoneActiveClassName` | `string` | - | Active drop zone class |
@@ -109,6 +119,145 @@ function App() {
109
119
  | `onExpandChange` | `(event: ExpandChangeEvent<T>) => void` | Called when expand/collapse changes |
110
120
  | `onHoverChange` | `(event: HoverChangeEvent<T>) => void` | Called when hover zone changes |
111
121
 
122
+ ### Sensor Config
123
+
124
+ ```typescript
125
+ interface SensorConfig {
126
+ /** Distance in pixels before drag starts (default: 8) */
127
+ activationDistance?: number
128
+ /** Delay in ms before drag starts (overrides distance) */
129
+ activationDelay?: number
130
+ /** Tolerance in px for delay-based activation (default: 5) */
131
+ tolerance?: number
132
+ /** Override the default long-press delay for touch sensors (default: 200ms) */
133
+ longPressDelay?: number
134
+ /** Trigger haptic feedback (vibration) on drag start for touch devices */
135
+ hapticFeedback?: boolean
136
+ }
137
+ ```
138
+
139
+ ```tsx
140
+ <BlockTree
141
+ sensors={{
142
+ longPressDelay: 300, // 300ms long-press for touch
143
+ hapticFeedback: true, // Vibrate on drag start
144
+ }}
145
+ ...
146
+ />
147
+ ```
148
+
149
+ ### Animation Config
150
+
151
+ ```typescript
152
+ interface AnimationConfig {
153
+ /** Duration for expand/collapse animations in ms */
154
+ expandDuration?: number
155
+ /** Duration for drag overlay animation in ms */
156
+ dragOverlayDuration?: number
157
+ /** Easing function (CSS timing function, default: 'ease') */
158
+ easing?: string
159
+ }
160
+ ```
161
+
162
+ ### Event Types
163
+
164
+ All event types are exported and generic over your block type `T`.
165
+
166
+ #### DragStartEvent
167
+
168
+ ```typescript
169
+ interface DragStartEvent<T> {
170
+ block: T
171
+ blockId: string
172
+ }
173
+ ```
174
+
175
+ #### DragMoveEvent
176
+
177
+ ```typescript
178
+ interface DragMoveEvent<T> {
179
+ block: T
180
+ blockId: string
181
+ overZone: string | null // Current hover zone ID
182
+ coordinates: { x: number; y: number }
183
+ }
184
+ ```
185
+
186
+ #### DragEndEvent
187
+
188
+ ```typescript
189
+ interface DragEndEvent<T> {
190
+ block: T
191
+ blockId: string
192
+ targetZone: string | null // Zone where block was dropped
193
+ cancelled: boolean
194
+ }
195
+ ```
196
+
197
+ #### BlockMoveEvent
198
+
199
+ ```typescript
200
+ interface BlockMoveEvent<T> {
201
+ block: T
202
+ from: BlockPosition // { parentId, index }
203
+ to: BlockPosition
204
+ blocks: T[] // Full block array after the move
205
+ movedIds: string[] // All moved block IDs (for multi-select)
206
+ }
207
+ ```
208
+
209
+ #### MoveOperation
210
+
211
+ Passed to the `onBeforeMove` middleware:
212
+
213
+ ```typescript
214
+ interface MoveOperation<T> {
215
+ block: T
216
+ from: BlockPosition
217
+ targetZone: string // Drop zone ID (e.g. "after-uuid", "into-uuid")
218
+ }
219
+ ```
220
+
221
+ #### BlockAddEvent
222
+
223
+ ```typescript
224
+ interface BlockAddEvent<T> {
225
+ block: T
226
+ parentId: string | null
227
+ index: number
228
+ }
229
+ ```
230
+
231
+ #### BlockDeleteEvent
232
+
233
+ ```typescript
234
+ interface BlockDeleteEvent<T> {
235
+ block: T
236
+ deletedIds: string[] // Block + all descendant IDs
237
+ parentId: string | null
238
+ }
239
+ ```
240
+
241
+ #### ExpandChangeEvent
242
+
243
+ ```typescript
244
+ interface ExpandChangeEvent<T> {
245
+ block: T
246
+ blockId: string
247
+ expanded: boolean
248
+ }
249
+ ```
250
+
251
+ #### HoverChangeEvent
252
+
253
+ ```typescript
254
+ interface HoverChangeEvent<T> {
255
+ zoneId: string | null
256
+ zoneType: 'before' | 'after' | 'into' | null
257
+ targetBlock: T | null
258
+ }
259
+ ```
260
+
112
261
  ### Types
113
262
 
114
263
  #### BaseBlock
@@ -150,6 +299,182 @@ interface ContainerRendererProps<T extends BaseBlock> extends BlockRendererProps
150
299
  }
151
300
  ```
152
301
 
302
+ #### NestedBlock
303
+
304
+ Tree representation used by serialization helpers:
305
+
306
+ ```typescript
307
+ type NestedBlock<T extends BaseBlock> = Omit<T, 'parentId' | 'order'> & {
308
+ children: NestedBlock<T>[]
309
+ }
310
+ ```
311
+
312
+ ## Serialization Helpers
313
+
314
+ Convert between flat block arrays and nested tree structures:
315
+
316
+ ```tsx
317
+ import { flatToNested, nestedToFlat, type NestedBlock } from 'dnd-block-tree'
318
+
319
+ // Flat array -> nested tree (e.g. for JSON export)
320
+ const nested: NestedBlock<MyBlock>[] = flatToNested(blocks)
321
+
322
+ // Nested tree -> flat array (e.g. for JSON import)
323
+ const flat: MyBlock[] = nestedToFlat(nested)
324
+ ```
325
+
326
+ `flatToNested` groups by `parentId`, sorts siblings by `order`, and recursively builds `children` arrays. `parentId` and `order` are omitted from the output since they're structural.
327
+
328
+ `nestedToFlat` performs a DFS walk, assigning `parentId` and integer `order` on the way down.
329
+
330
+ ## SSR Compatibility
331
+
332
+ For Next.js App Router or other SSR environments, use `BlockTreeSSR` to avoid hydration mismatches:
333
+
334
+ ```tsx
335
+ import { BlockTreeSSR } from 'dnd-block-tree'
336
+
337
+ function Page() {
338
+ return (
339
+ <BlockTreeSSR
340
+ blocks={blocks}
341
+ renderers={renderers}
342
+ containerTypes={CONTAINER_TYPES}
343
+ onChange={setBlocks}
344
+ fallback={<div>Loading tree...</div>}
345
+ />
346
+ )
347
+ }
348
+ ```
349
+
350
+ `BlockTreeSSR` renders the `fallback` (default: `null`) on the server, then mounts the full `BlockTree` after hydration via `useEffect`. All `BlockTree` props are passed through.
351
+
352
+ ```typescript
353
+ interface BlockTreeSSRProps<T, C> extends BlockTreeProps<T, C> {
354
+ fallback?: ReactNode
355
+ }
356
+ ```
357
+
358
+ ## Animation & Transitions
359
+
360
+ ### Expand/Collapse Transitions
361
+
362
+ Pass an `AnimationConfig` to enable CSS transitions on container expand/collapse:
363
+
364
+ ```tsx
365
+ <BlockTree
366
+ animation={{
367
+ expandDuration: 200, // ms
368
+ easing: 'ease-in-out', // CSS timing function
369
+ }}
370
+ ...
371
+ />
372
+ ```
373
+
374
+ ### FLIP Reorder Animation
375
+
376
+ The `useLayoutAnimation` hook provides FLIP-based (First-Last-Invert-Play) animations for block reorder transitions. It's a standalone composable — not built into BlockTree:
377
+
378
+ ```tsx
379
+ import { useLayoutAnimation } from 'dnd-block-tree'
380
+
381
+ function MyTree() {
382
+ const containerRef = useRef<HTMLDivElement>(null)
383
+
384
+ useLayoutAnimation(containerRef, {
385
+ duration: 200, // Transition duration in ms (default: 200)
386
+ easing: 'ease', // CSS easing (default: 'ease')
387
+ selector: '[data-block-id]', // Selector for animated children (default)
388
+ })
389
+
390
+ return (
391
+ <div ref={containerRef}>
392
+ <BlockTree ... />
393
+ </div>
394
+ )
395
+ }
396
+ ```
397
+
398
+ ```typescript
399
+ interface UseLayoutAnimationOptions {
400
+ duration?: number // default: 200
401
+ easing?: string // default: 'ease'
402
+ selector?: string // default: '[data-block-id]'
403
+ }
404
+ ```
405
+
406
+ ## Virtual Scrolling
407
+
408
+ Enable windowed rendering for large trees (1000+ blocks) with the `virtualize` prop:
409
+
410
+ ```tsx
411
+ <BlockTree
412
+ virtualize={{
413
+ itemHeight: 40, // Fixed height of each item in pixels
414
+ overscan: 5, // Extra items rendered outside viewport (default: 5)
415
+ }}
416
+ blocks={blocks}
417
+ ...
418
+ />
419
+ ```
420
+
421
+ When enabled, only visible blocks (plus overscan) are rendered. The tree is wrapped in a scrollable container with a spacer div maintaining correct total height.
422
+
423
+ **Limitations:** Fixed item height only. Variable height items are not supported.
424
+
425
+ ### useVirtualTree Hook
426
+
427
+ For custom virtual scrolling implementations outside of BlockTree:
428
+
429
+ ```tsx
430
+ import { useVirtualTree } from 'dnd-block-tree'
431
+
432
+ const containerRef = useRef<HTMLDivElement>(null)
433
+ const { visibleRange, totalHeight, offsetY } = useVirtualTree({
434
+ containerRef,
435
+ itemCount: 1000,
436
+ itemHeight: 40,
437
+ overscan: 5,
438
+ })
439
+ ```
440
+
441
+ ```typescript
442
+ interface UseVirtualTreeOptions {
443
+ containerRef: React.RefObject<HTMLElement | null>
444
+ itemCount: number
445
+ itemHeight: number
446
+ overscan?: number // default: 5
447
+ }
448
+
449
+ interface UseVirtualTreeResult {
450
+ visibleRange: { start: number; end: number }
451
+ totalHeight: number
452
+ offsetY: number
453
+ }
454
+ ```
455
+
456
+ ## Touch & Mobile
457
+
458
+ Touch support is built-in with sensible defaults (200ms long-press, 5px tolerance). For fine-tuning:
459
+
460
+ ```tsx
461
+ <BlockTree
462
+ sensors={{
463
+ longPressDelay: 300, // Override default 200ms touch activation
464
+ hapticFeedback: true, // Vibrate on drag start (uses navigator.vibrate)
465
+ }}
466
+ ...
467
+ />
468
+ ```
469
+
470
+ The `triggerHaptic` utility is also exported for use in custom components:
471
+
472
+ ```tsx
473
+ import { triggerHaptic } from 'dnd-block-tree'
474
+
475
+ triggerHaptic(10) // Vibrate for 10ms (default)
476
+ ```
477
+
153
478
  ## Undo/Redo
154
479
 
155
480
  The `useBlockHistory` hook provides undo/redo support as a composable layer on top of `BlockTree`:
@@ -222,6 +547,112 @@ Limit nesting depth to prevent deeply nested trees:
222
547
  - `maxDepth={2}` - blocks can nest one level inside containers
223
548
  - When a move would exceed the limit, the drop zone is rejected and the move is a no-op
224
549
 
550
+ ## Move Middleware (`onBeforeMove`)
551
+
552
+ The `onBeforeMove` callback intercepts moves before they are committed. You can use it to validate, transform, or cancel moves:
553
+
554
+ ```tsx
555
+ <BlockTree
556
+ blocks={blocks}
557
+ onChange={setBlocks}
558
+ onBeforeMove={(operation) => {
559
+ // Cancel: return false to prevent the move
560
+ if (operation.block.type === 'locked') {
561
+ return false
562
+ }
563
+
564
+ // Transform: change the target zone
565
+ if (operation.targetZone.startsWith('into-') && someCondition) {
566
+ return { ...operation, targetZone: `after-${extractBlockId(operation.targetZone)}` }
567
+ }
568
+
569
+ // Allow: return void/undefined to proceed as-is
570
+ }}
571
+ />
572
+ ```
573
+
574
+ The middleware receives a `MoveOperation` with the block, its original position (`from`), and the target drop zone. Returning:
575
+ - `false` cancels the move entirely
576
+ - A modified `MoveOperation` transforms the move (e.g. redirect to a different zone)
577
+ - `void` / `undefined` allows the move as-is
578
+
579
+ ## Fractional Indexing
580
+
581
+ By default, siblings are reindexed `0, 1, 2, ...` on every move. For collaborative or CRDT-compatible scenarios, use fractional indexing:
582
+
583
+ ```tsx
584
+ import { BlockTree, initFractionalOrder } from 'dnd-block-tree'
585
+
586
+ // Convert existing blocks to fractional ordering
587
+ const [blocks, setBlocks] = useState(() => initFractionalOrder(initialBlocks))
588
+
589
+ <BlockTree
590
+ blocks={blocks}
591
+ onChange={setBlocks}
592
+ orderingStrategy="fractional"
593
+ />
594
+ ```
595
+
596
+ With fractional ordering, only the moved block receives a new `order` value (a lexicographically sortable string key). Siblings are never reindexed, making it safe for concurrent edits.
597
+
598
+ Related utilities:
599
+
600
+ ```typescript
601
+ import {
602
+ generateKeyBetween, // Generate a key between two existing keys
603
+ generateNKeysBetween, // Generate N keys between two existing keys
604
+ generateInitialKeys, // Generate N evenly-spaced initial keys
605
+ initFractionalOrder, // Convert integer-ordered blocks to fractional
606
+ compareFractionalKeys, // Compare two fractional keys
607
+ } from 'dnd-block-tree'
608
+ ```
609
+
610
+ ## Collision Detection
611
+
612
+ The library ships with three collision detection strategies:
613
+
614
+ ```typescript
615
+ import {
616
+ weightedVerticalCollision, // Default: edge-distance based, depth-aware
617
+ closestCenterCollision, // Simple closest-center algorithm
618
+ createStickyCollision, // Wraps any strategy with hysteresis to prevent flickering
619
+ } from 'dnd-block-tree'
620
+
621
+ // Use a custom collision strategy
622
+ <BlockTree collisionDetection={closestCenterCollision} ... />
623
+
624
+ // Or use the sticky wrapper with a custom threshold (px)
625
+ const collision = createStickyCollision(20)
626
+ <BlockTree collisionDetection={collision} ... />
627
+ ```
628
+
629
+ You can also pass any `CollisionDetection` function from `@dnd-kit/core`.
630
+
631
+ ### Snapshotted Zone Rects
632
+
633
+ `createStickyCollision` accepts an optional `SnapshotRectsRef` — a ref to a `Map<string, DOMRect>` of frozen zone positions. When provided, collision detection uses these snapshots instead of live DOM measurements, preventing feedback loops caused by the in-flow ghost preview shifting zone positions.
634
+
635
+ ```typescript
636
+ import { createStickyCollision, type SnapshotRectsRef } from 'dnd-block-tree'
637
+
638
+ const snapshotRef: SnapshotRectsRef = { current: null }
639
+ const collision = createStickyCollision(20, snapshotRef)
640
+
641
+ // Snapshot all zone rects after drag starts:
642
+ snapshotRef.current = new Map(
643
+ [...document.querySelectorAll('[data-zone-id]')].map(el => [
644
+ el.getAttribute('data-zone-id')!,
645
+ el.getBoundingClientRect(),
646
+ ])
647
+ )
648
+ ```
649
+
650
+ `BlockTree` handles this lifecycle automatically — zones are snapshotted on drag start and re-measured via `requestAnimationFrame` after each ghost position commit.
651
+
652
+ ### Cross-Depth Hysteresis
653
+
654
+ The sticky collision uses a reduced threshold (25% of normal) when switching between zones at different indentation levels. This makes it easy to drag blocks in and out of containers while still preventing flickering between same-depth adjacent zones.
655
+
225
656
  ## Type Safety
226
657
 
227
658
  The library provides automatic type inference for container vs non-container renderers:
@@ -245,20 +676,57 @@ const renderers: BlockRenderers<MyBlock, typeof CONTAINER_TYPES> = {
245
676
 
246
677
  ## Utilities
247
678
 
248
- The library exports several utility functions:
679
+ The library exports utility functions for tree manipulation, ID generation, and zone parsing:
249
680
 
250
681
  ```typescript
251
682
  import {
252
- computeNormalizedIndex, // Convert flat array to normalized index
253
- buildOrderedBlocks, // Convert index back to ordered array
254
- reparentBlockIndex, // Move a block to a new position
255
- reparentMultipleBlocks, // Move multiple blocks preserving relative order
256
- getDescendantIds, // Get all descendant IDs of a block
257
- getBlockDepth, // Compute depth of a block in the tree
258
- getSubtreeDepth, // Compute max depth of a subtree
259
- generateId, // Generate unique block IDs
260
- generateKeyBetween, // Generate a fractional key between two keys
261
- initFractionalOrder, // Convert integer-ordered blocks to fractional
683
+ // Tree operations
684
+ computeNormalizedIndex, // Convert flat array to { byId, byParent } index
685
+ buildOrderedBlocks, // Convert index back to ordered flat array
686
+ reparentBlockIndex, // Move a single block to a new position
687
+ reparentMultipleBlocks, // Move multiple blocks preserving relative order
688
+ getDescendantIds, // Get all descendant IDs of a block (Set)
689
+ deleteBlockAndDescendants, // Remove a block and all its descendants from index
690
+ getBlockDepth, // Compute depth of a block (root = 1)
691
+ getSubtreeDepth, // Max depth of a subtree (leaf = 1)
692
+
693
+ // Serialization
694
+ flatToNested, // Convert flat block array to nested tree
695
+ nestedToFlat, // Convert nested tree to flat block array
696
+
697
+ // ID / zone helpers
698
+ generateId, // Generate unique block IDs
699
+ extractUUID, // Extract block ID from zone ID string
700
+ getDropZoneType, // Parse zone type: 'before' | 'after' | 'into'
701
+ extractBlockId, // Extract block ID from zone ID (alias)
702
+
703
+ // Touch
704
+ triggerHaptic, // Trigger haptic feedback (navigator.vibrate)
705
+
706
+ // Fractional indexing
707
+ generateKeyBetween, // Generate a fractional key between two keys
708
+ generateNKeysBetween, // Generate N keys between two existing keys
709
+ generateInitialKeys, // Generate N evenly-spaced initial keys
710
+ initFractionalOrder, // Convert integer-ordered blocks to fractional
711
+ compareFractionalKeys, // Compare two fractional keys
712
+
713
+ // Collision detection
714
+ weightedVerticalCollision, // Edge-distance collision, depth-aware
715
+ closestCenterCollision, // Simple closest-center collision
716
+ createStickyCollision, // Hysteresis wrapper with snapshot support
717
+ type SnapshotRectsRef, // Ref type for frozen zone rects
718
+
719
+ // Hooks
720
+ useBlockHistory, // Undo/redo state management
721
+ useLayoutAnimation, // FLIP-based reorder animations
722
+ useVirtualTree, // Virtual scrolling primitives
723
+
724
+ // Components
725
+ BlockTree, // Main drag-and-drop tree component
726
+ BlockTreeSSR, // SSR-safe wrapper
727
+ TreeRenderer, // Recursive tree renderer
728
+ DropZone, // Individual drop zone
729
+ DragOverlay, // Drag overlay wrapper
262
730
  } from 'dnd-block-tree'
263
731
  ```
264
732