@ptahjs/dnd 0.0.1 → 0.0.2

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/dist/dnd.css CHANGED
@@ -1,2 +1,2 @@
1
- .draggable-dot-wrap{pointer-events:none;z-index:1;box-sizing:border-box;position:absolute;inset:0}.draggable-dot{z-index:2;pointer-events:auto;background:#3a7afe;border-radius:50%;width:10px;height:10px;display:block;position:absolute;transform:translate(-50%,-50%)}.draggable-dot[data-pos=tl]{cursor:nw-resize;top:0%;left:0%}.draggable-dot[data-pos=tm]{cursor:n-resize;border-radius:8px;width:16px;height:8px;top:0%;left:50%}.draggable-dot[data-pos=tr]{cursor:ne-resize;top:0%;right:0%;transform:translate(50%,-50%)}.draggable-dot[data-pos=rm]{cursor:e-resize;border-radius:8px;width:8px;height:16px;top:50%;right:0%;transform:translate(50%,-50%)}.draggable-dot[data-pos=br]{cursor:se-resize;bottom:0%;right:0%;transform:translate(50%,50%)}.draggable-dot[data-pos=bm]{cursor:s-resize;border-radius:8px;width:16px;height:8px;bottom:0%;left:50%;transform:translate(-50%,50%)}.draggable-dot[data-pos=bl]{cursor:sw-resize;bottom:0%;left:0%;transform:translate(-50%,50%)}.draggable-dot[data-pos=lm]{cursor:w-resize;border-radius:8px;width:8px;height:16px;top:50%;left:0%}.draggable-rotate{z-index:2;cursor:grab;pointer-events:auto;position:absolute;top:0;left:50%;transform:translate(-50%,-200%)}.draggable-rotate:after{content:"";background:url(data:image/svg+xml;charset=utf-8,%3Csvg%20xmlns%3D%22http%3A//www.w3.org/2000/svg%22%20viewBox%3D%220%200%201024%201024%22%3E%3Cpath%20fill%3D%22%233a7afe%22%20d%3D%22M784.512%20230.272v-50.56a32%2032%200%201%201%2064%200v149.056a32%2032%200%200%201-32%2032H667.52a32%2032%200%201%201%200-64h92.992A320%20320%200%201%200%20524.8%20833.152a320%20320%200%200%200%20320-320h64a384%20384%200%200%201-384%20384%20384%20384%200%200%201-384-384%20384%20384%200%200%201%20643.712-282.88z%22/%3E%3C/svg%3E) 50%/contain no-repeat;width:16px;height:16px;display:block}.draggable-markline-x,.draggable-markline-y{z-index:9999;background-color:#3a7afe;display:none;position:absolute;top:0;left:0}.draggable-markline-x{width:1px;height:100%}.draggable-markline-y{width:100%;height:1px}.dnd-mirror{pointer-events:none;z-index:999999;will-change:transform;opacity:.5;background:#fff;margin:0;position:fixed;top:0;left:0;transform:translate(0,0)}
1
+ .draggable-dot-wrap{pointer-events:none;z-index:1;box-sizing:border-box;position:absolute;inset:0}.draggable-dot{z-index:2;pointer-events:auto;background:#3a7afe;border-radius:50%;width:10px;height:10px;display:block;position:absolute;transform:translate(-50%,-50%)}.draggable-dot[data-pos=tl]{cursor:nw-resize;top:0%;left:0%}.draggable-dot[data-pos=tm]{cursor:n-resize;border-radius:8px;width:16px;height:8px;top:0%;left:50%}.draggable-dot[data-pos=tr]{cursor:ne-resize;top:0%;right:0%;transform:translate(50%,-50%)}.draggable-dot[data-pos=rm]{cursor:e-resize;border-radius:8px;width:8px;height:16px;top:50%;right:0%;transform:translate(50%,-50%)}.draggable-dot[data-pos=br]{cursor:se-resize;bottom:0%;right:0%;transform:translate(50%,50%)}.draggable-dot[data-pos=bm]{cursor:s-resize;border-radius:8px;width:16px;height:8px;bottom:0%;left:50%;transform:translate(-50%,50%)}.draggable-dot[data-pos=bl]{cursor:sw-resize;bottom:0%;left:0%;transform:translate(-50%,50%)}.draggable-dot[data-pos=lm]{cursor:w-resize;border-radius:8px;width:8px;height:16px;top:50%;left:0%}.draggable-rotate{z-index:2;cursor:grab;pointer-events:auto;position:absolute;top:0;left:50%;transform:translate(-50%,-200%)}.draggable-rotate:after{content:"";background:url(data:image/svg+xml;charset=utf-8,%3Csvg%20xmlns%3D%22http%3A//www.w3.org/2000/svg%22%20viewBox%3D%220%200%201024%201024%22%3E%3Cpath%20fill%3D%22%233a7afe%22%20d%3D%22M784.512%20230.272v-50.56a32%2032%200%201%201%2064%200v149.056a32%2032%200%200%201-32%2032H667.52a32%2032%200%201%201%200-64h92.992A320%20320%200%201%200%20524.8%20833.152a320%20320%200%200%200%20320-320h64a384%20384%200%200%201-384%20384%20384%20384%200%200%201-384-384%20384%20384%200%200%201%20643.712-282.88z%22/%3E%3C/svg%3E) 50%/contain no-repeat;width:16px;height:16px;display:block}.draggable-markline-x,.draggable-markline-y{z-index:9999;background-color:#3a7afe;display:none;position:absolute;top:0;left:0}.draggable-markline-x{width:1px;height:100%}.draggable-markline-y{width:100%;height:1px}.dnd-mirror{pointer-events:none;z-index:999999;will-change:transform;opacity:.5;background:#fff;margin:0;position:fixed;top:0;left:0;transform:translate(0,0)}.dnd-indicator{--dnd-color:#3a7afe;--dnd-dot:6px;--dnd-line:2px;pointer-events:none;background:var(--dnd-color);position:absolute}.dnd-indicator:before,.dnd-indicator:after{content:"";background:var(--dnd-color);width:var(--dnd-dot);height:var(--dnd-dot);opacity:0;border-radius:50%;transition:opacity .15s;position:absolute}.dnd-indicator-active:before,.dnd-indicator-active:after{opacity:1}.dnd-indicator--top,.dnd-indicator--bottom{height:var(--dnd-line);width:100%;left:0}.dnd-indicator--top{top:0}.dnd-indicator--bottom{bottom:0}.dnd-indicator--top:before,.dnd-indicator--bottom:before{left:-2px}.dnd-indicator--top:after,.dnd-indicator--bottom:after{right:-2px}.dnd-indicator--top:before,.dnd-indicator--top:after{top:-2px}.dnd-indicator--bottom:before,.dnd-indicator--bottom:after{bottom:-2px}.dnd-indicator--left,.dnd-indicator--right{width:var(--dnd-line);height:100%;top:0}.dnd-indicator--left{left:0}.dnd-indicator--right{right:0}.dnd-indicator--left:before,.dnd-indicator--left:after{left:-2px}.dnd-indicator--right:before,.dnd-indicator--right:after{right:-2px}.dnd-indicator--left:before,.dnd-indicator--right:before{top:-2px}.dnd-indicator--left:after,.dnd-indicator--right:after{bottom:-2px}
2
2
  /*$vite$:1*/
package/dist/dnd.js CHANGED
@@ -314,7 +314,7 @@ var p = class {
314
314
  let i = document.elementFromPoint(e, t);
315
315
  if (!i) return;
316
316
  let a = n || "_default", o = i;
317
- for (r && r.contains(o) && (o = r.parentElement); o;) {
317
+ if (!(r && r.contains(o) && (o = r.parentElement ?? null, !o))) for (; o;) {
318
318
  if (this.isDrop(o) && this.getNamespace(o) === a) return o;
319
319
  o = o.parentElement;
320
320
  }
@@ -397,7 +397,7 @@ var p = class {
397
397
  }
398
398
  #f = (e) => {
399
399
  if (!this.#n || !this.#s || e.pointerId !== this.#r) return;
400
- let t = e.getCoalescedEvents?.() ?? [e], n = t[t.length - 1], r = performance.now(), i = n.clientX, a = n.clientY;
400
+ let t = e.getCoalescedEvents?.(), n = t?.length ? t[t.length - 1] : e, r = performance.now(), i = n.clientX, a = n.clientY;
401
401
  if (i === this.#i && a === this.#a) {
402
402
  e.preventDefault();
403
403
  return;
@@ -450,7 +450,7 @@ var p = class {
450
450
  commit() {
451
451
  for (let e of this.removeOps) e?.parentNode?.removeChild?.(e);
452
452
  let e = this.removeOps.size > 0;
453
- for (let [t, n] of this.appendOps) (!e || !this.removeOps.has(t)) && n?.appendChild?.(t);
453
+ for (let [t, n] of this.appendOps) (!e || !this.removeOps.has(t) && !this.removeOps.has(n)) && n?.appendChild?.(t);
454
454
  for (let [t, n] of this.classOps) if (!e || !this.removeOps.has(t)) for (let [e, r] of n) r ? t?.classList?.add?.(e) : t?.classList?.remove?.(e);
455
455
  for (let [t, n] of this.styleOps) if (!e || !this.removeOps.has(t)) for (let [e, r] of n) r == null ? e.includes("-") ? t?.style?.removeProperty?.(e) : t?.style && (t.style[e] = "") : e.includes("-") ? t?.style?.setProperty?.(e, String(r)) : t?.style && (t.style[e] = r);
456
456
  for (let e of this.fnOps) e?.();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ptahjs/dnd",
3
- "version": "0.0.1",
3
+ "version": "0.0.2",
4
4
  "files": [
5
5
  "dist"
6
6
  ],
package/readme.md ADDED
@@ -0,0 +1,371 @@
1
+ # @ptahjs/dnd
2
+
3
+ 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.
4
+
5
+ **[Live Demo →](https://dnd.ptahjs.com/)**
6
+
7
+ ## Features
8
+
9
+ - **Pointer Events based** — unified mouse, touch, and stylus support
10
+ - **Plugin architecture** — all behaviors (mirror, drop feedback, auto-scroll, transform) are optional plugins
11
+ - **RAF-driven rendering** — per-frame measure → compute → commit pipeline prevents layout thrashing
12
+ - **Namespace support** — isolate independent drag-and-drop zones within the same page
13
+ - **Transform controller** — drag-scope mode with built-in move, resize, and rotate for canvas editors
14
+ - **Auto-scroll** — edge-triggered scrolling during drag
15
+ - **Zero dependencies** — no runtime dependencies
16
+
17
+ ## Installation
18
+
19
+ ```bash
20
+ npm install @ptahjs/dnd
21
+ ```
22
+
23
+ ## Quick Start
24
+
25
+ ```js
26
+ import { Dnd, MirrorService, DropService } from '@ptahjs/dnd'
27
+ import '@ptahjs/dnd/style'
28
+
29
+ const dnd = new Dnd({ root: '#container' })
30
+ dnd.use(new MirrorService())
31
+ dnd.use(new DropService())
32
+
33
+ dnd.on('dragstart', (payload) => console.log('drag started', payload))
34
+ dnd.on('drop', (payload) => console.log('dropped', payload))
35
+ dnd.on('cancel', (payload) => console.log('cancelled', payload))
36
+ ```
37
+
38
+ ## HTML Markup
39
+
40
+ Use data attributes to declare draggable elements and drop targets:
41
+
42
+ ```html
43
+ <!-- Root container -->
44
+ <div id="container">
45
+
46
+ <!-- Drop target -->
47
+ <div drop>
48
+ <!-- Draggable element -->
49
+ <div drag data-data='{"id":1,"type":"card"}'>Drag me</div>
50
+ </div>
51
+
52
+ <!-- Combined draggable + drop target -->
53
+ <div dragdrop data-namespace="list-a">Item</div>
54
+
55
+ </div>
56
+ ```
57
+
58
+ | Attribute | Description |
59
+ |---|---|
60
+ | `drag` | Marks an element as draggable |
61
+ | `drop` | Marks an element as a drop target |
62
+ | `dragdrop` | Element is both draggable and a drop target |
63
+ | `drag-handle` | Restricts drag to a specific handle element inside the draggable |
64
+ | `data-namespace` | Groups draggable/drop elements — only elements in the same namespace interact |
65
+ | `data-data` | JSON data payload attached to the draggable |
66
+ | `copy` | Creates a copy on drop instead of moving |
67
+ | `drag-scope` | Defines a canvas-style scope for `TransformControllerService` |
68
+ | `drop-indicator` | Enables directional drop indicator (`top`, `right`, `bottom`, `left`, or `all`) |
69
+ | `ignore-mirror` | Dragging this element does not create a mirror clone |
70
+ | `ignore-click` | Clicks on this element do not affect active selection |
71
+ | `resizable` / `rotatable` | Per-element toggle for resize/rotate handles (defaults to `true` within drag-scope) |
72
+ | `scale-ratio` | Canvas zoom ratio applied to pointer coordinates in drag-scope mode |
73
+
74
+ ## API
75
+
76
+ ### `new Dnd(config?)`
77
+
78
+ Creates a DnD instance.
79
+
80
+ | Option | Type | Default | Description |
81
+ |---|---|---|---|
82
+ | `root` | `HTMLElement \| string` | — | Root container element or CSS selector |
83
+ | `threshold` | `number` | `3` | Pixel distance before drag is considered started |
84
+
85
+ ### Instance methods
86
+
87
+ ```js
88
+ // Register a plugin
89
+ dnd.use(plugin)
90
+
91
+ // Set or replace the root container
92
+ dnd.setRoot(element)
93
+
94
+ // Override drop permission (called every frame)
95
+ dnd.canDrop = (payload) => payload.data.type !== 'locked'
96
+
97
+ // Custom mirror rendering
98
+ dnd.renderMirror = (ctx) => {
99
+ const el = document.createElement('div')
100
+ el.textContent = 'Custom mirror'
101
+ return el
102
+ }
103
+
104
+ // Event listeners
105
+ dnd.on('dragstart', handler)
106
+ dnd.on('drag', handler)
107
+ dnd.on('drop', handler)
108
+ dnd.on('cancel', handler)
109
+ dnd.off('drop', handler)
110
+
111
+ // Clean up everything
112
+ dnd.destroy()
113
+ ```
114
+
115
+ ### `dnd.monitor`
116
+
117
+ The current drag session object. Read-only during an active drag.
118
+
119
+ | Property | Description |
120
+ |---|---|
121
+ | `active` | Whether a drag session is active (pointer is down) |
122
+ | `started` | Whether the drag threshold has been exceeded |
123
+ | `x`, `y` | Current pointer coordinates |
124
+ | `dx`, `dy` | Delta from drag start |
125
+ | `sourceEl` | The element being dragged |
126
+ | `handleEl` | The handle element that was grabbed |
127
+ | `currentDrop` | The drop target currently under the pointer |
128
+ | `currentAllowed` | Whether `canDrop` returned `true` for the current target |
129
+ | `currentDropRect` | Bounding rect of the current drop target |
130
+ | `indicatorRegion` | Active drop indicator direction (`top` / `right` / `bottom` / `left`) |
131
+ | `data` | Parsed data from the draggable's `data-data` attribute |
132
+ | `namespace` | Namespace of the drag session |
133
+ | `isCopy` | Whether the draggable has the `copy` attribute |
134
+
135
+ ## Events
136
+
137
+ All event handlers receive a payload object.
138
+
139
+ ```js
140
+ dnd.on('dragstart', ({ source, data, namespace }) => { /* ... */ })
141
+ dnd.on('drag', ({ source, currentDrop, currentAllowed, x, y }) => { /* ... */ })
142
+ dnd.on('drop', ({ source, currentDrop, indicatorRegion, data }) => { /* ... */ })
143
+ dnd.on('cancel', ({ source, data }) => { /* ... */ })
144
+ ```
145
+
146
+ ## Built-in Plugins (Services)
147
+
148
+ All services are optional. Register them with `dnd.use(new ServiceName())`.
149
+
150
+ ### `MirrorService`
151
+
152
+ Creates a floating clone of the dragged element that follows the pointer. Adds `dnd-dragging` class to the source element during drag.
153
+
154
+ ```js
155
+ dnd.use(new MirrorService())
156
+ ```
157
+
158
+ The clone can be customized via `dnd.renderMirror`. To disable the mirror for a specific element, add the `ignore-mirror` attribute to it.
159
+
160
+ ### `DropService`
161
+
162
+ Maintains `currentDrop`, `currentDropRect`, and `currentAllowed` on the session. Applies `dnd-canDrop` or `dnd-noDrop` CSS class to the active drop target.
163
+
164
+ ```js
165
+ dnd.use(new DropService())
166
+ ```
167
+
168
+ ### `DropIndicatorService`
169
+
170
+ Shows a directional insertion indicator (top/right/bottom/left) on the active drop target. Requires the drop target to have a `drop-indicator` attribute.
171
+
172
+ ```js
173
+ dnd.use(new DropIndicatorService())
174
+ ```
175
+
176
+ ```html
177
+ <!-- Enable all four directions -->
178
+ <div drop drop-indicator>...</div>
179
+
180
+ <!-- Enable specific directions only -->
181
+ <div drop drop-indicator="top bottom">...</div>
182
+ ```
183
+
184
+ The indicator element has class `dnd-indicator` and toggles `dnd-indicator--top`, `dnd-indicator--right`, `dnd-indicator--bottom`, `dnd-indicator--left` direction classes.
185
+
186
+ ### `AutoScrollService`
187
+
188
+ Automatically scrolls the nearest scrollable ancestor (or window) when the pointer approaches the edge of the container during drag.
189
+
190
+ ```js
191
+ dnd.use(new AutoScrollService({
192
+ edge: 48, // Edge trigger distance in px (default: 48)
193
+ minSpeed: 180, // Minimum scroll speed in px/s (default: 180)
194
+ maxSpeed: 600, // Maximum scroll speed in px/s (default: 600)
195
+ allowWindowScroll: true // Allow scrolling window (default: true)
196
+ }))
197
+ ```
198
+
199
+ ### `ActiveSelectionService`
200
+
201
+ Tracks the selected (active) draggable per namespace. Adds `dnd-active` class to the selected element. Clicking outside clears the selection.
202
+
203
+ Within a `drag-scope` container, selecting an element injects resize/rotate handles into it.
204
+
205
+ ```js
206
+ dnd.use(new ActiveSelectionService())
207
+ ```
208
+
209
+ ### `TransformControllerService`
210
+
211
+ Enables move, resize, and rotate within a `drag-scope` container. Designed for canvas editors with elements that use CSS `transform` for positioning.
212
+
213
+ ```js
214
+ dnd.use(new TransformControllerService({
215
+ resizable: true,
216
+ rotatable: true,
217
+ boundary: true, // Constrain movement within drag-scope
218
+ scaleRatio: 1, // Canvas zoom ratio (can also be set via data-scale-ratio)
219
+ snapToGrid: false,
220
+ gridX: 10,
221
+ gridY: 10,
222
+ aspectRatio: undefined, // Lock aspect ratio during resize
223
+ minWidth: 10,
224
+ minHeight: 10,
225
+ maxWidth: 0, // 0 = no limit
226
+ maxHeight: 0,
227
+ rotateSnap: false,
228
+ rotateStep: 15, // Snap angle in degrees
229
+ snap: true, // Snap-to-element alignment
230
+ snapThreshold: 10, // Snap threshold in px
231
+ markline: true, // Show alignment guide lines
232
+ }))
233
+ ```
234
+
235
+ **Emitted events** (via `dnd.on`):
236
+
237
+ | Event | Description |
238
+ |---|---|
239
+ | `draggable:drag` | Element moved — payload includes `el`, `x`, `y`, `width`, `height`, `angle` |
240
+ | `draggable:resize` | Element resized — same payload |
241
+ | `draggable:rotate` | Element rotated — same payload |
242
+ | `draggable:drop` | Drag ended with a committed transform — includes final `x`, `y`, `width`, `height`, `angle` |
243
+
244
+ **HTML markup for drag-scope:**
245
+
246
+ ```html
247
+ <div drag-scope data-scale-ratio="1">
248
+ <div drag resizable rotatable
249
+ style="transform: translate(100px, 50px); width: 200px; height: 120px;">
250
+ Canvas element
251
+ </div>
252
+ </div>
253
+ ```
254
+
255
+ ## CSS Classes
256
+
257
+ | Class | Applied to | Description |
258
+ |---|---|---|
259
+ | `dnd-dragging` | Source element | While drag is active |
260
+ | `dnd-mirror` | Mirror clone | The floating drag ghost |
261
+ | `dnd-active` | Selected draggable | While element is selected |
262
+ | `dnd-canDrop` | Drop target | When `canDrop` returns `true` |
263
+ | `dnd-noDrop` | Drop target | When `canDrop` returns `false` |
264
+ | `dnd-indicator` | Indicator element | The drop direction indicator |
265
+ | `dnd-indicator-active` | Indicator element | When indicator is visible |
266
+ | `dnd-indicator--top/right/bottom/left` | Indicator element | Active direction |
267
+
268
+ ## Writing a Custom Plugin
269
+
270
+ A plugin is a plain object or class instance with optional lifecycle hooks. Register it with `dnd.use(plugin)`.
271
+
272
+ ```js
273
+ const myPlugin = {
274
+ order: 50, // lower = earlier execution
275
+
276
+ onAttach(dnd) {
277
+ // Called once when plugin is registered
278
+ },
279
+
280
+ onRootChange(nextRoot, prevRoot, signal) {
281
+ // Called when dnd.setRoot() is called
282
+ nextRoot.addEventListener('contextmenu', handler, { signal })
283
+ },
284
+
285
+ onDown(ctx, event) {
286
+ // Pointer down — drag not yet started
287
+ },
288
+
289
+ onStart(ctx) {
290
+ // Drag threshold exceeded, drag is now active
291
+ },
292
+
293
+ onMeasure(ctx) {
294
+ // Read-only DOM measurements (called every frame)
295
+ },
296
+
297
+ onCompute(ctx) {
298
+ // Pure calculations based on measurements (no DOM writes)
299
+ },
300
+
301
+ onCommit(ctx) {
302
+ // Write DOM changes via ctx.frame to batch updates
303
+ ctx.frame.toggleClass(element, 'my-class', true)
304
+ ctx.frame.setStyle(element, 'opacity', '0.5')
305
+ },
306
+
307
+ onAfterDrag(ctx) {
308
+ // Post-commit hook, e.g. for auto-scroll
309
+ // Return true or { scrolled: true } to request a re-render next frame
310
+ },
311
+
312
+ onEnd(ctx, meta) {
313
+ // meta.ended: true = drop, false = cancel
314
+ // meta.reason: 'pointerup' | 'blur' | 'destroy'
315
+ },
316
+
317
+ onDestroy(dnd, session) {
318
+ // Cleanup when dnd.destroy() is called
319
+ }
320
+ }
321
+
322
+ dnd.use(myPlugin)
323
+ ```
324
+
325
+ ### Context object (`ctx`)
326
+
327
+ | Property | Description |
328
+ |---|---|
329
+ | `ctx.session` | The current drag session (monitor) |
330
+ | `ctx.store` | Cross-session store (`selectedByNs` map) |
331
+ | `ctx.frame` | Frame command queue for batched DOM writes |
332
+ | `ctx.adapter` | DOM adapter for measurements and hit testing |
333
+ | `ctx.dnd` | The `Dnd` instance |
334
+ | `ctx.payload(type)` | Builds an event payload for the given event type |
335
+
336
+ ## Architecture
337
+
338
+ ```
339
+ Dnd (Facade)
340
+ ├── PointerSensor — captures pointerdown/move/up events
341
+ ├── State — finite state machine (DOWN → MOVE → END)
342
+ ├── DomAdapter — DOM queries and per-frame rect caching
343
+ ├── RafScheduler — requestAnimationFrame loop (only runs when dirty)
344
+ ├── FrameContext — batched DOM command queue for the current frame
345
+ └── PluginRuntime — ordered plugin lifecycle dispatcher
346
+ ├── MirrorService
347
+ ├── DropService
348
+ ├── DropIndicatorService
349
+ ├── AutoScrollService
350
+ ├── ActiveSelectionService
351
+ └── TransformControllerService
352
+ ```
353
+
354
+ Per-frame pipeline (while dragging):
355
+
356
+ ```
357
+ pointermove → State.dispatch(MOVE) → scheduler.request()
358
+
359
+ requestAnimationFrame
360
+
361
+ PluginRuntime.onMeasure (read DOM)
362
+ PluginRuntime.onCompute (pure math)
363
+ emit('drag', payload)
364
+ PluginRuntime.onCommit (write DOM)
365
+ FrameContext.commit()
366
+ PluginRuntime.onAfterDrag (scroll, etc.)
367
+ ```
368
+
369
+ ## License
370
+
371
+ MIT