Create a gist now

Instantly share code, notes, and snippets.

What would you like to do?
Work in progress: a view-agnostic Mithril drag and drop component using James Forbes' pointer stream API

Dragm

A drag & drop Mithril component. Stream-based, pointer-agnostic, view-agnostic.

npm i -s dragm
import Drag from 'dragm'
// or
const Drag = require('dragm')

or

<script src="//unkpg.com/dragm"></script>
<script>
m.mount(document.body, {
  view: () => [
    Drag({
      // Bind functions to drag & drop streams!
      drop: () => 
        alert('You got the drop on me'),
      
      // Bring your own view!
      view: ({state: {drag}}) =>
        m('.draggable', {
          // Interpolate drag streams internally!
          style: {color: drag() ? 'green' : 'inherit'} 
        }, 
          drag() ? 'Dragging!' : 'Drag me?'
        )
    })
  ]
})
</script>

Stream-based

Dragm is a Mithril interface to @JAForbes' excellent pointer-stream library. It provides streams which update when drag & drop events occur on the component. You don't need to bring any tooling to make use of this - it's all included and as simple as you like. The underlying stream implementation is Flyd, which is fully interoperable with Mithril streams thanks to fantasy-land applicative compliance.

Pointer-agnostic

Works with touch and mouse, bypassing the inconsistent HTML5 drag & drop API. Currently, dragm provides streams for:

  • drag: stream Boolean - which tells you whether the component is being dragged or not. It emits when dragging starts and ends.
  • drop: stream undefined - which tells you when the component has been dropped onto. It emits whenever the drop occurs. Note that the value of this stream is useless - the significance is if & when it fires.

View-agnostic

No view constraints, no DOM structure impositions. Dragm allows you to provide the view as an attribute, allowing you to define your own virtual DOM logic using the provided vnode state's drag & drop streams; alternatively / additionally, you can pass in drag & drop streams as attributes to update your upstream model, and provide children as you would with a regular component.

This mostly depends on whether you have a centralised application state model like Redux and keep your components pure - in which case you'll be wanting to define actions to bind to Dragm's streams and provide children based on your pre-existing model; or if you separate your application model into its constituent components - in which case you can provide a view which can access the component's state, thereby allowing you to interpolate the drag & drop streams there.

View-less

// For centralised application models & stateless views
const CustomDragItem = {
  // Where drag and drop are your action triggers, and dragging is your data
  view : ({attrs: {drag, drop, dragging, text}}) =>
    // These drag components bind to your actions
    m(Drag, {drop},
      m('.drop-area',
        m(Drag, {drag},
          // Seeing as all our state data is already available from our store,
          // we can interpolate it ahead of time & pass the subtree in as children
          m('.drag-handle',{
            class: dragging ? 'dragging' : 'resting'
          }
            m('.title', text),

            'Drag here'
          )
        ),
        
        'Drop here'
      )
    )
}

View-applied

