src_tools_draw.js

import {getLayerDetailsFromEvent} from '../gui/layerGroup';
import {
  getEventOffset,
  customUI
} from '../gui/generic';
import {Point2D} from '../math/point';
import {guid} from '../math/stats';
import {logger} from '../utils/logger';
import {replaceFlags} from '../utils/string';
import {
  getShapeDisplayName,
  DrawGroupCommand,
  DeleteGroupCommand,
  MoveGroupCommand
} from './drawCommands';
import {
  canNodeChangeColour,
  isNodeNameShape
} from '../app/drawController';
import {ScrollWheel} from './scrollWheel';
import {ShapeEditor} from './editor';
// external
import Konva from 'konva';

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

/**
 * Draw Debug flag.
 */
export const DRAW_DEBUG = false;

/**
 * Drawing tool.
 *
 * This tool is responsible for the draw layer group structure. The layout is:
 *
 * drawLayer
 * |_ positionGroup: name="position-group", id="#2-0#_#3-1""
 *    |_ shapeGroup: name="{shape name}-group", id="#"
 *       |_ shape: name="shape"
 *       |_ label: name="label"
 *       |_ extra: line tick, protractor arc...
 *
 * Discussion:
 * - posGroup > shapeGroup
 *    pro: slice/frame display: 1 loop
 *    cons: multi-slice shape splitted in positionGroups
 * - shapeGroup > posGroup
 *    pros: more logical
 *    cons: slice/frame display: 2 loops
 */
export class Draw {

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

  /**
   * Scroll wheel handler.
   *
   * @type {ScrollWheel}
   */
  #scrollWhell;

  /**
   * Shape editor.
   *
   * @type {object}
   */
  #shapeEditor;

  /**
   * Trash draw: a cross.
   *
   * @type {object}
   */
  #trash;

  /**
   * Drawing style.
   *
   * @type {Style}
   */
  #style;

  /**
   * @param {App} app The associated application.
   */
  constructor(app) {
    this.#app = app;
    this.#scrollWhell = new ScrollWheel(app);
    this.#shapeEditor = new ShapeEditor(app);
    // associate the event listeners of the editor
    //  with those of the draw tool
    this.#shapeEditor.setDrawEventCallback(this.#fireEvent);

    this.#style = app.getStyle();

    // trash cross
    this.#trash = new Konva.Group();
    // first line of the cross
    const trashLine1 = new Konva.Line({
      points: [-10, -10, 10, 10],
      stroke: 'red'
    });
    // second line of the cross
    const trashLine2 = new Konva.Line({
      points: [10, -10, -10, 10],
      stroke: 'red'
    });
    this.#trash.width(20);
    this.#trash.height(20);
    this.#trash.add(trashLine1);
    this.#trash.add(trashLine2);
  }

  /**
   * Interaction start flag.
   *
   * @type {boolean}
   */
  #started = false;

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

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

  /**
   * Draw command.
   *
   * @type {object}
   */
  #command = null;

  /**
   * Current shape group.
   *
   * @type {object}
   */
  #tmpShapeGroup = null;

  /**
   * Shape name.
   *
   * @type {string}
   */
  #shapeName;

  /**
   * List of points.
   *
   * @type {Array}
   */
  #points = [];

  /**
   * Last selected point.
   *
   * @type {object}
   */
  #lastPoint = null;

  /**
   * Active shape, ie shape with mouse over.
   *
   * @type {object}
   */
  #activeShapeGroup;

  /**
   * Original mouse cursor.
   *
   * @type {string}
   */
  #originalCursor;

  /**
   * Mouse cursor.
   *
   * @type {string}
   */
  #mouseOverCursor = 'pointer';

  /**
   * Event listeners.
   */
  #listeners = {};

