npm.io
0.1.3 • Published yesterday

@ptahjs/dnd

Licence
Version
0.1.3
Deps
2
Size
70 kB
Vulns
0
Weekly
0

@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 →

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

npm install @ptahjs/dnd

Quick Start

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:

<!-- 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
// 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.

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.

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.

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.

dnd.use(new DropIndicatorService())
<!-- 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.

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.

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.

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:

<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).

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