dnd-block-tree 0.3.0 → 0.5.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 +555 -10
- package/dist/index.d.mts +296 -12
- package/dist/index.d.ts +296 -12
- package/dist/index.js +849 -114
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +836 -116
- package/dist/index.mjs.map +1 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -15,11 +15,20 @@ A headless React library for building hierarchical drag-and-drop interfaces. Bri
|
|
|
15
15
|
- **Stable Drop Zones** - Zones render based on original block positions, not preview state, ensuring consistent drop targets during drag
|
|
16
16
|
- **Ghost Preview** - Semi-transparent preview shows where blocks will land without affecting zone positions
|
|
17
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
|
|
19
|
-
- **8px Activation Distance** - Prevents accidental drags. Pointer must move 8px before drag starts, allowing normal clicks
|
|
18
|
+
- **Mobile & Touch Support** - Separate touch/pointer activation constraints with configurable `longPressDelay` and optional `hapticFeedback`
|
|
20
19
|
- **Snapshot-Based Computation** - State captured at drag start. All preview computations use snapshot, ensuring consistent behavior
|
|
21
20
|
- **Debounced Preview** - 150ms debounced virtual state for smooth drag previews without jitter
|
|
22
21
|
- **Customizable Drag Rules** - `canDrag` and `canDrop` filters for fine-grained control over drag behavior
|
|
22
|
+
- **Max Depth Constraint** - Limit nesting depth via `maxDepth` prop, enforced in both drag validation and programmatic APIs
|
|
23
|
+
- **Keyboard Navigation** - Arrow key traversal, Enter/Space to expand/collapse, Home/End to jump. Opt-in via `keyboardNavigation` prop
|
|
24
|
+
- **Multi-Select Drag** - Cmd/Ctrl+Click and Shift+Click selection with batch drag. Opt-in via `multiSelect` prop
|
|
25
|
+
- **Undo/Redo** - Composable `useBlockHistory` hook with past/future stacks for state history management
|
|
26
|
+
- **Lifecycle Callbacks** - `onBlockAdd`, `onBlockDelete`, `onBlockMove`, `onBeforeMove` middleware, and more
|
|
27
|
+
- **Fractional Indexing** - Opt-in CRDT-compatible ordering via `orderingStrategy: 'fractional'`
|
|
28
|
+
- **Serialization** - `flatToNested` / `nestedToFlat` converters for flat array ↔ nested tree transforms
|
|
29
|
+
- **SSR Compatible** - `BlockTreeSSR` wrapper for hydration-safe rendering in Next.js and other SSR environments
|
|
30
|
+
- **Animation Support** - CSS expand/collapse transitions via `AnimationConfig` and FLIP reorder animations via `useLayoutAnimation`
|
|
31
|
+
- **Virtual Scrolling** - Windowed rendering for large trees (1000+ blocks) via `virtualize` prop
|
|
23
32
|
|
|
24
33
|
## Installation
|
|
25
34
|
|
|
@@ -78,11 +87,176 @@ function App() {
|
|
|
78
87
|
| `showDropPreview` | `boolean` | `true` | Show live preview of block at drop position |
|
|
79
88
|
| `canDrag` | `(block: T) => boolean` | - | Filter which blocks can be dragged |
|
|
80
89
|
| `canDrop` | `(block, zone, target) => boolean` | - | Filter valid drop targets |
|
|
90
|
+
| `collisionDetection` | `CollisionDetection` | sticky | Custom collision detection algorithm (from `@dnd-kit/core`) |
|
|
91
|
+
| `sensors` | `SensorConfig` | - | Sensor configuration (see [Sensor Config](#sensor-config)) |
|
|
92
|
+
| `animation` | `AnimationConfig` | - | Animation configuration (see [Animation](#animation--transitions)) |
|
|
93
|
+
| `maxDepth` | `number` | - | Maximum nesting depth (1 = flat, 2 = one level, etc.) |
|
|
94
|
+
| `keyboardNavigation` | `boolean` | `false` | Enable keyboard navigation with arrow keys |
|
|
95
|
+
| `multiSelect` | `boolean` | `false` | Enable multi-select with Cmd/Ctrl+Click and Shift+Click |
|
|
96
|
+
| `selectedIds` | `Set<string>` | - | Externally-controlled selected IDs (for multi-select) |
|
|
97
|
+
| `onSelectionChange` | `(ids: Set<string>) => void` | - | Called when selection changes |
|
|
98
|
+
| `orderingStrategy` | `'integer' \| 'fractional'` | `'integer'` | Sibling ordering strategy |
|
|
99
|
+
| `initialExpanded` | `string[] \| 'all' \| 'none'` | `'all'` | Initially expanded container IDs |
|
|
100
|
+
| `virtualize` | `{ itemHeight: number; overscan?: number }` | - | Enable virtual scrolling (see [Virtual Scrolling](#virtual-scrolling)) |
|
|
81
101
|
| `className` | `string` | - | Root container class |
|
|
82
102
|
| `dropZoneClassName` | `string` | - | Drop zone class |
|
|
83
103
|
| `dropZoneActiveClassName` | `string` | - | Active drop zone class |
|
|
84
104
|
| `indentClassName` | `string` | - | Nested children indent class |
|
|
85
105
|
|
|
106
|
+
### Callbacks
|
|
107
|
+
|
|
108
|
+
| Callback | Type | Description |
|
|
109
|
+
|----------|------|-------------|
|
|
110
|
+
| `onDragStart` | `(event: DragStartEvent<T>) => boolean \| void` | Called when drag starts. Return `false` to prevent. |
|
|
111
|
+
| `onDragMove` | `(event: DragMoveEvent<T>) => void` | Called during drag movement (debounced) |
|
|
112
|
+
| `onDragEnd` | `(event: DragEndEvent<T>) => void` | Called when drag ends |
|
|
113
|
+
| `onDragCancel` | `(event: DragEndEvent<T>) => void` | Called when drag is cancelled |
|
|
114
|
+
| `onBeforeMove` | `(op: MoveOperation<T>) => MoveOperation<T> \| false \| void` | Middleware to transform or cancel moves |
|
|
115
|
+
| `onBlockMove` | `(event: BlockMoveEvent<T>) => void` | Called after a block is moved |
|
|
116
|
+
| `onBlockAdd` | `(event: BlockAddEvent<T>) => void` | Called after a block is added |
|
|
117
|
+
| `onBlockDelete` | `(event: BlockDeleteEvent<T>) => void` | Called after a block is deleted |
|
|
118
|
+
| `onExpandChange` | `(event: ExpandChangeEvent<T>) => void` | Called when expand/collapse changes |
|
|
119
|
+
| `onHoverChange` | `(event: HoverChangeEvent<T>) => void` | Called when hover zone changes |
|
|
120
|
+
|
|
121
|
+
### Sensor Config
|
|
122
|
+
|
|
123
|
+
```typescript
|
|
124
|
+
interface SensorConfig {
|
|
125
|
+
/** Distance in pixels before drag starts (default: 8) */
|
|
126
|
+
activationDistance?: number
|
|
127
|
+
/** Delay in ms before drag starts (overrides distance) */
|
|
128
|
+
activationDelay?: number
|
|
129
|
+
/** Tolerance in px for delay-based activation (default: 5) */
|
|
130
|
+
tolerance?: number
|
|
131
|
+
/** Override the default long-press delay for touch sensors (default: 200ms) */
|
|
132
|
+
longPressDelay?: number
|
|
133
|
+
/** Trigger haptic feedback (vibration) on drag start for touch devices */
|
|
134
|
+
hapticFeedback?: boolean
|
|
135
|
+
}
|
|
136
|
+
```
|
|
137
|
+
|
|
138
|
+
```tsx
|
|
139
|
+
<BlockTree
|
|
140
|
+
sensors={{
|
|
141
|
+
longPressDelay: 300, // 300ms long-press for touch
|
|
142
|
+
hapticFeedback: true, // Vibrate on drag start
|
|
143
|
+
}}
|
|
144
|
+
...
|
|
145
|
+
/>
|
|
146
|
+
```
|
|
147
|
+
|
|
148
|
+
### Animation Config
|
|
149
|
+
|
|
150
|
+
```typescript
|
|
151
|
+
interface AnimationConfig {
|
|
152
|
+
/** Duration for expand/collapse animations in ms */
|
|
153
|
+
expandDuration?: number
|
|
154
|
+
/** Duration for drag overlay animation in ms */
|
|
155
|
+
dragOverlayDuration?: number
|
|
156
|
+
/** Easing function (CSS timing function, default: 'ease') */
|
|
157
|
+
easing?: string
|
|
158
|
+
}
|
|
159
|
+
```
|
|
160
|
+
|
|
161
|
+
### Event Types
|
|
162
|
+
|
|
163
|
+
All event types are exported and generic over your block type `T`.
|
|
164
|
+
|
|
165
|
+
#### DragStartEvent
|
|
166
|
+
|
|
167
|
+
```typescript
|
|
168
|
+
interface DragStartEvent<T> {
|
|
169
|
+
block: T
|
|
170
|
+
blockId: string
|
|
171
|
+
}
|
|
172
|
+
```
|
|
173
|
+
|
|
174
|
+
#### DragMoveEvent
|
|
175
|
+
|
|
176
|
+
```typescript
|
|
177
|
+
interface DragMoveEvent<T> {
|
|
178
|
+
block: T
|
|
179
|
+
blockId: string
|
|
180
|
+
overZone: string | null // Current hover zone ID
|
|
181
|
+
coordinates: { x: number; y: number }
|
|
182
|
+
}
|
|
183
|
+
```
|
|
184
|
+
|
|
185
|
+
#### DragEndEvent
|
|
186
|
+
|
|
187
|
+
```typescript
|
|
188
|
+
interface DragEndEvent<T> {
|
|
189
|
+
block: T
|
|
190
|
+
blockId: string
|
|
191
|
+
targetZone: string | null // Zone where block was dropped
|
|
192
|
+
cancelled: boolean
|
|
193
|
+
}
|
|
194
|
+
```
|
|
195
|
+
|
|
196
|
+
#### BlockMoveEvent
|
|
197
|
+
|
|
198
|
+
```typescript
|
|
199
|
+
interface BlockMoveEvent<T> {
|
|
200
|
+
block: T
|
|
201
|
+
from: BlockPosition // { parentId, index }
|
|
202
|
+
to: BlockPosition
|
|
203
|
+
blocks: T[] // Full block array after the move
|
|
204
|
+
movedIds: string[] // All moved block IDs (for multi-select)
|
|
205
|
+
}
|
|
206
|
+
```
|
|
207
|
+
|
|
208
|
+
#### MoveOperation
|
|
209
|
+
|
|
210
|
+
Passed to the `onBeforeMove` middleware:
|
|
211
|
+
|
|
212
|
+
```typescript
|
|
213
|
+
interface MoveOperation<T> {
|
|
214
|
+
block: T
|
|
215
|
+
from: BlockPosition
|
|
216
|
+
targetZone: string // Drop zone ID (e.g. "after-uuid", "into-uuid")
|
|
217
|
+
}
|
|
218
|
+
```
|
|
219
|
+
|
|
220
|
+
#### BlockAddEvent
|
|
221
|
+
|
|
222
|
+
```typescript
|
|
223
|
+
interface BlockAddEvent<T> {
|
|
224
|
+
block: T
|
|
225
|
+
parentId: string | null
|
|
226
|
+
index: number
|
|
227
|
+
}
|
|
228
|
+
```
|
|
229
|
+
|
|
230
|
+
#### BlockDeleteEvent
|
|
231
|
+
|
|
232
|
+
```typescript
|
|
233
|
+
interface BlockDeleteEvent<T> {
|
|
234
|
+
block: T
|
|
235
|
+
deletedIds: string[] // Block + all descendant IDs
|
|
236
|
+
parentId: string | null
|
|
237
|
+
}
|
|
238
|
+
```
|
|
239
|
+
|
|
240
|
+
#### ExpandChangeEvent
|
|
241
|
+
|
|
242
|
+
```typescript
|
|
243
|
+
interface ExpandChangeEvent<T> {
|
|
244
|
+
block: T
|
|
245
|
+
blockId: string
|
|
246
|
+
expanded: boolean
|
|
247
|
+
}
|
|
248
|
+
```
|
|
249
|
+
|
|
250
|
+
#### HoverChangeEvent
|
|
251
|
+
|
|
252
|
+
```typescript
|
|
253
|
+
interface HoverChangeEvent<T> {
|
|
254
|
+
zoneId: string | null
|
|
255
|
+
zoneType: 'before' | 'after' | 'into' | null
|
|
256
|
+
targetBlock: T | null
|
|
257
|
+
}
|
|
258
|
+
```
|
|
259
|
+
|
|
86
260
|
### Types
|
|
87
261
|
|
|
88
262
|
#### BaseBlock
|
|
@@ -94,7 +268,7 @@ interface BaseBlock {
|
|
|
94
268
|
id: string
|
|
95
269
|
type: string
|
|
96
270
|
parentId: string | null
|
|
97
|
-
order: number
|
|
271
|
+
order: number | string // number for integer ordering, string for fractional
|
|
98
272
|
}
|
|
99
273
|
```
|
|
100
274
|
|
|
@@ -124,6 +298,335 @@ interface ContainerRendererProps<T extends BaseBlock> extends BlockRendererProps
|
|
|
124
298
|
}
|
|
125
299
|
```
|
|
126
300
|
|
|
301
|
+
#### NestedBlock
|
|
302
|
+
|
|
303
|
+
Tree representation used by serialization helpers:
|
|
304
|
+
|
|
305
|
+
```typescript
|
|
306
|
+
type NestedBlock<T extends BaseBlock> = Omit<T, 'parentId' | 'order'> & {
|
|
307
|
+
children: NestedBlock<T>[]
|
|
308
|
+
}
|
|
309
|
+
```
|
|
310
|
+
|
|
311
|
+
## Serialization Helpers
|
|
312
|
+
|
|
313
|
+
Convert between flat block arrays and nested tree structures:
|
|
314
|
+
|
|
315
|
+
```tsx
|
|
316
|
+
import { flatToNested, nestedToFlat, type NestedBlock } from 'dnd-block-tree'
|
|
317
|
+
|
|
318
|
+
// Flat array -> nested tree (e.g. for JSON export)
|
|
319
|
+
const nested: NestedBlock<MyBlock>[] = flatToNested(blocks)
|
|
320
|
+
|
|
321
|
+
// Nested tree -> flat array (e.g. for JSON import)
|
|
322
|
+
const flat: MyBlock[] = nestedToFlat(nested)
|
|
323
|
+
```
|
|
324
|
+
|
|
325
|
+
`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.
|
|
326
|
+
|
|
327
|
+
`nestedToFlat` performs a DFS walk, assigning `parentId` and integer `order` on the way down.
|
|
328
|
+
|
|
329
|
+
## SSR Compatibility
|
|
330
|
+
|
|
331
|
+
For Next.js App Router or other SSR environments, use `BlockTreeSSR` to avoid hydration mismatches:
|
|
332
|
+
|
|
333
|
+
```tsx
|
|
334
|
+
import { BlockTreeSSR } from 'dnd-block-tree'
|
|
335
|
+
|
|
336
|
+
function Page() {
|
|
337
|
+
return (
|
|
338
|
+
<BlockTreeSSR
|
|
339
|
+
blocks={blocks}
|
|
340
|
+
renderers={renderers}
|
|
341
|
+
containerTypes={CONTAINER_TYPES}
|
|
342
|
+
onChange={setBlocks}
|
|
343
|
+
fallback={<div>Loading tree...</div>}
|
|
344
|
+
/>
|
|
345
|
+
)
|
|
346
|
+
}
|
|
347
|
+
```
|
|
348
|
+
|
|
349
|
+
`BlockTreeSSR` renders the `fallback` (default: `null`) on the server, then mounts the full `BlockTree` after hydration via `useEffect`. All `BlockTree` props are passed through.
|
|
350
|
+
|
|
351
|
+
```typescript
|
|
352
|
+
interface BlockTreeSSRProps<T, C> extends BlockTreeProps<T, C> {
|
|
353
|
+
fallback?: ReactNode
|
|
354
|
+
}
|
|
355
|
+
```
|
|
356
|
+
|
|
357
|
+
## Animation & Transitions
|
|
358
|
+
|
|
359
|
+
### Expand/Collapse Transitions
|
|
360
|
+
|
|
361
|
+
Pass an `AnimationConfig` to enable CSS transitions on container expand/collapse:
|
|
362
|
+
|
|
363
|
+
```tsx
|
|
364
|
+
<BlockTree
|
|
365
|
+
animation={{
|
|
366
|
+
expandDuration: 200, // ms
|
|
367
|
+
easing: 'ease-in-out', // CSS timing function
|
|
368
|
+
}}
|
|
369
|
+
...
|
|
370
|
+
/>
|
|
371
|
+
```
|
|
372
|
+
|
|
373
|
+
### FLIP Reorder Animation
|
|
374
|
+
|
|
375
|
+
The `useLayoutAnimation` hook provides FLIP-based (First-Last-Invert-Play) animations for block reorder transitions. It's a standalone composable — not built into BlockTree:
|
|
376
|
+
|
|
377
|
+
```tsx
|
|
378
|
+
import { useLayoutAnimation } from 'dnd-block-tree'
|
|
379
|
+
|
|
380
|
+
function MyTree() {
|
|
381
|
+
const containerRef = useRef<HTMLDivElement>(null)
|
|
382
|
+
|
|
383
|
+
useLayoutAnimation(containerRef, {
|
|
384
|
+
duration: 200, // Transition duration in ms (default: 200)
|
|
385
|
+
easing: 'ease', // CSS easing (default: 'ease')
|
|
386
|
+
selector: '[data-block-id]', // Selector for animated children (default)
|
|
387
|
+
})
|
|
388
|
+
|
|
389
|
+
return (
|
|
390
|
+
<div ref={containerRef}>
|
|
391
|
+
<BlockTree ... />
|
|
392
|
+
</div>
|
|
393
|
+
)
|
|
394
|
+
}
|
|
395
|
+
```
|
|
396
|
+
|
|
397
|
+
```typescript
|
|
398
|
+
interface UseLayoutAnimationOptions {
|
|
399
|
+
duration?: number // default: 200
|
|
400
|
+
easing?: string // default: 'ease'
|
|
401
|
+
selector?: string // default: '[data-block-id]'
|
|
402
|
+
}
|
|
403
|
+
```
|
|
404
|
+
|
|
405
|
+
## Virtual Scrolling
|
|
406
|
+
|
|
407
|
+
Enable windowed rendering for large trees (1000+ blocks) with the `virtualize` prop:
|
|
408
|
+
|
|
409
|
+
```tsx
|
|
410
|
+
<BlockTree
|
|
411
|
+
virtualize={{
|
|
412
|
+
itemHeight: 40, // Fixed height of each item in pixels
|
|
413
|
+
overscan: 5, // Extra items rendered outside viewport (default: 5)
|
|
414
|
+
}}
|
|
415
|
+
blocks={blocks}
|
|
416
|
+
...
|
|
417
|
+
/>
|
|
418
|
+
```
|
|
419
|
+
|
|
420
|
+
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.
|
|
421
|
+
|
|
422
|
+
**Limitations:** Fixed item height only. Variable height items are not supported.
|
|
423
|
+
|
|
424
|
+
### useVirtualTree Hook
|
|
425
|
+
|
|
426
|
+
For custom virtual scrolling implementations outside of BlockTree:
|
|
427
|
+
|
|
428
|
+
```tsx
|
|
429
|
+
import { useVirtualTree } from 'dnd-block-tree'
|
|
430
|
+
|
|
431
|
+
const containerRef = useRef<HTMLDivElement>(null)
|
|
432
|
+
const { visibleRange, totalHeight, offsetY } = useVirtualTree({
|
|
433
|
+
containerRef,
|
|
434
|
+
itemCount: 1000,
|
|
435
|
+
itemHeight: 40,
|
|
436
|
+
overscan: 5,
|
|
437
|
+
})
|
|
438
|
+
```
|
|
439
|
+
|
|
440
|
+
```typescript
|
|
441
|
+
interface UseVirtualTreeOptions {
|
|
442
|
+
containerRef: React.RefObject<HTMLElement | null>
|
|
443
|
+
itemCount: number
|
|
444
|
+
itemHeight: number
|
|
445
|
+
overscan?: number // default: 5
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
interface UseVirtualTreeResult {
|
|
449
|
+
visibleRange: { start: number; end: number }
|
|
450
|
+
totalHeight: number
|
|
451
|
+
offsetY: number
|
|
452
|
+
}
|
|
453
|
+
```
|
|
454
|
+
|
|
455
|
+
## Touch & Mobile
|
|
456
|
+
|
|
457
|
+
Touch support is built-in with sensible defaults (200ms long-press, 5px tolerance). For fine-tuning:
|
|
458
|
+
|
|
459
|
+
```tsx
|
|
460
|
+
<BlockTree
|
|
461
|
+
sensors={{
|
|
462
|
+
longPressDelay: 300, // Override default 200ms touch activation
|
|
463
|
+
hapticFeedback: true, // Vibrate on drag start (uses navigator.vibrate)
|
|
464
|
+
}}
|
|
465
|
+
...
|
|
466
|
+
/>
|
|
467
|
+
```
|
|
468
|
+
|
|
469
|
+
The `triggerHaptic` utility is also exported for use in custom components:
|
|
470
|
+
|
|
471
|
+
```tsx
|
|
472
|
+
import { triggerHaptic } from 'dnd-block-tree'
|
|
473
|
+
|
|
474
|
+
triggerHaptic(10) // Vibrate for 10ms (default)
|
|
475
|
+
```
|
|
476
|
+
|
|
477
|
+
## Undo/Redo
|
|
478
|
+
|
|
479
|
+
The `useBlockHistory` hook provides undo/redo support as a composable layer on top of `BlockTree`:
|
|
480
|
+
|
|
481
|
+
```tsx
|
|
482
|
+
import { BlockTree, useBlockHistory } from 'dnd-block-tree'
|
|
483
|
+
|
|
484
|
+
function App() {
|
|
485
|
+
const { blocks, set, undo, redo, canUndo, canRedo } = useBlockHistory<MyBlock>(initialBlocks, {
|
|
486
|
+
maxSteps: 50, // optional, default 50
|
|
487
|
+
})
|
|
488
|
+
|
|
489
|
+
return (
|
|
490
|
+
<>
|
|
491
|
+
<div>
|
|
492
|
+
<button onClick={undo} disabled={!canUndo}>Undo</button>
|
|
493
|
+
<button onClick={redo} disabled={!canRedo}>Redo</button>
|
|
494
|
+
</div>
|
|
495
|
+
<BlockTree blocks={blocks} onChange={set} ... />
|
|
496
|
+
</>
|
|
497
|
+
)
|
|
498
|
+
}
|
|
499
|
+
```
|
|
500
|
+
|
|
501
|
+
## Keyboard Navigation
|
|
502
|
+
|
|
503
|
+
Enable accessible tree navigation with the `keyboardNavigation` prop:
|
|
504
|
+
|
|
505
|
+
```tsx
|
|
506
|
+
<BlockTree keyboardNavigation blocks={blocks} ... />
|
|
507
|
+
```
|
|
508
|
+
|
|
509
|
+
| Key | Action |
|
|
510
|
+
|-----|--------|
|
|
511
|
+
| Arrow Down | Focus next visible block |
|
|
512
|
+
| Arrow Up | Focus previous visible block |
|
|
513
|
+
| Arrow Right | Expand container, or focus first child |
|
|
514
|
+
| Arrow Left | Collapse container, or focus parent |
|
|
515
|
+
| Enter / Space | Toggle expand/collapse |
|
|
516
|
+
| Home | Focus first block |
|
|
517
|
+
| End | Focus last block |
|
|
518
|
+
|
|
519
|
+
Blocks receive `data-block-id` and `tabIndex` attributes for focus management, and the tree root gets `role="tree"`.
|
|
520
|
+
|
|
521
|
+
## Multi-Select Drag
|
|
522
|
+
|
|
523
|
+
Enable batch selection and drag with the `multiSelect` prop:
|
|
524
|
+
|
|
525
|
+
```tsx
|
|
526
|
+
<BlockTree multiSelect blocks={blocks} ... />
|
|
527
|
+
```
|
|
528
|
+
|
|
529
|
+
- **Cmd/Ctrl+Click** toggles a single block in the selection
|
|
530
|
+
- **Shift+Click** range-selects between the last clicked and current block
|
|
531
|
+
- **Plain click** clears selection and selects only the clicked block
|
|
532
|
+
- Dragging a selected block moves all selected blocks, preserving relative order
|
|
533
|
+
- The drag overlay shows a stacked card effect with a count badge
|
|
534
|
+
|
|
535
|
+
You can also control selection externally via `selectedIds` and `onSelectionChange`.
|
|
536
|
+
|
|
537
|
+
## Max Depth
|
|
538
|
+
|
|
539
|
+
Limit nesting depth to prevent deeply nested trees:
|
|
540
|
+
|
|
541
|
+
```tsx
|
|
542
|
+
<BlockTree maxDepth={2} blocks={blocks} ... />
|
|
543
|
+
```
|
|
544
|
+
|
|
545
|
+
- `maxDepth={1}` - flat list, no nesting allowed
|
|
546
|
+
- `maxDepth={2}` - blocks can nest one level inside containers
|
|
547
|
+
- When a move would exceed the limit, the drop zone is rejected and the move is a no-op
|
|
548
|
+
|
|
549
|
+
## Move Middleware (`onBeforeMove`)
|
|
550
|
+
|
|
551
|
+
The `onBeforeMove` callback intercepts moves before they are committed. You can use it to validate, transform, or cancel moves:
|
|
552
|
+
|
|
553
|
+
```tsx
|
|
554
|
+
<BlockTree
|
|
555
|
+
blocks={blocks}
|
|
556
|
+
onChange={setBlocks}
|
|
557
|
+
onBeforeMove={(operation) => {
|
|
558
|
+
// Cancel: return false to prevent the move
|
|
559
|
+
if (operation.block.type === 'locked') {
|
|
560
|
+
return false
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
// Transform: change the target zone
|
|
564
|
+
if (operation.targetZone.startsWith('into-') && someCondition) {
|
|
565
|
+
return { ...operation, targetZone: `after-${extractBlockId(operation.targetZone)}` }
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
// Allow: return void/undefined to proceed as-is
|
|
569
|
+
}}
|
|
570
|
+
/>
|
|
571
|
+
```
|
|
572
|
+
|
|
573
|
+
The middleware receives a `MoveOperation` with the block, its original position (`from`), and the target drop zone. Returning:
|
|
574
|
+
- `false` cancels the move entirely
|
|
575
|
+
- A modified `MoveOperation` transforms the move (e.g. redirect to a different zone)
|
|
576
|
+
- `void` / `undefined` allows the move as-is
|
|
577
|
+
|
|
578
|
+
## Fractional Indexing
|
|
579
|
+
|
|
580
|
+
By default, siblings are reindexed `0, 1, 2, ...` on every move. For collaborative or CRDT-compatible scenarios, use fractional indexing:
|
|
581
|
+
|
|
582
|
+
```tsx
|
|
583
|
+
import { BlockTree, initFractionalOrder } from 'dnd-block-tree'
|
|
584
|
+
|
|
585
|
+
// Convert existing blocks to fractional ordering
|
|
586
|
+
const [blocks, setBlocks] = useState(() => initFractionalOrder(initialBlocks))
|
|
587
|
+
|
|
588
|
+
<BlockTree
|
|
589
|
+
blocks={blocks}
|
|
590
|
+
onChange={setBlocks}
|
|
591
|
+
orderingStrategy="fractional"
|
|
592
|
+
/>
|
|
593
|
+
```
|
|
594
|
+
|
|
595
|
+
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.
|
|
596
|
+
|
|
597
|
+
Related utilities:
|
|
598
|
+
|
|
599
|
+
```typescript
|
|
600
|
+
import {
|
|
601
|
+
generateKeyBetween, // Generate a key between two existing keys
|
|
602
|
+
generateNKeysBetween, // Generate N keys between two existing keys
|
|
603
|
+
generateInitialKeys, // Generate N evenly-spaced initial keys
|
|
604
|
+
initFractionalOrder, // Convert integer-ordered blocks to fractional
|
|
605
|
+
compareFractionalKeys, // Compare two fractional keys
|
|
606
|
+
} from 'dnd-block-tree'
|
|
607
|
+
```
|
|
608
|
+
|
|
609
|
+
## Collision Detection
|
|
610
|
+
|
|
611
|
+
The library ships with three collision detection strategies:
|
|
612
|
+
|
|
613
|
+
```typescript
|
|
614
|
+
import {
|
|
615
|
+
weightedVerticalCollision, // Default: edge-distance based, depth-aware
|
|
616
|
+
closestCenterCollision, // Simple closest-center algorithm
|
|
617
|
+
createStickyCollision, // Wraps any strategy with hysteresis to prevent flickering
|
|
618
|
+
} from 'dnd-block-tree'
|
|
619
|
+
|
|
620
|
+
// Use a custom collision strategy
|
|
621
|
+
<BlockTree collisionDetection={closestCenterCollision} ... />
|
|
622
|
+
|
|
623
|
+
// Or use the sticky wrapper with a custom threshold (px)
|
|
624
|
+
const collision = createStickyCollision(20)
|
|
625
|
+
<BlockTree collisionDetection={collision} ... />
|
|
626
|
+
```
|
|
627
|
+
|
|
628
|
+
You can also pass any `CollisionDetection` function from `@dnd-kit/core`.
|
|
629
|
+
|
|
127
630
|
## Type Safety
|
|
128
631
|
|
|
129
632
|
The library provides automatic type inference for container vs non-container renderers:
|
|
@@ -147,14 +650,56 @@ const renderers: BlockRenderers<MyBlock, typeof CONTAINER_TYPES> = {
|
|
|
147
650
|
|
|
148
651
|
## Utilities
|
|
149
652
|
|
|
150
|
-
The library exports
|
|
653
|
+
The library exports utility functions for tree manipulation, ID generation, and zone parsing:
|
|
151
654
|
|
|
152
655
|
```typescript
|
|
153
656
|
import {
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
657
|
+
// Tree operations
|
|
658
|
+
computeNormalizedIndex, // Convert flat array to { byId, byParent } index
|
|
659
|
+
buildOrderedBlocks, // Convert index back to ordered flat array
|
|
660
|
+
reparentBlockIndex, // Move a single block to a new position
|
|
661
|
+
reparentMultipleBlocks, // Move multiple blocks preserving relative order
|
|
662
|
+
getDescendantIds, // Get all descendant IDs of a block (Set)
|
|
663
|
+
deleteBlockAndDescendants, // Remove a block and all its descendants from index
|
|
664
|
+
getBlockDepth, // Compute depth of a block (root = 1)
|
|
665
|
+
getSubtreeDepth, // Max depth of a subtree (leaf = 1)
|
|
666
|
+
|
|
667
|
+
// Serialization
|
|
668
|
+
flatToNested, // Convert flat block array to nested tree
|
|
669
|
+
nestedToFlat, // Convert nested tree to flat block array
|
|
670
|
+
|
|
671
|
+
// ID / zone helpers
|
|
672
|
+
generateId, // Generate unique block IDs
|
|
673
|
+
extractUUID, // Extract block ID from zone ID string
|
|
674
|
+
getDropZoneType, // Parse zone type: 'before' | 'after' | 'into'
|
|
675
|
+
extractBlockId, // Extract block ID from zone ID (alias)
|
|
676
|
+
|
|
677
|
+
// Touch
|
|
678
|
+
triggerHaptic, // Trigger haptic feedback (navigator.vibrate)
|
|
679
|
+
|
|
680
|
+
// Fractional indexing
|
|
681
|
+
generateKeyBetween, // Generate a fractional key between two keys
|
|
682
|
+
generateNKeysBetween, // Generate N keys between two existing keys
|
|
683
|
+
generateInitialKeys, // Generate N evenly-spaced initial keys
|
|
684
|
+
initFractionalOrder, // Convert integer-ordered blocks to fractional
|
|
685
|
+
compareFractionalKeys, // Compare two fractional keys
|
|
686
|
+
|
|
687
|
+
// Collision detection
|
|
688
|
+
weightedVerticalCollision, // Edge-distance collision, depth-aware
|
|
689
|
+
closestCenterCollision, // Simple closest-center collision
|
|
690
|
+
createStickyCollision, // Hysteresis wrapper to prevent flickering
|
|
691
|
+
|
|
692
|
+
// Hooks
|
|
693
|
+
useBlockHistory, // Undo/redo state management
|
|
694
|
+
useLayoutAnimation, // FLIP-based reorder animations
|
|
695
|
+
useVirtualTree, // Virtual scrolling primitives
|
|
696
|
+
|
|
697
|
+
// Components
|
|
698
|
+
BlockTree, // Main drag-and-drop tree component
|
|
699
|
+
BlockTreeSSR, // SSR-safe wrapper
|
|
700
|
+
TreeRenderer, // Recursive tree renderer
|
|
701
|
+
DropZone, // Individual drop zone
|
|
702
|
+
DragOverlay, // Drag overlay wrapper
|
|
158
703
|
} from 'dnd-block-tree'
|
|
159
704
|
```
|
|
160
705
|
|
|
@@ -162,13 +707,13 @@ import {
|
|
|
162
707
|
|
|
163
708
|
Check out the [live demo](https://dnd-block-tree.vercel.app) to see the library in action with two example use cases:
|
|
164
709
|
|
|
165
|
-
- **Productivity** - Sections, tasks, and notes
|
|
710
|
+
- **Productivity** - Sections, tasks, and notes with undo/redo, max depth control, and keyboard navigation
|
|
166
711
|
- **File System** - Folders and files
|
|
167
712
|
|
|
168
713
|
## Built With
|
|
169
714
|
|
|
170
715
|
- [dnd-kit](https://dndkit.com/) - Modern drag and drop toolkit for React
|
|
171
|
-
- React 18+
|
|
716
|
+
- React 18+ / React 19
|
|
172
717
|
- TypeScript
|
|
173
718
|
|
|
174
719
|
## License
|