  /**
   * Handle mouse down event.
   *
   * @param {object} event The mouse down event.
   */
  mousedown = (event) => {
    // exit if a draw was started (handle at mouse move or up)
    if (this.#started) {
      return;
    }

    const layerDetails = getLayerDetailsFromEvent(event);
    const layerGroup = this.#app.getLayerGroupByDivId(layerDetails.groupDivId);
    const drawLayer = layerGroup.getActiveDrawLayer();

    // determine if the click happened in an existing shape
    const stage = drawLayer.getKonvaStage();
    const kshape = stage.getIntersection({
      x: event._x,
      y: event._y
    });

    // update scale
    this.#style.setZoomScale(stage.scale());

    if (kshape) {
      const group = kshape.getParent();
      const selectedShape = group.find('.shape')[0];
      // reset editor if click on other shape
      // (and avoid anchors mouse down)
      if (selectedShape && selectedShape !== this.#shapeEditor.getShape()) {
        this.#shapeEditor.disable();
        this.#shapeEditor.setShape(selectedShape);
        const viewController =
          layerGroup.getActiveViewLayer().getViewController();
        this.#shapeEditor.setViewController(viewController);
        this.#shapeEditor.enable();
      }
    } else {
      // disable edition
      this.#shapeEditor.disable();
      this.#shapeEditor.setShape(null);
      this.#shapeEditor.setViewController(null);
      // start storing points
      this.#started = true;
      // set factory
      this.#currentFactory = new this.#shapeFactoryList[this.#shapeName]();
      // clear array
      this.#points = [];
      // store point
      const viewLayer = layerGroup.getActiveViewLayer();
      const pos = viewLayer.displayToPlanePos(event._x, event._y);
      this.#lastPoint = new Point2D(pos.x, pos.y);
      this.#points.push(this.#lastPoint);
    }
  };