// For stateful, decentralised component architecture
const CustomDragItem = {
  // Our model is unaware of Dragm, and doesn't provide custom actions:
  // We just get text & determine the import of Dragm's API internally.  
  view : ({state, attrs: {text}}) =>
    m(Drag, {
      drop(){
        state.dropped = true
      }
    },
      m('.drop-area',
        m(Drag, {
          drag(dragging){
            state.dropped = (
                dragging
              ? undefined
              : false
            )
          },
          
          // Instead of passing the subtree as children,
          // We provide a view to read from Dragm's internal state
          view: ({state: {drag}}) =>
            m('.drag-handle',{
              class: drag() ? 'dragging' : 'resting'
            },

              m('.title', text),

              'Drag here'
            )
        }),
        
          this.dropped === true
        ? 'Dropped here!'
        : this.dropped === false
        ? 'Missed!'
        : 'Drop here...'
      )
    )
}
import m from 'mithril'
import {stream} from 'flyd'
import PointerStream from 'pointer-stream'
// The component state object is provided wholesale for the author API, therefore we store our internal API state in a Map
const= new Map()
// Bind a temporal vnode to the private store associated with its persistent state
// This allows us to reliably access the last render's dom and attribute data
function update(vnode){
書.get(vnode.state).vnode = vnode
}
export default const Drag = {
// Persist vnode data on every draw
oncreate: update,
onupdate: update,
oninit(vnode){
// Set up our private state to correspond with this component instance
const {state} = vnode
const= {}
書.set(state,秘)
update(vnode)
// Set up the component API streams: drag & drop
;['drag', 'drop'].map(key => {
// A private stream that our internal API depends on
秘[key] = stream(false)
// The public API derived from it
state[key] = stream(false)
// When the private stream updates
秘[key].map(value => {
// Update the internal public stream
state[key](value)
// If receiver streams of the same key were provided as attributes, update them too
if(秘.vnode.attrs[key])
秘.vnode.attrs[key](value)
})
})
// Create a pointer stream
秘.pointer = PointerStream()
// When its drag status changes…
秘.pointer.dragging.on(dragging => {
// We can't work without the DOM
if(!秘.vnode.dom)
return
// Were we previously dragging?
const dragmode = 秘.drag()
// Where did the event take place?
const {x, y} = 秘.pointer.coords()
// Did the event occur on this component's DOM?
const targeted = nodes(秘.vnode).find(el => el.contains(document.elementFromPoint(x, y)))
// We weren't but are now dragging
if(!dragmode && dragging && targeted)
m.redraw(秘.drag(true))
// We were but are no longer dragging
else if(dragmode && !dragging)
m.redraw(秘.drag(false))
// We ended a drag and did so here
if(!dragging && targeted)
m.redraw(秘.drop(true))
})
},
// Drag is view agnostic and provides none of its own:
// If a view was supplied as an attribute, we pass the vnode to it so that the state streams can be read from within
// Otherwise we simply render the children: stream data can be read by passing in receiving streams as attributes with corresponding keys
view({children, attrs: {view = () => children}}){
return view.apply(this, arguments)
},
// Clean up state
onremove({state,attrs}){
// Retrieve private state
const= 書.get(state)
書.delete(state)
// Kill the pointer
秘.pointer.end(true)
// For each API stream
;['drag', 'drop'].map(key => {
// Kill the private stream
秘[key].end(true)
// …the public stream
state[key].end(true)
// …and the supplied stream, if provided
if(attrs[key] && attrs[key].end)
attrs[key].end(true)
})
}
}
// Return the whole sequence of DOM nodes represented by a given vnode
function nodes({dom,domSize}){
const nodes = [dom]
while(nodes.length < domSize)
nodes.push(nodes[nodes.length-1].nextSibling)
return nodes
}
import m from 'mithril'
import {stream} from 'flyd'
import PointerStream from 'pointer-stream'
// The component state object is provided wholesale for the author API, therefore we store our internal API state in a Map
const= new Map()
// Bind a temporal vnode to the private store associated with its persistent state
// This allows us to reliably access the last render's dom and attribute data
function update(vnode){
書.get(vnode.state).vnode = vnode
}
export default const Drag = {
// Persist vnode data on every draw
oncreate: update,
onupdate: update,
oninit(vnode){
// Set up our private state to correspond with this component instance
const {state} = vnode
const= {}
書.set(state,秘)
update(vnode)
// Set up the component API streams: drag & drop
;['drag', 'drop'].map(key => {
// A private stream that our internal API depends on
秘[key] = stream(false)
// The public API derived from it
state[key] = stream(false)
// When the private stream updates
秘[key].map(value => {
// Update the internal public stream
state[key](value)
// If receiver streams of the same key were provided as attributes, update them too
if(秘.vnode.attrs[key])
秘.vnode.attrs[key](value)
})
})
// Create a pointer stream
秘.pointer = PointerStream()
// When its drag status changes…
秘.pointer.drag.on(dragging => {
// Were we previously dragging?
const dragmode = 秘.drag()
// Where did the event take place?
const {x, y} = 秘.pointer.coords()
// Did the event occur on this component's DOM?
const targeted = nodes(this.vnode).find(el => el.contains(document.elementFromPoint(x, y)))
// We weren't but are now dragging
if(!dragmode && dragging && targeted){
秘.drag(true)
m.redraw()
}
// We were but are no longer dragging
else if(dragmode && !dragging){
秘.drag(false)
m.redraw()
}
// We ended a drag and did so here
if(!dragging && targeted){
秘.drop(true)
m.redraw()
}
})
},
// Drag is view agnostic and provides none of its own:
// If a view was supplied as an attribute, we pass the vnode to it so that the state streams can be read from within
// Otherwise we simply render the children: stream data can be read by passing in receiving streams as attributes with corresponding keys
view({children, attrs: {view = () => children}}){
return view.apply(this, arguments)
},
// Clean up state
onremove({state,attrs}){
// Retrieve private state
const= 書.get(state)
書.delete(state)
// Kill the pointer
秘.pointer.end(true)
// For each API stream
;['drag', 'drop'].map(key => {
// Kill the private stream
秘[key].end(true)
// …the public stream
state[key].end(true)
// …and the supplied stream, if provided
if(attrs[key] && attrs[key].end)
attrs[key].end(true)
})
}
}
// Return the whole sequence of DOM nodes represented by a given vnode
function nodes({dom,domSize}){
const nodes = [dom]
while(nodes.length < domSize)
nodes.push(nodes[nodes.length-1].nextSibling)
return nodes
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment