npm package: @ptahjs/dnd
Description: A lightweight, framework-agnostic drag-and-drop library built on the Pointer Events API. Designed with a plugin-first architecture for building canvas editors, sortable lists, and other interactive UIs.
Latest version: 0.0.3

# @ptahjs/dnd

A lightweight, framework-agnostic drag-and-drop library built on the Pointer Events API. Designed with a plugin-first architecture for building canvas editors, sortable lists, and other interactive UIs.

**[Live Demo →](https://dnd.ptahjs.com/)**

## Features

- **Pointer Events based** — unified mouse, touch, and stylus support
- **Plugin architecture** — all behaviors (mirror, drop feedback, auto-scroll, transform) are optional plugins
- **RAF-driven rendering** — per-frame measure → compute → commit pipeline prevents layout thrashing
- **Namespace support** — isolate independent drag-and-drop zones within the same page
- **Transform controller** — drag-scope mode with built-in move, resize, and rotate for canvas editors
- **Auto-scroll** — edge-triggered scrolling during drag
- **Zero dependencies** — no runtime dependencies

## Installation

```bash
npm install @ptahjs/dnd
```

## Quick Start

```js
import { Dnd, MirrorService, DropService } from '@ptahjs/dnd'
import '@ptahjs/dnd/style'

const dnd = new Dnd({ root: '#container' })
dnd.use(new MirrorService())
dnd.use(new DropService())

dnd.on('dragstart', (payload) => console.log('drag started', payload))
dnd.on('drop', (payload) => console.log('dropped', payload))
dnd.on('cancel', (payload) => console.log('cancelled', payload))
```

## HTML Markup

Use data attributes to declare draggable elements and drop targets:

```html
<!-- Root container -->
<div id="container">

  <!-- Drop target -->
  <div drop>
    <!-- Draggable element -->
    <div drag data-data='{"id":1,"type":"card"}'>Drag me</div>
  </div>

  <!-- Combined draggable + drop target -->
  <div dragdrop data-namespace="list-a">Item</div>

</div>
```

| Attribute | Description |
|---|---|
| `drag` | Marks an element as draggable |
| `drop` | Marks an element as a drop target |
| `dragdrop` | Element is both draggable and a drop target |
| `drag-handle` | Restricts drag to a specific handle element inside the draggable |
| `data-namespace` | Groups draggable/drop elements — only elements in the same namespace interact |
| `data-data` | JSON data payload attached to the draggable |
| `copy` | Creates a copy on drop instead of moving |
| `drag-scope` | Defines a canvas-style scope for `TransformControllerService` |
| `drop-indicator` | Enables directional drop indicator (`top`, `right`, `bottom`, `left`, or `all`) |
| `ignore-mirror` | Dragging this element does not create a mirror clone |
| `ignore-click` | Clicks on this element do not affect active selection |
| `resizable` / `rotatable` | Per-element toggle for resize/rotate handles (defaults to `true` within drag-scope) |
| `scale-ratio` | Canvas zoom ratio applied to pointer coordinates in drag-scope mode |

## API

### `new Dnd(config?)`

Creates a DnD instance.

| Option | Type | Default | Description |
|---|---|---|---|
| `root` | `HTMLElement \| string` | — | Root container element or CSS selector |
| `threshold` | `number` | `3` | Pixel distance before drag is considered started |

### Instance methods

```js
// Register a plugin
dnd.use(plugin)

// Set or replace the root container
dnd.setRoot(element)

// Override drop permission (called every frame)
dnd.canDrop = (payload) => payload.data.type !== 'locked'

// Custom mirror rendering
dnd.renderMirror = (ctx) => {
  const el = document.createElement('div')
  el.textContent = 'Custom mirror'
  return el
}

// Event listeners
dnd.on('dragstart', handler)
dnd.on('drag', handler)
dnd.on('drop', handler)
dnd.on('cancel', handler)
dnd.off('drop', handler)

// Clean up everything
dnd.destroy()
```

### `dnd.monitor`

The current drag session object. Read-only during an active drag.

| Property | Description |
|---|---|
| `active` | Whether a drag session is active (pointer is down) |
| `started` | Whether the drag threshold has been exceeded |
| `x`, `y` | Current pointer coordinates |
| `dx`, `dy` | Delta from drag start |
| `sourceEl` | The element being dragged |
| `handleEl` | The handle element that was grabbed |
| `currentDrop` | The drop target currently under the pointer |
| `currentAllowed` | Whether `canDrop` returned `true` for the current target |
| `currentDropRect` | Bounding rect of the current drop target |
| `indicatorRegion` | Active drop indicator direction (`top` / `right` / `bottom` / `left`) |
| `data` | Parsed data from the draggable's `data-data` attribute |
| `namespace` | Namespace of the drag session |
| `isCopy` | Whether the draggable has the `copy` attribute |

## Events

All event handlers receive a payload object.

```js
dnd.on('dragstart', ({ source, data, namespace }) => { /* ... */ })
dnd.on('drag',      ({ source, currentDrop, currentAllowed, x, y }) => { /* ... */ })
dnd.on('drop',      ({ source, currentDrop, indicatorRegion, data }) => { /* ... */ })
dnd.on('cancel',    ({ source, data }) => { /* ... */ })
```

## Built-in Plugins (Services)

All services are optional. Register them with `dnd.use(new ServiceName())`.

### `MirrorService`

Creates a floating clone of the dragged element that follows the pointer. Adds `dnd-dragging` class to the source element during drag.

```js
dnd.use(new MirrorService())
```

The clone can be customized via `dnd.renderMirror`. To disable the mirror for a specific element, add the `ignore-mirror` attribute to it.

### `DropService`

Maintains `currentDrop`, `currentDropRect`, and `currentAllowed` on the session. Applies `dnd-canDrop` or `dnd-noDrop` CSS class to the active drop target.

```js
dnd.use(new DropService())
```

### `DropIndicatorService`

Shows a directional insertion indicator (top/right/bottom/left) on the active drop target. Requires the drop target to have a `drop-indicator` attribute.

```js
dnd.use(new DropIndicatorService())
```

```html
<!-- Enable all four directions -->
<div drop drop-indicator>...</div>

<!-- Enable specific directions only -->
<div drop drop-indicator="top bottom">...</div>
```

The indicator element has class `dnd-indicator` and toggles `dnd-indicator--top`, `dnd-indicator--right`, `dnd-indicator--bottom`, `dnd-indicator--left` direction classes.

### `AutoScrollService`

Automatically scrolls the nearest scrollable ancestor (or window) when the pointer approaches the edge of the container during drag.

```js
dnd.use(new AutoScrollService({
  edge: 48,       // Edge trigger distance in px (default: 48)
  minSpeed: 180,  // Minimum scroll speed in px/s (default: 180)
  maxSpeed: 600,  // Maximum scroll speed in px/s (default: 600)
  allowWindowScroll: true  // Allow scrolling window (default: true)
}))
```

### `ActiveSelectionService`

Tracks the selected (active) draggable per namespace. Adds `dnd-active` class to the selected element. Clicking outside clears the selection.

Within a `drag-scope` container, selecting an element injects resize/rotate handles into it.

```js
dnd.use(new ActiveSelectionService())
```

### `TransformControllerService`

Enables move, resize, and rotate within a `drag-scope` container. Designed for canvas editors with elements that use CSS `transform` for positioning.

```js
dnd.use(new TransformControllerService({
  resizable: true,
  rotatable: true,
  boundary: true,         // Constrain movement within drag-scope
  scaleRatio: 1,          // Canvas zoom ratio (can also be set via data-scale-ratio)
  snapToGrid: false,
  gridX: 10,
  gridY: 10,
  aspectRatio: undefined, // Lock aspect ratio during resize
  minWidth: 10,
  minHeight: 10,
  maxWidth: 0,            // 0 = no limit
  maxHeight: 0,
  rotateSnap: false,
  rotateStep: 15,         // Snap angle in degrees
  snap: true,             // Snap-to-element alignment
  snapThreshold: 10,      // Snap threshold in px
  markline: true,         // Show alignment guide lines
}))
```

**Emitted events** (via `dnd.on`):

| Event | Description |
|---|---|
| `draggable:drag` | Element moved — payload includes `el`, `x`, `y`, `width`, `height`, `angle` |
| `draggable:resize` | Element resized — same payload |
| `draggable:rotate` | Element rotated — same payload |
| `draggable:drop` | Drag ended with a committed transform — includes final `x`, `y`, `width`, `height`, `angle` |

**HTML markup for drag-scope:**

```html
<div drag-scope data-scale-ratio="1">
  <div drag resizable rotatable
       style="transform: translate(100px, 50px); width: 200px; height: 120px;">
    Canvas element
  </div>
</div>
```

## CSS Classes

| Class | Applied to | Description |
|---|---|---|
| `dnd-dragging` | Source element | While drag is active |
| `dnd-mirror` | Mirror clone | The floating drag ghost |
| `dnd-active` | Selected draggable | While element is selected |
| `dnd-canDrop` | Drop target | When `canDrop` returns `true` |
| `dnd-noDrop` | Drop target | When `canDrop` returns `false` |
| `dnd-indicator` | Indicator element | The drop direction indicator |
| `dnd-indicator-active` | Indicator element | When indicator is visible |
| `dnd-indicator--top/right/bottom/left` | Indicator element | Active direction |

## Writing a Custom Plugin

A plugin is a plain object or class instance with optional lifecycle hooks. Register it with `dnd.use(plugin)`.

```js
const myPlugin = {
  order: 50, // lower = earlier execution

  onAttach(dnd) {
    // Called once when plugin is registered
  },

  onRootChange(nextRoot, prevRoot, signal) {
    // Called when dnd.setRoot() is called
    nextRoot.addEventListener('contextmenu', handler, { signal })
  },

  onDown(ctx, event) {
    // Pointer down — drag not yet started
  },

  onStart(ctx) {
    // Drag threshold exceeded, drag is now active
  },

  onMeasure(ctx) {
    // Read-only DOM measurements (called every frame)
  },

  onCompute(ctx) {
    // Pure calculations based on measurements (no DOM writes)
  },

  onCommit(ctx) {
    // Write DOM changes via ctx.frame to batch updates
    ctx.frame.toggleClass(element, 'my-class', true)
    ctx.frame.setStyle(element, 'opacity', '0.5')
  },

  onAfterDrag(ctx) {
    // Post-commit hook, e.g. for auto-scroll
    // Return true or { scrolled: true } to request a re-render next frame
  },

  onEnd(ctx, meta) {
    // meta.ended: true = drop, false = cancel
    // meta.reason: 'pointerup' | 'blur' | 'destroy'
  },

  onDestroy(dnd, session) {
    // Cleanup when dnd.destroy() is called
  }
}

dnd.use(myPlugin)
```

### Context object (`ctx`)

| Property | Description |
|---|---|
| `ctx.session` | The current drag session (monitor) |
| `ctx.store` | Cross-session store (`selectedByNs` map) |
| `ctx.frame` | Frame command queue for batched DOM writes |
| `ctx.adapter` | DOM adapter for measurements and hit testing |
| `ctx.dnd` | The `Dnd` instance |
| `ctx.payload(type)` | Builds an event payload for the given event type |

## Architecture

```
Dnd (Facade)
├── PointerSensor      — captures pointerdown/move/up events
├── State              — finite state machine (DOWN → MOVE → END)
├── DomAdapter         — DOM queries and per-frame rect caching
├── RafScheduler       — requestAnimationFrame loop (only runs when dirty)
├── FrameContext       — batched DOM command queue for the current frame
└── PluginRuntime      — ordered plugin lifecycle dispatcher
    ├── MirrorService
    ├── DropService
    ├── DropIndicatorService
    ├── AutoScrollService
    ├── ActiveSelectionService
    └── TransformControllerService
```

Per-frame pipeline (while dragging):

```
pointermove → State.dispatch(MOVE) → scheduler.request()
                                           ↓
                                      requestAnimationFrame
                                           ↓
                               PluginRuntime.onMeasure   (read DOM)
                               PluginRuntime.onCompute   (pure math)
                               emit('drag', payload)
                               PluginRuntime.onCommit    (write DOM)
                               FrameContext.commit()
                               PluginRuntime.onAfterDrag (scroll, etc.)
```

## License

MIT