  /**
   * Handle mouse move event.
   *
   * @param {object} event The mouse move event.
   */
  mousemove = (event) => {
    // exit if not started draw
    if (!this.#started) {
      return;
    }

    const layerDetails = getLayerDetailsFromEvent(event);
    const layerGroup = this.#app.getLayerGroupByDivId(layerDetails.groupDivId);
    const viewLayer = layerGroup.getActiveViewLayer();
    const pos = viewLayer.displayToPlanePos(event._x, event._y);

    // draw line to current pos
    if (Math.abs(pos.x - this.#lastPoint.getX()) > 0 ||
      Math.abs(pos.y - this.#lastPoint.getY()) > 0) {
      // clear last added point from the list (but not the first one)
      // if it was marked as temporary
      if (this.#points.length !== 1 &&
        typeof this.#points[this.#points.length - 1].tmp !== 'undefined') {
        this.#points.pop();
      }
      // current point
      this.#lastPoint = new Point2D(pos.x, pos.y);
      // mark it as temporary
      this.#lastPoint.tmp = true;
      // add it to the list
      this.#points.push(this.#lastPoint);
      // update points
      this.#onNewPoints(this.#points, layerGroup);
    }
  };

  /**
   * Handle mouse up event.
   *
   * @param {object} event The mouse up event.
   */
  mouseup = (event) => {
    // exit if not started draw
    if (!this.#started) {
      return;
    }
    // exit if no points
    if (this.#points.length === 0) {
      logger.warn('Draw mouseup but no points...');
      return;
    }

    // do we have all the needed points
    if (this.#points.length === this.#currentFactory.getNPoints()) {
      // store points
      const layerDetails = getLayerDetailsFromEvent(event);
      const layerGroup =
        this.#app.getLayerGroupByDivId(layerDetails.groupDivId);
      this.#onFinalPoints(this.#points, layerGroup);
      // reset flag
      this.#started = false;
    } else {
      // remove temporary flag
      if (typeof this.#points[this.#points.length - 1].tmp !== 'undefined') {
        delete this.#points[this.#points.length - 1].tmp;
      }
    }
  };

  /**
   * Handle double click event: some tools use it to finish interaction.
   *
   * @param {object} event The double click event.
   */
  dblclick = (event) => {
    // only end by double click undefined NPoints
    if (typeof this.#currentFactory.getNPoints() !== 'undefined') {
      return;
    }
    // exit if not started draw
    if (!this.#started) {
      return;
    }
    // exit if no points
    if (this.#points.length === 0) {
      logger.warn('Draw dblclick but no points...');
      return;
    }

    // store points
    const layerDetails = getLayerDetailsFromEvent(event);
    const layerGroup = this.#app.getLayerGroupByDivId(layerDetails.groupDivId);
    this.#onFinalPoints(this.#points, layerGroup);
    // reset flag
    this.#started = false;
  };

  /**
   * Handle mouse out event.
   *
   * @param {object} event The mouse out event.
   */
  mouseout = (event) => {
    this.mouseup(event);
  };

  /**
   * Handle touch start event.
   *
   * @param {object} event The touch start event.
   */
  touchstart = (event) => {
    this.mousedown(event);
  };

  /**
   * Handle touch move event.
   *
   * @param {object} event The touch move event.
   */
  touchmove = (event) => {
    // exit if not started draw
    if (!this.#started) {
      return;
    }

    const layerDetails = getLayerDetailsFromEvent(event);
    const layerGroup = this.#app.getLayerGroupByDivId(layerDetails.groupDivId);
    const viewLayer = layerGroup.getActiveViewLayer();
    const pos = viewLayer.displayToPlanePos(event._x, event._y);

    if (Math.abs(pos.x - this.#lastPoint.getX()) > 0 ||
      Math.abs(pos.y - this.#lastPoint.getY()) > 0) {
      // clear last added point from the list (but not the first one)
      if (this.#points.length !== 1) {
        this.#points.pop();
      }
      // current point
      this.#lastPoint = new Point2D(pos.x, pos.y);
      // add current one to the list
      this.#points.push(this.#lastPoint);
      // allow for anchor points
      if (this.#points.length < this.#currentFactory.getNPoints()) {
        clearTimeout(this.timer);
        this.timer = setTimeout(() => {
          this.#points.push(this.#lastPoint);
        }, this.#currentFactory.getTimeout());
      }
      // update points
      this.#onNewPoints(this.#points, layerGroup);
    }
  };

  /**
   * Handle touch end event.
   *
   * @param {object} event The touch end event.
   */
  touchend = (event) => {
    this.dblclick(event);
  };

  /**
   * Handle mouse wheel event.
   *
   * @param {object} event The mouse wheel event.
   */
  wheel = (event) => {
    this.#scrollWhell.wheel(event);
  };

  /**
   * Handle key down event.
   *
   * @param {object} event The key down event.
   */
  keydown = (event) => {
    // call app handler if we are not in the middle of a draw
    if (!this.#started) {
      event.context = 'Draw';
      this.#app.onKeydown(event);
    }
    let konvaLayer;

    // press delete or backspace key
    if ((event.key === 'Delete' ||
      event.key === 'Backspace') &&
      this.#shapeEditor.isActive()) {
      // get shape
      const shapeGroup = this.#shapeEditor.getShape().getParent();
      konvaLayer = shapeGroup.getLayer();
      const shapeDisplayName = getShapeDisplayName(
        shapeGroup.getChildren(isNodeNameShape)[0]);
      // delete command
      const delcmd = new DeleteGroupCommand(shapeGroup,
        shapeDisplayName, konvaLayer);
      delcmd.onExecute = this.#fireEvent;
      delcmd.onUndo = this.#fireEvent;
      delcmd.execute();
      this.#app.addToUndoStack(delcmd);
    }

    // escape key: exit shape creation
    if (event.key === 'Escape' && this.#tmpShapeGroup !== null) {
      konvaLayer = this.#tmpShapeGroup.getLayer();
      // reset temporary shape group
      this.#tmpShapeGroup.destroy();
      this.#tmpShapeGroup = null;
      // reset flag and points
      this.#started = false;
      this.#points = [];
      // redraw
      konvaLayer.draw();
    }
  };

  /**
   * Update the current draw with new points.
   *
   * @param {Array} tmpPoints The array of new points.
   * @param {LayerGroup} layerGroup The origin layer group.
   */
  #onNewPoints(tmpPoints, layerGroup) {
    const drawLayer = layerGroup.getActiveDrawLayer();
    const konvaLayer = drawLayer.getKonvaLayer();

    // remove temporary shape draw
    if (this.#tmpShapeGroup) {
      this.#tmpShapeGroup.destroy();
      this.#tmpShapeGroup = null;
    }

    // create shape group
    const viewController =
      layerGroup.getActiveViewLayer().getViewController();
    this.#tmpShapeGroup = this.#currentFactory.create(
      tmpPoints, this.#style, viewController);
    // do not listen during creation
    const shape = this.#tmpShapeGroup.getChildren(isNodeNameShape)[0];
    shape.listening(false);
    konvaLayer.listening(false);
    // draw shape
    konvaLayer.add(this.#tmpShapeGroup);
    konvaLayer.draw();
  }

  /**
   * Create the final shape from a point list.
   *
   * @param {Array} finalPoints The array of points.
   * @param {LayerGroup} layerGroup The origin layer group.
   */
  #onFinalPoints(finalPoints, layerGroup) {
    const drawLayer = layerGroup.getActiveDrawLayer();
    const konvaLayer = drawLayer.getKonvaLayer();

    // reset temporary shape group
    if (this.#tmpShapeGroup) {
      this.#tmpShapeGroup.destroy();
      this.#tmpShapeGroup = null;
    }

    const viewController =
      layerGroup.getActiveViewLayer().getViewController();
    const drawController =
      layerGroup.getActiveDrawLayer().getDrawController();

    // create final shape
    const finalShapeGroup = this.#currentFactory.create(
      finalPoints, this.#style, viewController);
    finalShapeGroup.id(guid());

    // get the position group
    const posGroup = drawController.getCurrentPosGroup();
    // add shape group to position group
    posGroup.add(finalShapeGroup);

    // re-activate layer
    konvaLayer.listening(true);
    // draw shape command
    this.#command = new DrawGroupCommand(
      finalShapeGroup, this.#shapeName, konvaLayer);
    this.#command.onExecute = this.#fireEvent;
    this.#command.onUndo = this.#fireEvent;
    // execute it
    this.#command.execute();
    // save it in undo stack
    this.#app.addToUndoStack(this.#command);

    // activate shape listeners
    this.setShapeOn(finalShapeGroup, layerGroup);
  }

  /**
   * Activate the tool.
   *
   * @param {boolean} flag The flag to activate or not.
   */
  activate(flag) {
    // reset shape display properties
    this.#shapeEditor.disable();
    this.#shapeEditor.setShape(null);
    this.#shapeEditor.setViewController(null);
    // get the current draw layer
    const layerGroup = this.#app.getActiveLayerGroup();
    this.#activateCurrentPositionShapes(flag, layerGroup);
    // listen to app change to update the draw layer
    if (flag) {
      // store cursor
      this.#originalCursor = document.body.style.cursor;
      // TODO: merge with drawController.activateDrawLayer?
      this.#app.addEventListener('positionchange', () => {
        this.#updateDrawLayer(layerGroup);
      });
      // same for colour
      this.setFeatures({lineColour: this.#style.getLineColour()});
    } else {
      // reset shape and cursor
      this.#resetActiveShapeGroup();
      // reset local var
      this.#originalCursor = undefined;
      // remove listeners
      this.#app.removeEventListener('positionchange', () => {
        this.#updateDrawLayer(layerGroup);
      });
    }
  }

  /**
   * Update the draw layer.
   *
   * @param {LayerGroup} layerGroup The origin layer group.
   */
  #updateDrawLayer(layerGroup) {
    // activate the shape at current position
    this.#activateCurrentPositionShapes(true, layerGroup);
  }

  /**
   * Activate shapes at current position.
   *
   * @param {boolean} visible Set the draw layer visible or not.
   * @param {LayerGroup} layerGroup The origin layer group.
   */
  #activateCurrentPositionShapes(visible, layerGroup) {
    const drawController =
      layerGroup.getActiveDrawLayer().getDrawController();

    // get shape groups at the current position
    const shapeGroups =
      drawController.getCurrentPosGroup().getChildren();

    // set shape display properties
    if (visible) {
      // activate shape listeners
      shapeGroups.forEach((group) => {
        this.setShapeOn(group, layerGroup);
      });
    } else {
      // de-activate shape listeners
      shapeGroups.forEach((group) => {
        this.#setShapeOff(group);
      });
    }
    // draw
    const drawLayer = layerGroup.getActiveDrawLayer();
    const konvaLayer = drawLayer.getKonvaLayer();
    if (shapeGroups.length !== 0) {
      konvaLayer.listening(true);
    }
    konvaLayer.draw();
  }

