Source: control/modify.js

import { Modify, Interaction } from 'ol/interaction';
import { singleClick } from 'ol/events/condition';
import throttle from 'lodash.throttle';
import Control from './control';
import image from '../../img/modify_geometry2.svg';
import SelectMove from '../interaction/selectmove';
import SelectModify from '../interaction/selectmodify';
import Move from '../interaction/move';
import Delete from '../interaction/delete';

/**
 * Control for modifying geometries.
 * @extends {Control}
 * @alias ole.ModifyControl
 */
class ModifyControl extends Control {
  /**
   * @param {Object} [options] Tool options.
   * @param {number} [options.hitTolerance=5] Select tolerance in pixels.
   * @param {ol.Collection<ol.Feature>} [options.features] Destination for drawing.
   * @param {ol.source.Vector} [options.source] Destination for drawing.
   * @param {Object} [options.selectMoveOptions] Options for the select interaction used to move features.
   * @param {Object} [options.selectModifyOptions] Options for the select interaction used to modify features.
   * @param {Object} [options.moveInteractionOptions] Options for the move interaction.
   * @param {Object} [options.modifyInteractionOptions] Options for the modify interaction.
   * @param {Object} [options.deleteInteractionOptions] Options for the delete interaction.
   * @param {Object} [options.deselectInteractionOptions] Options for the deselect interaction. Default: features are deselected on click on map.
   * @param {Function} [options.cursorStyleHandler] Options to override default cursor styling behavior.
   */
  constructor(options = {}) {
    super({
      title: 'Modify geometry',
      className: 'ole-control-modify',
      image,
      ...options,
    });

    /**
     * Buffer around the coordintate clicked in pixels.
     * @type {number}
     * @private
     */
    this.hitTolerance =
      options.hitTolerance === undefined ? 5 : options.hitTolerance;

    /**
     * Filter function to determine which features are elligible for selection.
     * By default we exclude features on unmanaged layers(for ex: nodes to delete).
     * @type {function(ol.Feature, ol.layer.Layer)}
     * @private
     */
    this.selectFilter =
      options.selectFilter ||
      ((feature, layer) => {
        if (layer && this.layerFilter) {
          return this.layerFilter(layer);
        }
        return !!layer;
      });

    /**
     *
     * Return features elligible for selection on specific pixel.
     * @type {function(ol.events.MapBrowserEvent)}
     * @private
     */
    this.getFeatureAtPixel = this.getFeatureAtPixel.bind(this);

    /* Cursor management */
    this.previousCursor = null;
    this.cursorTimeout = null;
    this.cursorHandler = throttle(this.cursorHandler.bind(this), 150, {
      leading: true,
    });
    this.cursorStyleHandler =
      options?.cursorStyleHandler || ((cursorStyle) => cursorStyle);

    /* Interactions */
    this.createSelectMoveInteraction(options.selectMoveOptions);
    this.createSelectModifyInteraction(options.selectModifyOptions);
    this.createModifyInteraction(options.modifyInteractionOptions);
    this.createMoveInteraction(options.moveInteractionOptions);
    this.createDeleteInteraction(options.deleteInteractionOptions);
    this.createDeselectInteraction(options.deselectInteractionOptions);
  }

  /**
   * Create the interaction used to select feature to move.
   * @param {*} options
   * @private
   */
  createSelectMoveInteraction(options = {}) {
    /**
     * Select interaction to move features.
     * @type {ol.interaction.Select}
     * @private
     */
    this.selectMove = new SelectMove({
      filter: (feature, layer) => {
        // If the feature is already selected by modify interaction ignore the selection.
        if (this.isSelectedByModify(feature)) {
          return false;
        }
        return this.selectFilter(feature, layer);
      },
      hitTolerance: this.hitTolerance,
      ...options,
    });

    this.selectMove.getFeatures().on('add', () => {
      this.selectModify.getFeatures().clear();
      this.moveInteraction.setActive(true);
      this.deleteInteraction.setFeatures(this.selectMove.getFeatures());
    });

    this.selectMove.getFeatures().on('remove', () => {
      // Deactive interaction when the select array is empty
      if (this.selectMove.getFeatures().getLength() === 0) {
        this.moveInteraction.setActive(false);
        this.deleteInteraction.setFeatures();
      }
    });
    this.selectMove.setActive(false);
  }

