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