web-dev-qa-db-fra.com

Manière recommandée de rendre React composant / div déplaçable

Je souhaite créer un composant déplaçable (c'est-à-dire repositionnable à l'aide de la souris) React, qui semble impliquer nécessairement des gestionnaires d'état globaux et dispersés. Je peux le faire de manière incorrecte, avec une variable globale dans mon fichier JS, et pourrait probablement même l'envelopper dans une interface de fermeture Nice, mais je veux savoir s'il existe un moyen qui engrène avec React mieux.

De plus, comme je n'avais jamais fait cela en JavaScript brut auparavant, j'aimerais voir comment un expert le fait, pour m'assurer que tous les cas critiques sont traités, en particulier en ce qui concerne React.

Merci.

79
Andrew Fleenor

Je devrais probablement transformer cela en un article de blog, mais voici un exemple assez solide.

Les commentaires devraient expliquer assez bien les choses, mais laissez-moi savoir si vous avez des questions.

Et voici le violon avec lequel jouer: http://jsfiddle.net/Af9Jt/2/

var Draggable = React.createClass({
  getDefaultProps: function () {
    return {
      // allow the initial position to be passed in as a prop
      initialPos: {x: 0, y: 0}
    }
  },
  getInitialState: function () {
    return {
      pos: this.props.initialPos,
      dragging: false,
      rel: null // position relative to the cursor
    }
  },
  // we could get away with not having this (and just having the listeners on
  // our div), but then the experience would be possibly be janky. If there's
  // anything w/ a higher z-index that gets in the way, then you're toast,
  // etc.
  componentDidUpdate: function (props, state) {
    if (this.state.dragging && !state.dragging) {
      document.addEventListener('mousemove', this.onMouseMove)
      document.addEventListener('mouseup', this.onMouseUp)
    } else if (!this.state.dragging && state.dragging) {
      document.removeEventListener('mousemove', this.onMouseMove)
      document.removeEventListener('mouseup', this.onMouseUp)
    }
  },

  // calculate relative position to the mouse and set dragging=true
  onMouseDown: function (e) {
    // only left mouse button
    if (e.button !== 0) return
    var pos = $(this.getDOMNode()).offset()
    this.setState({
      dragging: true,
      rel: {
        x: e.pageX - pos.left,
        y: e.pageY - pos.top
      }
    })
    e.stopPropagation()
    e.preventDefault()
  },
  onMouseUp: function (e) {
    this.setState({dragging: false})
    e.stopPropagation()
    e.preventDefault()
  },
  onMouseMove: function (e) {
    if (!this.state.dragging) return
    this.setState({
      pos: {
        x: e.pageX - this.state.rel.x,
        y: e.pageY - this.state.rel.y
      }
    })
    e.stopPropagation()
    e.preventDefault()
  },
  render: function () {
    // transferPropsTo will merge style & other props passed into our
    // component to also be on the child DIV.
    return this.transferPropsTo(React.DOM.div({
      onMouseDown: this.onMouseDown,
      style: {
        left: this.state.pos.x + 'px',
        top: this.state.pos.y + 'px'
      }
    }, this.props.children))
  }
})

Réflexions sur la propriété de l'Etat, etc.

"Qui devrait posséder quel état" est une question importante à laquelle répondre dès le départ. Dans le cas d'un composant "déplaçable", je pouvais voir quelques scénarios différents.

Scénario 1

le parent devrait posséder la position actuelle du draggable. Dans ce cas, le draggable posséderait toujours l'état "suis-je en train de faire glisser", mais appellerait this.props.onChange(x, y) chaque fois qu'un événement mousemove se produirait.

Scénario 2

le parent n'a besoin que de posséder la "position non mobile", ainsi le "glissable" posséderait sa "position de glissement", mais onmouseup appelerait this.props.onChange(x, y) et reporterait la décision finale au parent. Si le parent n'aime pas le résultat final du glissable, il ne mettra simplement pas à jour son état et le "glissable" reviendra à sa position initiale avant de glisser.

Mixin ou composant?

@ssorallen a fait remarquer que, parce que "glissable" est plus un attribut qu'une chose en soi, il pourrait mieux servir de mix. Mon expérience avec les mixins est limitée, donc je n'ai pas vu comment ils pourraient aider ou gêner dans des situations compliquées. Cela pourrait bien être la meilleure option.

94
Jared Forsyth