  /**
   * Create the interaction used to select feature to modify.
   * @param {*} options
   * @private
   */
  createSelectModifyInteraction(options = {}) {
    /**
     * Select interaction to modify features.
     * @type {ol.interaction.Select}
     */
    this.selectModify = new SelectModify({
      filter: this.selectFilter,
      hitTolerance: this.hitTolerance,
      ...options,
    });

    this.selectModify.getFeatures().on('add', () => {
      this.selectMove.getFeatures().clear();
      this.modifyInteraction.setActive(true);
      this.deleteInteraction.setFeatures(this.selectModify.getFeatures());
    });

    this.selectModify.getFeatures().on('remove', () => {
      // Deactive interaction when the select array is empty
      if (this.selectModify.getFeatures().getLength() === 0) {
        this.modifyInteraction.setActive(false);
        this.deleteInteraction.setFeatures();
      }
    });
    this.selectModify.setActive(false);
  }

  /**
   * Create the interaction used to move feature.
   * @param {*} options
   * @private
   */
  createMoveInteraction(options = {}) {
    /**
     * @type {ole.interaction.Move}
     * @private
     */
    this.moveInteraction = new Move({
      features: this.selectMove.getFeatures(),
      ...options,
    });

    this.moveInteraction.on('movestart', (evt) => {
      this.editor.setEditFeature(evt.feature);
      this.isMoving = true;
    });

    this.moveInteraction.on('moveend', () => {
      this.editor.setEditFeature();
      this.isMoving = false;
    });
    this.moveInteraction.setActive(false);
  }

  /**
   * Create the interaction used to modify vertexes of features.
   * @param {*} options
   * @private
   */
  createModifyInteraction(options = {}) {
    /**
     * @type {ol.interaction.Modify}
     * @private
     */
    this.modifyInteraction = new Modify({
      features: this.selectModify.getFeatures(),
      deleteCondition: singleClick,
      ...options,
    });

    this.modifyInteraction.on('modifystart', (evt) => {
      this.editor.setEditFeature(evt.features.item(0));
      this.isModifying = true;
    });

    this.modifyInteraction.on('modifyend', () => {
      this.editor.setEditFeature();
      this.isModifying = false;
    });
    this.modifyInteraction.setActive(false);
  }

  /**
   * Create the interaction used to delete selected features.
   * @param {*} options
   * @private
   */
  createDeleteInteraction(options = {}) {
    /**
     * @type {ol.interaction.Delete}
     * @private
     */
    this.deleteInteraction = new Delete({ source: this.source, ...options });

    this.deleteInteraction.on('delete', () => {
      this.changeCursor(null);
    });
    this.deleteInteraction.setActive(false);
  }

  /**
   * Create the interaction used to deselected features when we click on the map.
   * @param {*} options
   * @private
   */
  createDeselectInteraction(options = {}) {
    // it's important that this condition was the same as the selectModify's
    // deleteCondition to avoid the selection of the feature under the node to delete.
    const condition = options.condition || singleClick;

    /**
     * @type {ol.interaction.Interaction}
     * @private
     */
    this.deselectInteraction = new Interaction({
      handleEvent: (mapBrowserEvent) => {
        if (!condition(mapBrowserEvent)) {
          return true;
        }
        const onFeature = this.getFeatureAtPixel(mapBrowserEvent.pixel);
        const onVertex = this.isHoverVertexFeatureAtPixel(
          mapBrowserEvent.pixel,
        );

        if (!onVertex && !onFeature) {
          // Default: Clear selection on click outside features.
          this.selectMove.getFeatures().clear();
          this.selectModify.getFeatures().clear();
          return false;
        }
        return true;
      },
    });
    this.deselectInteraction.setActive(false);
  }

  /**
   * Get a selectable feature at a pixel.
   * @param {*} pixel
   */
  getFeatureAtPixel(pixel) {
    const feature = this.map.forEachFeatureAtPixel(
      pixel,
      (feat, layer) => {
        if (this.selectFilter(feat, layer)) {
          return feat;
        }
        return null;
      },
      {
        hitTolerance: this.hitTolerance,
        layerFilter: this.layerFilter,
      },
    );
    return feature;
  }