  /**
   * Set shape group off properties.
   *
   * @param {object} shapeGroup The shape group to set off.
   */
  #setShapeOff(shapeGroup) {
    // mouse styling
    shapeGroup.off('mouseover');
    shapeGroup.off('mouseout');
    // drag
    shapeGroup.draggable(false);
    shapeGroup.off('dragstart.draw');
    shapeGroup.off('dragmove.draw');
    shapeGroup.off('dragend.draw');
    shapeGroup.off('dblclick');
  }

  /**
   * Get the real position from an event.
   * TODO: use layer method?
   *
   * @param {object} index The input index as {x,y}.
   * @param {LayerGroup} layerGroup The origin layer group.
   * @returns {object} The real position in the image as {x,y}.
   */
  #getRealPosition(index, layerGroup) {
    const drawLayer = layerGroup.getActiveDrawLayer();
    const stage = drawLayer.getKonvaStage();
    return {
      x: stage.offset().x + index.x / stage.scale().x,
      y: stage.offset().y + index.y / stage.scale().y
    };
  }

  /**
   * Reset the active shape group and mouse cursor to their original state.
   */
  #resetActiveShapeGroup() {
    if (typeof this.#originalCursor !== 'undefined') {
      document.body.style.cursor = this.#originalCursor;
    }
    if (typeof this.#activeShapeGroup !== 'undefined') {
      this.#activeShapeGroup.opacity(1);
      const colour = this.#style.getLineColour();
      this.#activeShapeGroup.getChildren(canNodeChangeColour).forEach(
        function (ashape) {
          ashape.stroke(colour);
        }
      );
    }
  }

  /**
   * Set shape group on properties.
   *
   * @param {object} shapeGroup The shape group to set on.
   * @param {LayerGroup} layerGroup The origin layer group.
   */
  setShapeOn(shapeGroup, layerGroup) {
    // adapt shape and cursor when mouse over
    const mouseOnShape = () => {
      document.body.style.cursor = this.#mouseOverCursor;
      shapeGroup.opacity(0.75);
    };
    // mouse over event hanlding
    shapeGroup.on('mouseover', () => {
      // save local vars
      this.#activeShapeGroup = shapeGroup;
      // adapt shape
      mouseOnShape();
    });
    // mouse out event hanlding
    shapeGroup.on('mouseout', () => {
      // reset shape
      this.#resetActiveShapeGroup();
      // reset local vars
      this.#activeShapeGroup = undefined;
    });

    const drawLayer = layerGroup.getActiveDrawLayer();
    const konvaLayer = drawLayer.getKonvaLayer();

    // make it draggable
    shapeGroup.draggable(true);
    // cache drag start position
    let dragStartPos = {x: shapeGroup.x(), y: shapeGroup.y()};

    // command name based on shape type
    const shapeDisplayName = getShapeDisplayName(
      shapeGroup.getChildren(isNodeNameShape)[0]);

    let colour = null;

    // drag start event handling
    shapeGroup.on('dragstart.draw', (/*event*/) => {
      // store colour
      colour = shapeGroup.getChildren(isNodeNameShape)[0].stroke();
      // display trash
      const drawLayer = layerGroup.getActiveDrawLayer();
      const stage = drawLayer.getKonvaStage();
      const scale = stage.scale();
      const invscale = {x: 1 / scale.x, y: 1 / scale.y};
      this.#trash.x(stage.offset().x + (stage.width() / (2 * scale.x)));
      this.#trash.y(stage.offset().y + (stage.height() / (15 * scale.y)));
      this.#trash.scale(invscale);
      konvaLayer.add(this.#trash);
      // deactivate anchors to avoid events on null shape
      this.#shapeEditor.setAnchorsActive(false);
      // draw
      konvaLayer.draw();
    });
    // drag move event handling
    shapeGroup.on('dragmove.draw', (event) => {
      const group = event.target;
      const drawLayer = layerGroup.getActiveDrawLayer();
      // validate the group position
      validateGroupPosition(drawLayer.getBaseSize(), group);
      // get appropriate factory
      let factory;
      const keys = Object.keys(this.#shapeFactoryList);
      for (let i = 0; i < keys.length; ++i) {
        factory = new this.#shapeFactoryList[keys[i]];
        if (factory.isFactoryGroup(shapeGroup)) {
          // stop at first find
          break;
        }
      }
      if (typeof factory === 'undefined') {
        throw new Error('Cannot find factory to update quantification.');
      }
      // update quantification if possible
      if (typeof factory.updateQuantification !== 'undefined') {
        const vc = layerGroup.getActiveViewLayer().getViewController();
        factory.updateQuantification(group, vc);
      }
      // highlight trash when on it
      const offset = getEventOffset(event.evt)[0];
      const eventPos = this.#getRealPosition(offset, layerGroup);
      const trashHalfWidth = this.#trash.width() * this.#trash.scaleX() / 2;
      const trashHalfHeight = this.#trash.height() * this.#trash.scaleY() / 2;
      if (Math.abs(eventPos.x - this.#trash.x()) < trashHalfWidth &&
        Math.abs(eventPos.y - this.#trash.y()) < trashHalfHeight) {
        this.#trash.getChildren().forEach(function (tshape) {
          tshape.stroke('orange');
        });
        // change the group shapes colour
        shapeGroup.getChildren(canNodeChangeColour).forEach(
          function (ashape) {
            ashape.stroke('red');
          });
      } else {
        this.#trash.getChildren().forEach(function (tshape) {
          tshape.stroke('red');
        });
        // reset the group shapes colour
        shapeGroup.getChildren(canNodeChangeColour).forEach(
          function (ashape) {
            if (typeof ashape.stroke !== 'undefined') {
              ashape.stroke(colour);
            }
          });
      }
      // draw
      konvaLayer.draw();
    });
    // drag end event handling
    shapeGroup.on('dragend.draw', (event) => {
      const group = event.target;
      // remove trash
      this.#trash.remove();
      // activate(false) will also trigger a dragend.draw
      if (typeof event === 'undefined' ||
        typeof event.evt === 'undefined') {
        return;
      }
      const pos = {x: group.x(), y: group.y()};
      // delete case
      const offset = getEventOffset(event.evt)[0];
      const eventPos = this.#getRealPosition(offset, layerGroup);
      const trashHalfWidth = this.#trash.width() * this.#trash.scaleX() / 2;
      const trashHalfHeight = this.#trash.height() * this.#trash.scaleY() / 2;
      if (Math.abs(eventPos.x - this.#trash.x()) < trashHalfWidth &&
        Math.abs(eventPos.y - this.#trash.y()) < trashHalfHeight) {
        // compensate for the drag translation
        group.x(dragStartPos.x);
        group.y(dragStartPos.y);
        // disable editor
        this.#shapeEditor.disable();
        this.#shapeEditor.setShape(null);
        this.#shapeEditor.setViewController(null);
        // reset colour
        shapeGroup.getChildren(canNodeChangeColour).forEach(
          function (ashape) {
            ashape.stroke(colour);
          });
        // reset cursor
        document.body.style.cursor = this.#originalCursor;
        // delete command
        const delcmd = new DeleteGroupCommand(group,
          shapeDisplayName, konvaLayer);
        delcmd.onExecute = this.#fireEvent;
        delcmd.onUndo = this.#fireEvent;
        delcmd.execute();
        this.#app.addToUndoStack(delcmd);
      } else {
        // save drag move
        const translation = {
          x: pos.x - dragStartPos.x,
          y: pos.y - dragStartPos.y
        };
        if (translation.x !== 0 || translation.y !== 0) {
          const mvcmd = new MoveGroupCommand(group,
            shapeDisplayName, translation, konvaLayer);
          mvcmd.onExecute = this.#fireEvent;
          mvcmd.onUndo = this.#fireEvent;
          this.#app.addToUndoStack(mvcmd);

          // the move is handled by Konva, trigger an event manually
          this.#fireEvent({
            type: 'drawmove',
            id: group.id()
          });
        }
        // reset anchors
        this.#shapeEditor.setAnchorsActive(true);
        this.#shapeEditor.resetAnchors();
      }
      // draw
      konvaLayer.draw();
      // reset start position
      dragStartPos = {x: group.x(), y: group.y()};
    });
    // double click handling: update label
    shapeGroup.on('dblclick', (event) => {
      const group = event.currentTarget;
      // get the label object for this shape
      const label = group.findOne('Label');
      // should just be one
      if (typeof label === 'undefined') {
        throw new Error('Could not find the shape label.');
      }
      const ktext = label.getText();
      // id for event
      const groupId = group.id();

      const onSaveCallback = (meta) => {
        // store meta
        ktext.meta = meta;
        // update text expression
        ktext.setText(replaceFlags(
          ktext.meta.textExpr, ktext.meta.quantification));
        label.setVisible(ktext.meta.textExpr.length !== 0);

        // trigger event
        this.#fireEvent({
          type: 'drawchange',
          id: groupId
        });
        // draw
        konvaLayer.draw();
      };

      // call roi dialog
      customUI.openRoiDialog(ktext.meta, onSaveCallback);
    });
  }

  /**
   * Set the tool configuration options.
   *
   * @param {object} options The list of shape names amd classes.
   */
  setOptions(options) {
    // save the options as the shape factory list
    this.#shapeFactoryList = options;
    // pass them to the editor
    this.#shapeEditor.setFactoryList(options);
  }

  /**
   * Get the type of tool options: here 'factory' since the shape
   * list contains factories to create each possible shape.
   *
   * @returns {string} The type.
   */
  getOptionsType() {
    return 'factory';
  }

  /**
   * Set the tool live features: shape colour and shape name.
   *
   * @param {object} features The list of features.
   */
  setFeatures(features) {
    if (typeof features.shapeColour !== 'undefined') {
      this.#style.setLineColour(features.shapeColour);
    }
    if (typeof features.shapeName !== 'undefined') {
      // check if we have it
      if (!this.hasShape(features.shapeName)) {
        throw new Error('Unknown shape: \'' + features.shapeName + '\'');
      }
      this.#shapeName = features.shapeName;
    }
    if (typeof features.mouseOverCursor !== 'undefined') {
      this.#mouseOverCursor = features.mouseOverCursor;
    }
  }

  /**
   * Initialise the tool.
   */
  init() {
    // does nothing
  }

  /**
   * Get the list of event names that this tool can fire.
   *
   * @returns {Array} The list of event names.
   */
  getEventNames() {
    return [
      'drawcreate', 'drawchange', 'drawmove', 'drawdelete', 'drawlabelchange'
    ];
  }

  /**
   * Add an event listener on the app.
   *
   * @param {string} type The event type.
   * @param {Function} listener The function associated with the provided
   *   event type.
   */
  addEventListener(type, listener) {
    if (typeof this.#listeners[type] === 'undefined') {
      this.#listeners[type] = [];
    }
    this.#listeners[type].push(listener);
  }

  /**
   * Remove an event listener from the app.
   *
   * @param {string} type The event type.
   * @param {Function} listener The function associated with the provided
   *   event type.
   */
  removeEventListener(type, listener) {
    if (typeof this.#listeners[type] === 'undefined') {
      return;
    }
    for (let i = 0; i < this.#listeners[type].length; ++i) {
      if (this.#listeners[type][i] === listener) {
        this.#listeners[type].splice(i, 1);
      }
    }
  }

  // Private Methods -----------------------------------------------------------

  /**
   * Fire an event: call all associated listeners.
   *
   * @param {object} event The event to fire.
   */
  #fireEvent = (event) => {
    if (typeof this.#listeners[event.type] === 'undefined') {
      return;
    }
    for (let i = 0; i < this.#listeners[event.type].length; ++i) {
      this.#listeners[event.type][i](event);
    }
  };

  /**
   * Check if the shape is in the shape list.
   *
   * @param {string} name The name of the shape.
   * @returns {boolean} True if there is a factory for the shape.
   */
  hasShape(name) {
    return typeof this.#shapeFactoryList[name] !== 'undefined';
  }

} // Draw class