J'ai implémenté react-dnd, un mixin HTML5 flexible par glisser-déposer pour React = avec contrôle total du DOM.

Les bibliothèques existantes de glisser-déposer ne correspondaient pas à mon cas d'utilisation et j'ai donc écrit le mien. Il ressemble au code que nous utilisons depuis environ un an sur Stampsy.com, mais réécrit pour tirer parti de React et de Flux.

Les principales exigences que j'avais

  • N'émet aucun code DOM ou CSS propre, en le laissant aux composants consommateurs;
  • Imposer le moins possible de structure aux composants consommateurs;
  • Utilisez le glisser-déposer HTML5 en tant que serveur principal, tout en permettant d'ajouter différents moteurs dans le futur.
  • À l'instar de l'API HTML5 d'origine, mettez l'accent sur le déplacement des données et pas seulement sur les "vues déplaçables";
  • Masquer les bizarreries de l'API HTML5 dans le code consommateur;
  • Différents composants peuvent être des "sources de glisser" ou des "cibles de dépôt" pour différents types de données;
  • Autoriser un composant à contenir plusieurs sources de glissement et à supprimer des cibles en cas de besoin;
  • Faciliter le changement d'apparence des cibles de dépôt si des données compatibles sont déplacées ou survolées;
  • Facilitez l’utilisation des images pour les vignettes glissées au lieu des captures d’écran d’éléments, en contournant les bizarreries du navigateur.

Si cela vous semble familier, lisez la suite.

Usage

Drag Source simple

Commencez par déclarer les types de données pouvant être déplacés.

Ils servent à vérifier la "compatibilité" des sources de glissement et des cibles de dépôt:

// ItemTypes.js
module.exports = {
  BLOCK: 'block',
  IMAGE: 'image'
};

