Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Feature Proposal: Add global handlers for start, finish and cancel drag #186

Open
davestewart opened this issue May 3, 2021 · 2 comments

Comments

@davestewart
Copy link
Contributor

davestewart commented May 3, 2021

Hey @kutlugsahin,

I'm busy on a new slew of features, and have added additional container / container types to my app which has highlighted the lack of global event handlers.

Setup

Lets's say I have 3 sections in my app:

  • A, B, C
  • each of which has 1 or more containers + draggables
  • each of which can be dragged to each other

Outside of the containers, there might be other parts of the app which might want to know if there is drag and drop:

  • is dragging (say, dim the background)
  • available drop targets (say, show a list)

Problem

Right now:

  • the only way to detect if a drag has started, is to add one of the callbacks get-child-payload or should-accept-drop
  • the only way to detect if a drag has finished, is to count the number of containers in via should-accept-drop then count the number of containers out using @drop

Additionally, there are issues using local code in multiple containers:

Approach

The easiest way I have found to do this is to provide global handler decorators:

let count = 0

export function makePayloadHandler (getChildPayload: Function) {
  return function (...args) {
    startDrag() // global handler
    return getChildPayload.call(this, ...args)
  }
}

export function makeAcceptHandler (handler: Function) {
  return function (...args) {
    const accept = handler.call(this, ...args)
    if (accept) {
      count++ // this will be counted down again in makeDropHandler(); at 0, all drops have fired
    }
    return accept
  }
}

export function makeDropHandler (handler: Function) {
  return function (event: DropResult, ...args) {
    // reduce the count
    const complete = --count === 0

    // call each drop handler
    handler.call(this, event, ...args)

    // if complete, clean up
    if (complete) {
      endDrag() // global handler
    }
  }
}

In the component:

// C should accept items from A and B
shouldAcceptDrop: makeAcceptHandler(function (options: ContainerProps, payload: DragContext) {
  return ['A', 'B'].includes(payload.type)
})

This works, but it is a lot of extra code, that feels like it could / should be in the library.

Proposal

I suggest two API changes, to manage local and global events:

Global events

Something like:

import smoothDnD from 'smooth-dnd'

smoothDnD
  .on('start', onStart)
  .on('finish', onFinish)
  .on('cancel', onCancel)

The dispatched events could contain some basic information about the drag.

This would allow any components outside of the components which contain the containers, to monitor drags.

In Vue, you could also provide a DragObserver component like so:

<DragObserver v-slot="{ isDragging, numItems }">
  <div v-if="isDragging" class="alert">{{ numItems }} are being dragged right now</div>
</DragObserver>

Vee Validate does this to wrap forms for validation, and it works great.

I'm not sure what the equivalent would be in React, but I'm sure there is one.

Another Vue option would be to export a Vue.observable and / or provide plugin code from the core:

import Vue from 'vue`

export const state = Vue.observable({
  isDragging: false,
  numItems: 0,
})

Vue.prototype.$drag = state

This then be used to get access to the state, add classes on any other containers, etc, etc:

<div :data-drag="$drag.isDragging"> ... </div>

Local handlers

In the same component, a developer could use the Observer component as above, or they could just use handlers:

<Container
  @start="startAll"
  @finish="finishAll"
  @cancel="cancelDrag">
    ...
  </Container>

I'm not sure about the names; I can also think of variations such as before, after, complete, etc, but they would fire before all containers were queried, and after all drops were complete.

I mention this also in #185

Wrap up

I hope that all makes sense.

I think the use cases are clear, and I think I'm presented POC code which hopefully would give you ideas on how to implement in the core.

Would love to chat more about this!

@davestewart
Copy link
Contributor Author

davestewart commented May 4, 2021