  /**
   * Detect if a vertex is hovered.
   * @param {*} pixel
   */
  isHoverVertexFeatureAtPixel(pixel) {
    let isHoverVertex = false;
    this.map.forEachFeatureAtPixel(
      pixel,
      (feat, layer) => {
        if (!layer) {
          isHoverVertex = true;
          return true;
        }
        return false;
      },
      {
        hitTolerance: this.hitTolerance,
      },
    );
    return isHoverVertex;
  }

  isSelectedByMove(feature) {
    return this.selectMove.getFeatures().getArray().indexOf(feature) !== -1;
  }

  isSelectedByModify(feature) {
    return this.selectModify.getFeatures().getArray().indexOf(feature) !== -1;
  }

  /**
   * Handle the move event of the move interaction.
   * @param {ol.MapBrowserEvent} evt Event.
   * @private
   */
  cursorHandler(evt) {
    if (evt.dragging || this.isMoving || this.isModifying) {
      this.changeCursor('grabbing');
      return;
    }

    const feature = this.getFeatureAtPixel(evt.pixel);
    if (!feature) {
      this.changeCursor(this.previousCursor);
      this.previousCursor = null;
      return;
    }

    if (this.isSelectedByMove(feature)) {
      this.changeCursor('grab');
    } else if (this.isSelectedByModify(feature)) {
      if (this.isHoverVertexFeatureAtPixel(evt.pixel)) {
        this.changeCursor('grab');
      } else {
        this.changeCursor(this.previousCursor);
      }
    } else {
      // Feature available for selection.
      this.changeCursor('pointer');
    }
  }

  /**
   * Change cursor style.
   * @param {string} cursor New cursor name.
   * @private
   */
  changeCursor(cursor) {
    if (!this.getActive()) {
      return;
    }
    const newCursor = this.cursorStyleHandler(cursor);
    const element = this.map.getTargetElement();
    if (
      (element.style.cursor || newCursor) &&
      element.style.cursor !== newCursor
    ) {
      if (this.previousCursor === null) {
        this.previousCursor = element.style.cursor;
      }
      element.style.cursor = newCursor;
    }
  }

  setMap(map) {
    if (this.map) {
      this.map.removeInteraction(this.modifyInteraction);
      this.map.removeInteraction(this.moveInteraction);
      this.map.removeInteraction(this.selectMove);
      this.map.removeInteraction(this.selectModify);
      this.map.removeInteraction(this.deleteInteraction);
      this.map.removeInteraction(this.deselectInteraction);
    }
    super.setMap(map);
    this.map?.addInteraction(this.deselectInteraction);
    this.map?.addInteraction(this.deleteInteraction);
    this.map?.addInteraction(this.selectModify);
    // For the default behvior it's very important to add selectMove after selectModify.
    // It will avoid single/dbleclick mess.
    this.map?.addInteraction(this.selectMove);
    this.map?.addInteraction(this.moveInteraction);
    this.map?.addInteraction(this.modifyInteraction);
  }

  /**
   * Add others listeners on the map than interactions.
   * @param {*} evt
   * @private
   */
  addListeners() {
    this.removeListeners();
    this.map?.on('pointerdown', this.cursorHandler);
    this.map?.on('pointermove', this.cursorHandler);
    this.map?.on('pointerup', this.cursorHandler);
  }

  /**
   * Remove others listeners on the map than interactions.
   * @param {*} evt
   * @private
   */
  removeListeners() {
    this.map?.un('pointerdown', this.cursorHandler);
    this.map?.un('pointermove', this.cursorHandler);
    this.map?.un('pointerup', this.cursorHandler);
  }

  /**
   * @inheritdoc
   */
  activate() {
    super.activate();
    this.deselectInteraction.setActive(true);
    this.deleteInteraction.setActive(true);
    this.selectModify.setActive(true);
    // For the default behavior it's very important to add selectMove after selectModify.
    // It will avoid single/dbleclick mess.
    this.selectMove.setActive(true);
    this.addListeners();
  }

  /**
   * @inheritdoc
   */
  deactivate(silent) {
    this.removeListeners();
    this.selectMove.getFeatures().clear();
    this.selectModify.getFeatures().clear();
    this.deselectInteraction.setActive(false);
    this.deleteInteraction.setActive(false);
    this.selectModify.setActive(false);
    this.selectMove.setActive(false);
    super.deactivate(silent);
  }
}

export default ModifyControl;