/**
 * Get the minimum position in a groups' anchors.
 *
 * @param {object} group The group that contains anchors.
 * @returns {object|undefined} The minimum position as {x,y}.
 */
function getAnchorMin(group) {
  const anchors = group.find('.anchor');
  if (anchors.length === 0) {
    return undefined;
  }
  let minX = anchors[0].x();
  let minY = anchors[0].y();
  for (let i = 0; i < anchors.length; ++i) {
    minX = Math.min(minX, anchors[i].x());
    minY = Math.min(minY, anchors[i].y());
  }

  return {x: minX, y: minY};
}

/**
 * Bound a node position.
 *
 * @param {object} node The node to bound the position.
 * @param {object} min The minimum position as {x,y}.
 * @param {object} max The maximum position as {x,y}.
 * @returns {boolean} True if the position was corrected.
 */
function boundNodePosition(node, min, max) {
  let changed = false;
  if (node.x() < min.x) {
    node.x(min.x);
    changed = true;
  } else if (node.x() > max.x) {
    node.x(max.x);
    changed = true;
  }
  if (node.y() < min.y) {
    node.y(min.y);
    changed = true;
  } else if (node.y() > max.y) {
    node.y(max.y);
    changed = true;
  }
  return changed;
}

/**
 * Validate a group position.
 *
 * @param {object} stageSize The stage size {x,y}.
 * @param {object} group The group to evaluate.
 * @returns {boolean} True if the position was corrected.
 */
function validateGroupPosition(stageSize, group) {
  // if anchors get mixed, width/height can be negative
  const shape = group.getChildren(isNodeNameShape)[0];
  const anchorMin = getAnchorMin(group);
  // handle no anchor: when dragging the label, the editor does
  //   not activate
  if (typeof anchorMin === 'undefined') {
    return null;
  }

  const min = {
    x: -anchorMin.x,
    y: -anchorMin.y
  };
  const max = {
    x: stageSize.x -
      (anchorMin.x + Math.abs(shape.width())),
    y: stageSize.y -
      (anchorMin.y + Math.abs(shape.height()))
  };

  return boundNodePosition(group, min, max);
}

/**
 * Validate an anchor position.
 *
 * @param {object} stageSize The stage size {x,y}.
 * @param {object} anchor The anchor to evaluate.
 * @returns {boolean} True if the position was corrected.
 */
export function validateAnchorPosition(stageSize, anchor) {
  const group = anchor.getParent();

  const min = {
    x: -group.x(),
    y: -group.y()
  };
  const max = {
    x: stageSize.x - group.x(),
    y: stageSize.y - group.y()
  };

  return boundNodePosition(anchor, min, max);
}