OK, further to writing this yesterday (and attempting to refactor my helper function to do something like my suggestion in #185 and use an extended version of DragResult) I've found a major use case, which is actually pretty important.

Outline

It's to do with APIs that require a single "move" call, vs a "remove" and then an "add" (or "add" then "remove").

The browser Extensions API is one; it requires that Tabs and Bookmarks be moved using a specific move() call (I'm sure there are other examples out there).

To start with, here is a basic algorithm to detect the type of action:

const action: DragAction | null = removedIndex !== null && addedIndex !== null
  ? removedIndex !== addedIndex
    ? 'order'
    : null
  : addedIndex !== null
    ? 'add'
    : removedIndex !== null
      ? 'remove'
      : null

Because of the way Smooth DnD works by calling each container's drop handler in turn, there is no way to know if a "move" has taken place.

Items in the same container (works)

Take a single container with two items, and we want to drag A to B's position:

+---------+       +---------+
| +-----+ |       | +-----+ |
| |  A  | |   |   | |  B  | |
| +-----+ |   |   | +-----+ |
| +-----+ |   |   | +-----+ |
| |  B  | |   v   | |  A  | |
| +-----+ |       | +-----+ |
+---------+       +---------+

Because there is only one container, the drop event fires only once, and we can detect an "order" (effectively a "move") because both addedIndex and removedIndex will both exist.

Perfect! We call move:

if (action === 'order') {
  chrome.tabs.move(...)
}

Items in different containers (can't work)

Now let's look at two containers where we want to move one item to the other container:

+---------+      +---------+
| +-----+ |      |         |
| |  A  |---------->       |
| +-----+ |      |         |
+---------+      +---------+


+---------+      +---------+
|         |      | +-----+ |
|         |      | |  A  | |
|         |      | +-----+ |
+---------+      +---------+

In this case, two drop events will fire, each one without the knowledge of the other:

// container 1
if (action === 'remove') {
  chrome.tabs.remove(...)
}
// container 2
if (action === 'add') {
  chrome.tabs.create(...)
}

But, EEEK! – by running separate remove and create operations we destroy tabs in one window, then re-create them in a second window; the user loses their form, history, etc, etc.

Because there was no way for the remove event to know that there was an add event coming (or to know that this was a "move" and not a "remove" and then an "add") we are unable to call the right API, which in Chrome's case is move:

chrome.tabs.move(tab, index, windowId)

The multiple components problem

The reason this has been working (for me) up to now, is because of my previous helper function which provides additional context (of the removes and adds) at the end of all calls.

This works fine if you just have a simple Kanban board, with all operations in one component, because if you have 3 containers, you only take action on the 3rd call (you don't do anything in the first 2 calls):

+-------------------------+
| +-----+ +-----+ +-----+ |
| |  1  | |  2  | |  3  | |
| +-----+ +-----+ +-----+ |
+-------------------------+

Only on call 3 when you know also what happened with 1 and 2, do you decide what to do.

However, as soon as you have multiple, separate components (which I presume SmoothDnD was specifically designed to work with because of the "calling each container" approach) and you try to use my previous technique, only the last container of all containers gets all the information:

Component A
+-------------------------+
| +-----+ +-----+ +-----+ |
| |  1  | |  2  | |  3  | |
| +-----+ +-----+ +-----+ |
+-------------------------+

Component B
+-------------------------+
| +-----+ +-----+ +-----+ |
| |  A  | |  B  | |  C  | |
| +-----+ +-----+ +-----+ |
+-------------------------+

Component C
+-------------------------+
| +-----+ +-----+ +-----+ |
| |  X  | |  Y  | |  Z  | |
| +-----+ +-----+ +-----+ |
+-------------------------+

Drop events will fire (I think) in order for 1 2 3 A B C X Y Z and so only Z which is "last" can ever know the status of all events.

If you had moved an item from 1 to A then container 1 would not have known it was a move, and would see it as a remove.

Additionally, Component 3 may have logic for items only of type X Y Z so may not know how to move a 1 to an A.

In fact this last point helps to make the case for a 100% global handler, which could marshall all containers (or perhaps a global store) and decide what should happen. In the case of something like Vuex or Redux, this would be really useful.

Conclusion

What would allow all containers to decide what they are doing would be a final call to each set, with the complete picture of the adds and removes, so they can decide to do a "move" rather than separate "add" and "remove" calls, so:

  • all events are logged 1 2 3 A B C X Y Z
  • smooth DnD works out what happened
  • Components A, B and C each get a call with the final info.

Workarounds

I have a couple of ideas how to solve this right now:

Debounce drop events

For each component, in the drop handler, collect the results of any drag, then debounce the actual update:

onDrop (event) {
  this.events.push(event)
  this.onDropComplete()
}

onDropComplete: debounce (function() {
  // get events
  // decide what to do
}, 0)

The complete handler would fire once per group, so:

  • a move within the group could call "move"
  • a move outside the group would result in a "remove" in one group and an "add" in the other
  • the payload would be the same

Give the drop handler factory more information

In the global drop handler factory, pass additional information about the container, then use this to return to each component when all results are in for its group. In the case above, drop handlers for 3, C and Z would get the information:

onDrop: makeDropHandler('windows', function (event, ...) {
  if (event.isLastInGroup) {
    if (event.action === 'move') {
    // move
  }
})

Does all this make sense?

@davestewart
Copy link
Contributor Author

davestewart commented May 5, 2021

OK! So I've implemented all this over the top of Vue Smooth DnD.

It actually wasn't too bad! I have all the support I require, so:

Global handlers:

  • onStart
  • onCancel (Esc cancels)
  • onFinish

Component-level handlers:

  • onPayload
  • onAccept
  • onLeave
  • onEnter
  • onDrop (once per container)
  • onComplete (once per container group)

Types:

  • DropEvent (same as base plus)

    • action: 'order', 'add', 'remove', 'move'
    • droppedOutside: whether the item was dropped outside
  • DropContext

    • group: name of the group the payload came from
    • data: optional data
    • id: optional id (instead of data)
  • DropPayload

    • type: same as group name, so you can work with shouldAcceptDrop
    • data: whatever you need to pass

Global state (provided as a plugin, watchable from anywhere):

  • isDragging
  • isOutside
  • payload
  • from
  • to

Rather pleased with this, and it's cleaned up my code no-end!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

No branches or pull requests

1 participant