src_tools_editor.js

import {logger} from '../utils/logger';
import {getShapeDisplayName, ChangeGroupCommand} from './drawCommands';
import {validateAnchorPosition} from './draw';
// external
import Konva from 'konva';

// doc imports
/* eslint-disable no-unused-vars */
import {App} from '../app/application';
import {ViewController} from '../app/viewController';
import {DrawLayer} from '../gui/drawLayer';
import {Style} from '../gui/style';
/* eslint-enable no-unused-vars */

/**
 * Get the default anchor shape.
 *
 * @param {number} x The X position.
 * @param {number} y The Y position.
 * @param {string} id The shape id.
 * @param {Style} style The application style.
 * @returns {Konva.Ellipse} The default anchor shape.
 */
export function getDefaultAnchor(x, y, id, style) {
  const radius = style.applyZoomScale(3);
  const absRadius = {
    x: Math.abs(radius.x),
    y: Math.abs(radius.y)
  };
  return new Konva.Ellipse({
    x: x,
    y: y,
    stroke: '#999',
    fill: 'rgba(100,100,100,0.7',
    strokeWidth: style.getStrokeWidth(),
    strokeScaleEnabled: false,
    radius: absRadius,
    radiusX: absRadius.x,
    radiusY: absRadius.y,
    name: 'anchor',
    id: id.toString(),
    dragOnTop: false,
    draggable: true,
    visible: false
  });
}

/**
 * Shape editor.
 */
export class ShapeEditor {

  /**
   * Associated app.
   *
   * @type {App}
   */
  #app;

  /**
   * @param {App} app The associated application.
   */
  constructor(app) {
    this.#app = app;
  }

  /**
   * Shape factory list.
   *
   * @type {object}
   */
  #shapeFactoryList = null;

  /**
   * Current shape factory.
   *
   * @type {object}
   */
  #currentFactory = null;

  /**
   * Edited shape.
   *
   * @type {Konva.Shape}
   */
  #shape = null;

  /**
   * Associated draw layer. Used to bound anchor move.
   *
   * @type {DrawLayer}
   */
  #drawLayer;

  /**
   * Associated view controller. Used for quantification update.
   *
   * @type {ViewController}
   */
  #viewController = null;

  /**
   * Active flag.
   *
   * @type {boolean}
   */
  #isActive = false;

  /**
   * @callback eventFn
   * @param {object} event The event.
   */

  /**
   * Draw event callback.
   *
   * @type {eventFn}
   */
  #drawEventCallback = null;

  /**
   * Set the tool options.
   *
   * @param {Array} list The list of shape classes.
   */
  setFactoryList(list) {
    this.#shapeFactoryList = list;
  }

  /**
   * Set the shape to edit.
   *
   * @param {Konva.Shape} inshape The shape to edit.
   * @param {DrawLayer} drawLayer The associated draw layer.
   * @param {ViewController} viewController The associated view controller.
   */
  setShape(inshape, drawLayer, viewController) {
    this.#shape = inshape;
    this.#drawLayer = drawLayer;
    this.#viewController = viewController;
    if (this.#shape) {
      // remove old anchors
      this.#removeAnchors();
      // find a factory for the input shape
      const group = this.#shape.getParent();
      const keys = Object.keys(this.#shapeFactoryList);
      this.#currentFactory = null;
      for (let i = 0; i < keys.length; ++i) {
        const factory = new this.#shapeFactoryList[keys[i]];
        if (factory.isFactoryGroup(group)) {
          this.#currentFactory = factory;
          // stop at first find
          break;
        }
      }
      if (this.#currentFactory === null) {
        throw new Error('Could not find a factory to update shape.');
      }
      // add new anchors
      this.#addAnchors();
    }
  }

  /**
   * Get the edited shape.
   *
   * @returns {Konva.Shape} The edited shape.
   */
  getShape() {
    return this.#shape;
  }

  /**
   * Get the active flag.
   *
   * @returns {boolean} The active flag.
   */
  isActive() {
    return this.#isActive;
  }

  /**
   * Set the draw event callback.
   *
   * @param {eventFn} callback The callback.
   */
  setDrawEventCallback(callback) {
    this.#drawEventCallback = callback;
  }

  /**
   * Enable the editor. Redraws the layer.
   */
  enable() {
    this.#isActive = true;
    if (this.#shape) {
      this.#setAnchorsVisible(true);
      if (this.#shape.getLayer()) {
        this.#shape.getLayer().draw();
      }
    }
  }

  /**
   * Disable the editor. Redraws the layer.
   */
  disable() {
    this.#isActive = false;
    if (this.#shape) {
      this.#setAnchorsVisible(false);
      if (this.#shape.getLayer()) {
        this.#shape.getLayer().draw();
      }
    }
  }

  /**
   * Reset the editor.
   */
  reset() {
    this.#shape = undefined;
    this.#drawLayer = undefined;
    this.#viewController = undefined;
  }

  /**
   * Reset the anchors.
   */
  resetAnchors() {
    // remove previous controls
    this.#removeAnchors();
    // add anchors
    this.#addAnchors();
    // set them visible
    this.#setAnchorsVisible(true);
  }

  /**
   * Apply a function on all anchors.
   *
   * @param {object} func A f(shape) function.
   */
  #applyFuncToAnchors(func) {
    if (this.#shape && this.#shape.getParent()) {
      const anchors = this.#shape.getParent().find('.anchor');
      anchors.forEach(func);
    }
  }

