Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

Get element position in the DOM on React DnD drop?

I'm using React DnD and Redux (using Kea) to build a formbuilder. I've the drag & drop portion working just fine, and I've managed to dispatch an action when an element drops, and I render the builder afterwards using state that the dispatch changed. However, in order to render elements in the correct order, I (think I) need to save the dropped elements position relative to it's siblings but I'm unable to figure out anything that isn't absolutely insane. I've experimented with refs and querying the DOM with the unique ID (I know I shouldn't), but both approaches feel pretty terrible and do not even work.

Here's a simplified representation of my app structure:

@DragDropContext(HTML5Backend)
@connect({ /* redux things */ })
<Builder>
  <Workbench tree={this.props.tree} />
  <Sidebar fields={this.props.field}/>
</Builder>

Workbench:

const boxTarget = {
  drop(props, monitor, component) {
    const item = monitor.getItem()
    console.log(component, item.unique, component[item.unique]); // last one is undefined
    window.component = component; // doing it manually works, so the element just isn't in the DOM yet

    return {
      key: 'workbench',
    }
  },
}

@DropTarget(ItemTypes.FIELD, boxTarget, (connect, monitor) => ({
  connectDropTarget: connect.dropTarget(),
  isOver: monitor.isOver(),
  canDrop: monitor.canDrop(),
}))
export default class Workbench extends Component {
  render() {
    const { tree } = this.props;
    const { canDrop, isOver, connectDropTarget } = this.props

    return connectDropTarget(
      <div className={this.props.className}>
        {tree.map((field, index) => {
          const { key, attributes, parent, unique } = field;
          if (parent === 'workbench') { // To render only root level nodes. I know how to render the children recursively, but to keep things simple...
            return (
              <Field
                unique={unique}
                key={key}
                _key={key}
                parent={this} // I'm passing the parent because the refs are useless in the Field instance (?) I don't know if this is a bad idea or not
              />
            );
          }

          return null;
        }).filter(Boolean)}
      </div>,
    )


    // ...

Field:

const boxSource = {
  beginDrag(props) {
    return {
      key: props._key,
      unique: props.unique || shortid.generate(),
      attributes: props.attributes,
    }
  },

  endDrag(props, monitor) {
    const item = monitor.getItem()
    const dropResult = monitor.getDropResult()

    console.log(dropResult);

    if (dropResult) {
      props.actions.onDrop({
        item,
        dropResult,
      });
    }
  },
}

@connect({ /* redux stuff */ })
@DragSource(ItemTypes.FIELD, boxSource, (connect, monitor) => ({
  connectDragSource: connect.dragSource(),
  isDragging: monitor.isDragging(),
}))
export default class Field extends Component {  
  render() {
    const { TagName, title, attributes, parent } = this.props
    const { isDragging, connectDragSource } = this.props
    const opacity = isDragging ? 0.4 : 1

    return connectDragSource(
      <div
        className={classes.frame}
        style={{opacity}}
        data-unique={this.props.unique || false}
        ref={(x) => parent[this.props.unique || this.props.key] = x} // If I save the ref to this instance, how do I access it in the drop function that works in context to boxTarget & Workbench? 
      >
        <header className={classes.header}>
          <span className={classes.headerName}>{title}</span>
        </header>
      <div className={classes.wrapper}>
        <TagName {...attributes} />
      </div>
    </div>
    )
  }
}

Sidebar isn't very relevant.

My state is a flat array, consisting of objects that I can use to render the fields, so I'm reordering it based on the element positions in the DOM.

[
  {
    key: 'field_type1',
    parent: 'workbench',
    children: ['DAWPNC'], // If there's more children, "mutate" this according to the DOM
    unique: 'AWJOPD',
    attributes: {},
  },
  {
    key: 'field_type2',
    parent: 'AWJOPD',
    children: false,
    unique: 'DAWPNC',
    attributes: {},
  },
]

The relevant portion of this question revolves around

const boxTarget = {
  drop(props, monitor, component) {
    const item = monitor.getItem()
    console.log(component, item.unique, component[item.unique]); // last one is undefined
    window.component = component; // doing it manually works, so the element just isn't in the DOM yet

    return {
      key: 'workbench',
    }
  },
}

I figured I'd just get the reference to the element somehow, but it doesn't seem to exist in the DOM, yet. It's the same thing if I try to hack with ReactDOM:

 // still inside the drop function, "works" with the timeout, doesn't without, but this is a bad idea
 setTimeout(() => {
    const domNode = ReactDOM.findDOMNode(component);
    const itemEl = domNode.querySelector(`[data-unique="${item.unique}"]`);
    const parentEl = itemEl.parentNode;

    const index = Array.from(parentEl.children).findIndex(x => x.getAttribute('data-unique') === item.unique);

    console.log(domNode, itemEl, index);
  });

How do I achieve what I want?

Apologies for my inconsistent usage of semicolons, I don't know what I want from them. I hate them.

like image 795
Christian Avatar asked Nov 26 '17 19:11

Christian


1 Answers

I think the key here is realizing that the Field component can be both a DragSource and a DropTarget. We can then define a standard set of drop types that would influence how the state is mutated.

const DropType = {
  After: 'DROP_AFTER',
  Before: 'DROP_BEFORE',
  Inside: 'DROP_INSIDE'
};

After and Before would allow re-ordering of fields, while Inside would allow nesting of fields (or dropping into the workbench).

Now, the action creator for handling any drop would be:

const drop = (source, target, dropType) => ({
  type: actions.DROP,
  source,
  target,
  dropType
});

It just takes the source and target objects, and the type of drop occurring, which will then be translated into the state mutation.

A drop type is really just a function of the target bounds, the drop position, and (optionally) the drag source, all within the context of a particular DropTarget type:

(bounds, position, source) => dropType

This function should be defined for each type of DropTarget supported. This would allow each DropTarget to support a different set of drop types. For instance, the Workbench only knows how to drop something inside of itself, not before or after, so the implementation for the workbench could look like:

(bounds, position) => DropType.Inside

For a Field, you could use the logic from the Simple Card Sort example, where the upper half of the DropTarget translates to a Before drop while the lower half translates to an After drop:

(bounds, position) => {
  const middleY = (bounds.bottom - bounds.top) / 2;
  const relativeY = position.y - bounds.top;
  return relativeY < middleY ? DropType.Before : DropType.After;
};

This approach also means that each DropTarget could handle the drop() spec method in the same manner:

  • get bounds of the drop target's DOM element
  • get the drop position
  • calculate the drop type from the bounds, position, and source
  • if any drop type occurred, handle the drop action

With React DnD, we have to be careful to appropriately handle nested drop targets since we have Fields in a Workbench:

const configureDrop = getDropType => (props, monitor, component) => {
  // a nested element handled the drop already
  if (monitor.didDrop())
    return;

  // requires that the component attach the ref to a node property
  const { node } = component;
  if (!node) return;

  const bounds = node.getBoundingClientRect();
  const position = monitor.getClientOffset();
  const source = monitor.getItem();

  const dropType = getDropType(bounds, position, source);

  if (!dropType)
    return;

  const { onDrop, ...target } = props;
  onDrop(source, target, dropType);

  // won't be used, but need to declare that the drop was handled
  return { dropped: true };
};

The Component class would end up looking something like this:

@connect(...)
@DragSource(ItemTypes.FIELD, { 
  beginDrag: ({ unique, parent, attributes }) => ({ unique, parent, attributes })
}, dragCollect)
// IMPORTANT: DropTarget has to be applied first so we aren't receiving
// the wrapped DragSource component in the drop() component argument
@DropTarget(ItemTypes.FIELD, { 
  drop: configureDrop(getFieldDropType)
  canDrop: ({ parent }) => parent // don't drop if it isn't on the Workbench
}, dropCollect)
class Field extends React.Component {
  render() { 
    return (
      // ref prop used to provide access to the underlying DOM node in drop()
      <div ref={ref => this.node = ref}>
        // field stuff
      </div>
    );
}

Couple things to note:

Be mindful of the decorator order. DropTarget should wrap the component, then DragSource should wrap the wrapped component. This way, we have access to the correct component instance inside drop().

The drop target's root node needs to be a native element node, not a custom component node.

Any component that will be decorated with the DropTarget utilizing configureDrop() will require that the component set its root node's DOM ref to a node property.

Since we are handling the drop in the DropTarget, the DragSource just needs to implement the beginDrag() method, which would just return whatever state you want mixed into your application state.

The last thing to do is handle each drop type in your reducer. Important to remember is that every time you move something around, you need to remove the source from its current parent (if applicable), then insert it into the new parent. Each action could mutate the state of up to three elements, the source's existing parent (to clean up its children), the source (to assign its parent reference), and the target's parent or the target if an Inside drop (to add to its children).

You also might want to consider making your state an object instead of an array, which might be easier to work with when implementing the reducer.

{
  AWJOPD: { ... },
  DAWPNC: { ... },
  workbench: {
    key: 'workbench',
    parent: null,
    children: [ 'DAWPNC' ]
  }
}
like image 62
Robert Farley Avatar answered Nov 02 '22 22:11

Robert Farley