(Si vous n'avez pas plusieurs types de données, cette bibliothèque peut ne pas vous convenir.)

Ensuite, créons un composant glissable très simple qui, une fois glissé, représente IMAGE:

var { DragDropMixin } = require('react-dnd'),
    ItemTypes = require('./ItemTypes');

var Image = React.createClass({
  mixins: [DragDropMixin],

  configureDragDrop(registerType) {

    // Specify all supported types by calling registerType(type, { dragSource?, dropTarget? })
    registerType(ItemTypes.IMAGE, {

      // dragSource, when specified, is { beginDrag(), canDrag()?, endDrag(didDrop)? }
      dragSource: {

        // beginDrag should return { item, dragOrigin?, dragPreview?, dragEffect? }
        beginDrag() {
          return {
            item: this.props.image
          };
        }
      }
    });
  },

  render() {

    // {...this.dragSourceFor(ItemTypes.IMAGE)} will expand into
    // { draggable: true, onDragStart: (handled by mixin), onDragEnd: (handled by mixin) }.

    return (
      <img src={this.props.image.url}
           {...this.dragSourceFor(ItemTypes.IMAGE)} />
    );
  }
);

En spécifiant configureDragDrop, nous indiquons à DragDropMixin le comportement de ce composant par glisser-déposer. Les composants déplaçables et stockables utilisent le même mixin.

Dans configureDragDrop, nous devons appeler registerType pour chacun de nos composants personnalisés ItemTypes pris en charge par ce composant. Par exemple, votre application peut comporter plusieurs représentations d'images, chacune fournissant un dragSource pour ItemTypes.IMAGE.

Un dragSource n'est qu'un objet spécifiant le fonctionnement de la source de glissement. Vous devez implémenter beginDrag pour renvoyer un élément représentant les données que vous faites glisser et, éventuellement, quelques options permettant de régler l'interface utilisateur de glisser. Vous pouvez éventuellement implémenter canDrag pour interdire le glissement, ou endDrag(didDrop) pour exécuter une logique lorsque la suppression s'est produite (ou non). Et vous pouvez partager cette logique entre les composants en laissant un mixin partagé générer dragSource pour eux.

Enfin, vous devez utiliser {...this.dragSourceFor(itemType)} sur certains (un ou plusieurs) éléments de render pour attacher des gestionnaires de glissement. Cela signifie que vous pouvez avoir plusieurs "poignées de déplacement" dans un élément, et qu'elles peuvent même correspondre à différents types d'élément. (Si vous ne connaissez pas la syntaxe de Attributs JSX Spread , vérifiez-le).

Cible de largage simple

Disons que nous voulons que ImageBlock soit une cible de dépôt pour IMAGEs. C'est à peu près la même chose, sauf que nous devons donner à registerType une implémentation de dropTarget:

var { DragDropMixin } = require('react-dnd'),
    ItemTypes = require('./ItemTypes');

var ImageBlock = React.createClass({
  mixins: [DragDropMixin],

  configureDragDrop(registerType) {

    registerType(ItemTypes.IMAGE, {

      // dropTarget, when specified, is { acceptDrop(item)?, enter(item)?, over(item)?, leave(item)? }
      dropTarget: {
        acceptDrop(image) {
          // Do something with image! for example,
          DocumentActionCreators.setImage(this.props.blockId, image);
        }
      }
    });
  },

  render() {

    // {...this.dropTargetFor(ItemTypes.IMAGE)} will expand into
    // { onDragEnter: (handled by mixin), onDragOver: (handled by mixin), onDragLeave: (handled by mixin), onDrop: (handled by mixin) }.

    return (
      <div {...this.dropTargetFor(ItemTypes.IMAGE)}>
        {this.props.image &&
          <img src={this.props.image.url} />
        }
      </div>
    );
  }
);

Drag Source + Drop Target In One Component

Supposons que nous voulions maintenant que l'utilisateur puisse faire glisser une image hors de ImageBlock. Nous avons juste besoin d’ajouter dragSource approprié et quelques gestionnaires:

var { DragDropMixin } = require('react-dnd'),
    ItemTypes = require('./ItemTypes');

var ImageBlock = React.createClass({
  mixins: [DragDropMixin],

  configureDragDrop(registerType) {

    registerType(ItemTypes.IMAGE, {

      // Add a drag source that only works when ImageBlock has an image:
      dragSource: {
        canDrag() {
          return !!this.props.image;
        },

        beginDrag() {
          return {
            item: this.props.image
          };
        }
      }

      dropTarget: {
        acceptDrop(image) {
          DocumentActionCreators.setImage(this.props.blockId, image);
        }
      }
    });
  },

  render() {

    return (
      <div {...this.dropTargetFor(ItemTypes.IMAGE)}>

        {/* Add {...this.dragSourceFor} handlers to a nested node */}
        {this.props.image &&
          <img src={this.props.image.url}
               {...this.dragSourceFor(ItemTypes.IMAGE)} />
        }
      </div>
    );
  }
);

Quoi d'autre est possible?

Je n'ai pas tout couvert, mais il est possible d'utiliser cette API de plusieurs manières:

  • Utilisez getDragState(type) et getDropState(type) pour savoir si le glissement est actif et utilisez-le pour changer de classe ou d'attribut CSS.
  • Spécifiez dragPreview comme étant Image pour utiliser les images comme espaces réservés au glissement (utilisez ImagePreloaderMixin pour les charger);
  • Disons que nous voulons rendre ImageBlocks réordonnable. Nous avons seulement besoin d'eux pour implémenter dropTarget et dragSource pour ItemTypes.BLOCK.
  • Supposons que nous ajoutions d'autres types de blocs. Nous pouvons réutiliser leur logique de réorganisation en le plaçant dans un mixin.
  • dropTargetFor(...types) permet de spécifier plusieurs types à la fois, ainsi une zone de dépôt peut capturer plusieurs types différents.
  • Lorsque vous avez besoin d'un contrôle plus fin, la plupart des méthodes sont transmises à l'événement de glisser qui les a provoquées en tant que dernier paramètre.

Pour une documentation à jour et les instructions d’installation, rendez-vous à repo dnd sur Github.

61
Dan Abramov

La réponse de Jared Forsyth est terriblement erronée et dépassée. Il s'ensuit tout un ensemble d'anti-modèles tels que tilisation de stopPropagation , état d'initialisation à partir de props , utilisation de jQuery, objets imbriqués en état et ayant quelques dragging champ d'état. Si elle est réécrite, la solution sera la suivante, mais elle obligera toujours la réconciliation virtuelle du DOM à chaque tick de déplacement de souris et sera peu performante.

UPD. Ma réponse était horriblement erronée et obsolète. Désormais, le code atténue les problèmes de cycle de vie lent des composants React en utilisant des gestionnaires d’événements natifs et des mises à jour de styles, utilise transform car il n’entraîne pas de reflux et limite les modifications du DOM par requestAnimationFrame. . Maintenant, il est toujours de 60 FPS pour moi dans chaque navigateur que j'ai essayé.

const throttle = (f) => {
    let token = null, lastArgs = null;
    const invoke = () => {
        f(...lastArgs);
        token = null;
    };
    const result = (...args) => {
        lastArgs = args;
        if (!token) {
            token = requestAnimationFrame(invoke);
        }
    };
    result.cancel = () => token && cancelAnimationFrame(token);
    return result;
};

class Draggable extends React.PureComponent {
    _relX = 0;
    _relY = 0;
    _ref = React.createRef();

    _onMouseDown = (event) => {
        if (event.button !== 0) {
            return;
        }
        const {scrollLeft, scrollTop, clientLeft, clientTop} = document.body;
        // Try to avoid calling `getBoundingClientRect` if you know the size
        // of the moving element from the beginning. It forces reflow and is
        // the laggiest part of the code right now. Luckily it's called only
        // once per click.
        const {left, top} = this._ref.current.getBoundingClientRect();
        this._relX = event.pageX - (left + scrollLeft - clientLeft);
        this._relY = event.pageY - (top + scrollTop - clientTop);
        document.addEventListener('mousemove', this._onMouseMove);
        document.addEventListener('mouseup', this._onMouseUp);
        event.preventDefault();
    };

    _onMouseUp = (event) => {
        document.removeEventListener('mousemove', this._onMouseMove);
        document.removeEventListener('mouseup', this._onMouseUp);
        event.preventDefault();
    };

    _onMouseMove = (event) => {
        this.props.onMove(
            event.pageX - this._relX,
            event.pageY - this._relY,
        );
        event.preventDefault();
    };

    _update = throttle(() => {
        const {x, y} = this.props;
        this._ref.current.style.transform = `translate(${x}px, ${y}px)`;
    });

    componentDidMount() {
        this._ref.current.addEventListener('mousedown', this._onMouseDown);
        this._update();
    }

    componentDidUpdate() {
        this._update();
    }

    componentWillUnmount() {
        this._ref.current.removeEventListener('mousedown', this._onMouseDown);
        this._update.cancel();
    }

    render() {
        return (
            <div className="draggable" ref={this._ref}>
                {this.props.children}
            </div>
        );
    }
}

class Test extends React.PureComponent {
    state = {
        x: 100,
        y: 200,
    };

    _move = (x, y) => this.setState({x, y});

    // you can implement grid snapping logic or whatever here
    /*
    _move = (x, y) => this.setState({
        x: ~~((x - 5) / 10) * 10 + 5,
        y: ~~((y - 5) / 10) * 10 + 5,
    });
    */

    render() {
        const {x, y} = this.state;
        return (
            <Draggable x={x} y={y} onMove={this._move}>
                Drag me
            </Draggable>
        );
    }
}

ReactDOM.render(
    <Test />,
    document.getElementById('container'),
);

et un peu de CSS

.draggable {
    /* just to size it to content */
    display: inline-block;
    /* opaque background is important for performance */
    background: white;
    /* avoid selecting text while dragging */
    user-select: none;
}

Exemple sur JSFiddle.

17
polkovnikov.ph

react-draggable est également facile à utiliser. Github:

https://github.com/mzabriskie/react-draggable

import React, {Component} from 'react';
import ReactDOM from 'react-dom';
import Draggable from 'react-draggable';

var App = React.createClass({
    render() {
        return (
            <div>
                <h1>Testing Draggable Windows!</h1>
                <Draggable handle="strong">
                    <div className="box no-cursor">
                        <strong className="cursor">Drag Here</strong>
                        <div>You must click my handle to drag me</div>
                    </div>
                </Draggable>
            </div>
        );
    }
});

ReactDOM.render(
    <App />, document.getElementById('content')
);

Et mon index.html:

<html>
    <head>
        <title>Testing Draggable Windows</title>
        <link rel="stylesheet" type="text/css" href="style.css" />
    </head>
    <body>
        <div id="content"></div>
        <script type="text/javascript" src="bundle.js" charset="utf-8"></script>    
    <script src="http://localhost:8080/webpack-dev-server.js"></script>
    </body>
</html>

Vous avez besoin de leurs styles, qui sont courts, ou vous n'obtenez pas le comportement attendu. J'aime le comportement plus que certains des autres choix possibles, mais il y a aussi quelque chose appelé réact-redimensionnable-et-déplaçable . J'essaie de redimensionner le travail avec draggable, mais pas de joie jusqu'à présent.

10
Joseph Larson

J'ai mis à jour la solution polkovnikov.ph avec React 16/ES6) avec des améliorations telles que la manipulation tactile et la capture sur une grille, ce dont j'ai besoin pour un jeu. La capture sur une grille atténue les problèmes de performances.

import React from 'react';
import ReactDOM from 'react-dom';
import PropTypes from 'prop-types';

class Draggable extends React.Component {
    constructor(props) {
        super(props);
        this.state = {
            relX: 0,
            relY: 0,
            x: props.x,
            y: props.y
        };
        this.gridX = props.gridX || 1;
        this.gridY = props.gridY || 1;
        this.onMouseDown = this.onMouseDown.bind(this);
        this.onMouseMove = this.onMouseMove.bind(this);
        this.onMouseUp = this.onMouseUp.bind(this);
        this.onTouchStart = this.onTouchStart.bind(this);
        this.onTouchMove = this.onTouchMove.bind(this);
        this.onTouchEnd = this.onTouchEnd.bind(this);
    }

    static propTypes = {
        onMove: PropTypes.func,
        onStop: PropTypes.func,
        x: PropTypes.number.isRequired,
        y: PropTypes.number.isRequired,
        gridX: PropTypes.number,
        gridY: PropTypes.number
    }; 

    onStart(e) {
        const ref = ReactDOM.findDOMNode(this.handle);
        const body = document.body;
        const box = ref.getBoundingClientRect();
        this.setState({
            relX: e.pageX - (box.left + body.scrollLeft - body.clientLeft),
            relY: e.pageY - (box.top + body.scrollTop - body.clientTop)
        });
    }

    onMove(e) {
        const x = Math.trunc((e.pageX - this.state.relX) / this.gridX) * this.gridX;
        const y = Math.trunc((e.pageY - this.state.relY) / this.gridY) * this.gridY;
        if (x !== this.state.x || y !== this.state.y) {
            this.setState({
                x,
                y
            });
            this.props.onMove && this.props.onMove(this.state.x, this.state.y);
        }        
    }

    onMouseDown(e) {
        if (e.button !== 0) return;
        this.onStart(e);
        document.addEventListener('mousemove', this.onMouseMove);
        document.addEventListener('mouseup', this.onMouseUp);
        e.preventDefault();
    }

    onMouseUp(e) {
        document.removeEventListener('mousemove', this.onMouseMove);
        document.removeEventListener('mouseup', this.onMouseUp);
        this.props.onStop && this.props.onStop(this.state.x, this.state.y);
        e.preventDefault();
    }

    onMouseMove(e) {
        this.onMove(e);
        e.preventDefault();
    }

    onTouchStart(e) {
        this.onStart(e.touches[0]);
        document.addEventListener('touchmove', this.onTouchMove, {passive: false});
        document.addEventListener('touchend', this.onTouchEnd, {passive: false});
        e.preventDefault();
    }

    onTouchMove(e) {
        this.onMove(e.touches[0]);
        e.preventDefault();
    }

    onTouchEnd(e) {
        document.removeEventListener('touchmove', this.onTouchMove);
        document.removeEventListener('touchend', this.onTouchEnd);
        this.props.onStop && this.props.onStop(this.state.x, this.state.y);
        e.preventDefault();
    }

    render() {
        return <div
            onMouseDown={this.onMouseDown}
            onTouchStart={this.onTouchStart}
            style={{
                position: 'absolute',
                left: this.state.x,
                top: this.state.y,
                touchAction: 'none'
            }}
            ref={(div) => { this.handle = div; }}
        >
            {this.props.children}
        </div>;
    }
}

export default Draggable;
9
anyhotcountry

Je voudrais ajouter un ème scénario

La position en mouvement n'est enregistrée d'aucune façon. Pensez-y comme à un mouvement de souris - votre curseur n'est pas un composant de React, n'est-ce pas?

Tout ce que vous faites, est d’ajouter un accessoire comme "glissable" à votre composant et un flux des événements de glissement qui manipuleront le dom.

setXandY: function(event) {
    // DOM Manipulation of x and y on your node
},

componentDidMount: function() {
    if(this.props.draggable) {
        var node = this.getDOMNode();
        dragStream(node).onValue(this.setXandY);  //baconjs stream
    };
},

Dans ce cas, une manipulation DOM est une chose élégante (je n'aurais jamais pensé dire ça)

2
Thomas Deutsch