  /**
   * Set anchors visibility.
   *
   * @param {boolean} flag The visible flag.
   */
  #setAnchorsVisible(flag) {
    this.#applyFuncToAnchors(function (anchor) {
      anchor.visible(flag);
    });
  }

  /**
   * Set anchors active.
   *
   * @param {boolean} flag The active (on/off) flag.
   */
  setAnchorsActive(flag) {
    let func = null;
    if (flag) {
      func = (anchor) => {
        this.#setAnchorOn(anchor);
      };
    } else {
      func = (anchor) => {
        this.#setAnchorOff(anchor);
      };
    }
    this.#applyFuncToAnchors(func);
  }

  /**
   * Remove anchors.
   */
  #removeAnchors() {
    this.#applyFuncToAnchors(function (anchor) {
      anchor.remove();
    });
  }

  /**
   * Add the shape anchors.
   */
  #addAnchors() {
    // exit if no shape or no layer
    if (!this.#shape || !this.#shape.getLayer()) {
      return;
    }
    // get shape group
    const group = this.#shape.getParent();

    // activate and add anchors to group
    const anchors =
      this.#currentFactory.getAnchors(this.#shape, this.#app.getStyle());
    for (let i = 0; i < anchors.length; ++i) {
      // set anchor on
      this.#setAnchorOn(anchors[i]);
      // add the anchor to the group
      group.add(anchors[i]);
    }
  }

  /**
   * Get a simple clone of the input anchor.
   *
   * @param {Konva.Shape} anchor The anchor to clone.
   * @returns {object} A clone of the input anchor.
   */
  #getClone(anchor) {
    // create closure to properties
    const parent = anchor.getParent();
    const id = anchor.id();
    const x = anchor.x();
    const y = anchor.y();
    // create clone object
    const clone = {};
    clone.getParent = function () {
      return parent;
    };
    clone.id = function () {
      return id;
    };
    clone.x = function () {
      return x;
    };
    clone.y = function () {
      return y;
    };
    return clone;
  }

  /**
   * Set the anchor on listeners.
   *
   * @param {Konva.Ellipse} anchor The anchor to set on.
   */
  #setAnchorOn(anchor) {
    let startAnchor = null;

    // command name based on shape type
    const shapeDisplayName = getShapeDisplayName(this.#shape);

    // drag start listener
    anchor.on('dragstart.edit', (event) => {
      const anchor = event.target;
      if (!(anchor instanceof Konva.Shape)) {
        return;
      }
      startAnchor = this.#getClone(anchor);
      // prevent bubbling upwards
      event.cancelBubble = true;
    });
    // drag move listener
    anchor.on('dragmove.edit', (event) => {
      const anchor = event.target;
      if (!(anchor instanceof Konva.Shape)) {
        return;
      }
      // validate the anchor position
      validateAnchorPosition(this.#drawLayer.getBaseSize(), anchor);
      // update shape
      this.#currentFactory.update(
        anchor, this.#app.getStyle(), this.#viewController);
      // redraw
      if (anchor.getLayer()) {
        anchor.getLayer().draw();
      } else {
        logger.warn('No layer to draw the anchor!');
      }
      // prevent bubbling upwards
      event.cancelBubble = true;
    });
    // drag end listener
    anchor.on('dragend.edit', (event) => {
      const anchor = event.target;
      if (!(anchor instanceof Konva.Shape)) {
        return;
      }
      const endAnchor = this.#getClone(anchor);
      // store the change command
      const chgcmd = new ChangeGroupCommand(
        shapeDisplayName,
        this.#currentFactory,
        startAnchor,
        endAnchor,
        this.#drawLayer,
        this.#viewController,
        this.#app.getStyle()
      );
      chgcmd.onExecute = this.#drawEventCallback;
      chgcmd.onUndo = this.#drawEventCallback;
      chgcmd.execute();
      this.#app.addToUndoStack(chgcmd);
      // reset start anchor
      startAnchor = endAnchor;
      // prevent bubbling upwards
      event.cancelBubble = true;
    });
    // mouse down listener
    anchor.on('mousedown touchstart', (event) => {
      const anchor = event.target;
      anchor.moveToTop();
    });
    // mouse over styling
    anchor.on('mouseover.edit', (event) => {
      const anchor = event.target;
      if (!(anchor instanceof Konva.Shape)) {
        return;
      }
      // style is handled by the group
      anchor.stroke('#ddd');
      if (anchor.getLayer()) {
        anchor.getLayer().draw();
      } else {
        logger.warn('No layer to draw the anchor!');
      }
    });
    // mouse out styling
    anchor.on('mouseout.edit', (event) => {
      const anchor = event.target;
      if (!(anchor instanceof Konva.Shape)) {
        return;
      }
      // style is handled by the group
      anchor.stroke('#999');
      if (anchor.getLayer()) {
        anchor.getLayer().draw();
      } else {
        logger.warn('No layer to draw the anchor!');
      }
    });
  }

  /**
   * Set the anchor off listeners.
   *
   * @param {Konva.Ellipse} anchor The anchor to set off.
   */
  #setAnchorOff(anchor) {
    anchor.off('dragstart.edit');
    anchor.off('dragmove.edit');
    anchor.off('dragend.edit');
    anchor.off('mousedown touchstart');
    anchor.off('mouseover.edit');
    anchor.off('mouseout.edit');
  }

} // class Editor