ivmartel/dwv

View on GitHub
src/tools/drawShapeHandler.js

Summary

Maintainability
A
0 mins
Test Coverage
import {
  getMousePoint,
  customUI
} from '../gui/generic';
import {
  RemoveAnnotationCommand,
  UpdateAnnotationCommand
} from './drawCommands';
import {
  isNodeNameShape,
  isNodeNameLabel,
  getShapePositionRange,
  isShapeInRange
} from './drawBounds';
import {DrawShapeEditor} from './drawShapeEditor';
import {DrawTrash} from './drawTrash';

// external
import Konva from 'konva';

// doc imports
/* eslint-disable no-unused-vars */
import {App} from '../app/application';
import {Scalar2D} from '../math/scalar';
import {DrawLayer} from '../gui/drawLayer';
import {Annotation} from '../image/annotation';
import {Point2D} from '../math/point';
/* eslint-enable no-unused-vars */

/**
 * Draw shape handler: handle action on existing shapes.
 */
export class DrawShapeHandler {

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

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

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

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

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

  /**
   * Shape with mouse over.
   *
   * @type {Konva.Group}
   */
  #mouseOverShapeGroup;

  /**
   * Event callback.
   *
   * @type {Function}
   */
  #eventCallback;

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

  /**
   * @param {App} app The associated application.
   * @param {Function} eventCallback Event callback.
   */
  constructor(app, eventCallback) {
    this.#app = app;
    this.#eventCallback = eventCallback;
    this.#shapeEditor = new DrawShapeEditor(app, eventCallback);
    this.#trash = new DrawTrash();
  }

  /**
   * Set the draw editor shape.
   *
   * @param {Konva.Shape} shape The shape to edit.
   * @param {DrawLayer} drawLayer The layer the shape belongs to.
   */
  setEditorShape(shape, drawLayer) {
    const drawController = drawLayer.getDrawController();
    if (shape &&
      shape instanceof Konva.Shape &&
      shape !== this.#shapeEditor.getShape() &&
      drawController.isAnnotationGroupEditable()) {
      // disable
      this.#shapeEditor.disable();
      // set shape
      this.#shapeEditor.setShape(
        shape,
        drawLayer,
        drawLayer.getDrawController().getAnnotation(shape.getParent().id()));
      // enable
      this.#shapeEditor.enable();
    }
  }

  /**
   * Get the currently edited shape group.
   *
   * @returns {Konva.Group|undefined} The edited group.
   */
  getEditorShapeGroup() {
    let res;
    if (this.#shapeEditor.isActive()) {
      res = this.#shapeEditor.getShape().getParent();
      if (!(res instanceof Konva.Group)) {
        return;
      }
    }
    return res;
  }

  /**
   * Get the currently edited annotation.
   *
   * @returns {Annotation|undefined} The edited annotation.
   */
  getEditorAnnotation() {
    let res;
    if (this.#shapeEditor.isActive()) {
      res = this.#shapeEditor.getAnnotation();
    }
    return res;
  }

  /**
   * Disable and reset the shape editor.
   */
  disableAndResetEditor() {
    this.#shapeEditor.disable();
    this.#shapeEditor.reset();
  }

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

  /**
   * Store specific mouse over cursor.
   *
   * @param {string} cursor The cursor name.
   */
  storeMouseOverCursor(cursor) {
    this.#mouseOverCursor = cursor;
  }

  /**
   * Handle shape group mouseover.
   */
  #onMouseOverShapeGroup() {
    // mouse cursor
    this.#originalCursor = document.body.style.cursor;
    document.body.style.cursor = this.#mouseOverCursor;
    // shape opacity
    this.#mouseOverShapeGroup.opacity(0.75);
  }

  /**
   * Handle shape group mouseout.
   */
  onMouseOutShapeGroup() {
    // mouse cursor
    if (typeof this.#originalCursor !== 'undefined') {
      document.body.style.cursor = this.#originalCursor;
      this.#originalCursor = undefined;
    }
    // shape opacity
    if (typeof this.#mouseOverShapeGroup !== 'undefined') {
      this.#mouseOverShapeGroup.opacity(1);
    }
  }

  /**
   * Add shape group mouse over and out listeners: updates
   *   shape group opacity and cursor.
   *
   * @param {Konva.Group} shapeGroup The shape group.
   */
  #addShapeOverListeners(shapeGroup) {
    // handle mouse over
    shapeGroup.on('mouseover', () => {
      this.#mouseOverShapeGroup = shapeGroup;
      this.#onMouseOverShapeGroup();
    });

    // handle mouse out
    shapeGroup.on('mouseout', () => {
      this.onMouseOutShapeGroup();
      this.#mouseOverShapeGroup = undefined;
    });
  }

  /**
   * Remove shape group mouse over and out listeners.
   *
   * @param {Konva.Group} shapeGroup The shape group.
   */
  #removeShapeOverListeners(shapeGroup) {
    shapeGroup.off('mouseover');
    shapeGroup.off('mouseout');
  }

  /**
   * Add shape group listeners.
   *
   * @param {Konva.Group} shapeGroup The shape group to set on.
   * @param {Annotation} annotation The associated annotation.
   * @param {DrawLayer} drawLayer The origin draw layer.
   */
  addShapeGroupListeners(shapeGroup, annotation, drawLayer) {
    // shape mouse over
    this.#addShapeOverListeners(shapeGroup);

    // make shape draggable
    this.#addShapeListeners(shapeGroup, annotation, drawLayer);

    // make label draggable
    this.#addLabelListeners(shapeGroup, annotation, drawLayer);

    // double click handling: update annotation text
    shapeGroup.on('dblclick', () => {
      // original text expr
      const originalTextExpr = annotation.textExpr;

      const onSaveCallback = (annotation) => {
        // new text expr
        const newTextExpr = annotation.textExpr;
        // create annotation update command
        const command = new UpdateAnnotationCommand(
          annotation,
          {textExpr: originalTextExpr},
          {textExpr: newTextExpr},
          drawLayer.getDrawController()
        );
        // add command to undo stack
        this.#app.addToUndoStack(command);
        // execute command
        command.execute();
      };

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

  /**
   * Add shape listeners.
   *
   * @param {Konva.Group} shapeGroup The shape group to get the shape from.
   * @param {Annotation} annotation The associated annotation.
   * @param {DrawLayer} drawLayer The origin draw layer.
   */
  #addShapeListeners(shapeGroup, annotation, drawLayer) {
    const konvaLayer = drawLayer.getKonvaLayer();

    const shape = shapeGroup.getChildren(isNodeNameShape)[0];
    if (!(shape instanceof Konva.Shape)) {
      return;
    }
    shape.draggable(true);

    // cache vars
    let dragStartPos;
    let previousPos;
    let originalProps;
    let colour;

    // drag start event handling
    shape.on('dragstart.draw', (event) => {
      // store colour
      colour = shape.stroke();
      // store pos
      dragStartPos = {
        x: shape.x(),
        y: shape.y()
      };
      previousPos = {
        x: event.target.x(),
        y: event.target.y()
      };
      // store original properties
      originalProps = {
        mathShape: annotation.mathShape,
        referencePoints: annotation.referencePoints
      };

      // display trash
      this.#trash.activate(drawLayer);
      // deactivate anchors to avoid events on null shape
      this.#shapeEditor.setAnchorsActive(false);
      // draw
      konvaLayer.draw();
    });

    // drag move event handling
    shape.on('dragmove.draw', (event) => {
      // if out of range, reset shape position and exit
      const range = getShapePositionRange(drawLayer.getBaseSize(), shape);
      if (range && !isShapeInRange(shape, range.min, range.max)) {
        shape.x(previousPos.x);
        shape.y(previousPos.y);
        return;
      }

      // move associated shapes (but not label)
      const diff = {
        x: event.target.x() - previousPos.x,
        y: event.target.y() - previousPos.y
      };
      const children = shapeGroup.getChildren();
      const labelWithDefaultPosition =
        typeof annotation.labelPosition === 'undefined';
      for (const child of children) {
        // skip shape and label with defined position
        if (child === event.target ||
          (child.name() === 'label' && !labelWithDefaultPosition)
        ) {
          continue;
        }
        // move other nodes
        child.move(diff);
      }

      // store pos
      previousPos = {
        x: event.target.x(),
        y: event.target.y()
      };

      // get appropriate factory
      const factory = annotation.getFactory();
      // update annotation
      factory.updateAnnotationOnTranslation(annotation, diff);
      // update label
      factory.updateLabelContent(annotation, shapeGroup, this.#app.getStyle());

      // highlight trash when on it
      const mousePoint = getMousePoint(event.evt);
      const offset = {
        x: mousePoint.getX(),
        y: mousePoint.getY()
      };
      const eventPos = this.#getRealPosition(offset, drawLayer);
      this.#trash.changeChildrenColourOnTrashHover(eventPos,
        shapeGroup, colour);
      // draw
      konvaLayer.draw();
    });

    // drag end event handling
    shape.on('dragend.draw', (event) => {
      // 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: shape.x(), y: shape.y()};
      // delete case
      const mousePoint = getMousePoint(event.evt);
      const offset = {
        x: mousePoint.getX(),
        y: mousePoint.getY()
      };
      const eventPos = this.#getRealPosition(offset, drawLayer);
      if (this.#trash.isOverTrash(eventPos)) {
        // compensate for the drag translation
        shapeGroup.x(dragStartPos.x);
        shapeGroup.y(dragStartPos.y);
        // disable editor
        this.#shapeEditor.disable();
        this.#shapeEditor.reset();
        this.#trash.changeGroupChildrenColour(shapeGroup, colour);
        // reset math shape (for undo)
        annotation.mathShape = originalProps.mathShape;
        annotation.referencePoints = originalProps.referencePoints;

        // create remove annotation command
        const command = new RemoveAnnotationCommand(
          annotation,
          drawLayer.getDrawController()
        );
        // add command to undo stack
        this.#app.addToUndoStack(command);
        // execute command: triggers draw remove
        command.execute();

        // reset cursor
        this.onMouseOutShapeGroup();
      } else {
        const translation = {
          x: pos.x - dragStartPos.x,
          y: pos.y - dragStartPos.y
        };
        if (translation.x !== 0 || translation.y !== 0) {
          // update annotation command
          const newProps = {
            mathShape: annotation.mathShape,
            referencePoints: annotation.referencePoints
          };
          const command = new UpdateAnnotationCommand(
            annotation,
            originalProps,
            newProps,
            drawLayer.getDrawController()
          );
          // add command to undo stack
          this.#app.addToUndoStack(command);
          // fire event manually since command is not executed
          this.#eventCallback({
            type: 'annotationupdate',
            data: annotation,
            dataid: drawLayer.getDataId(),
            keys: Object.keys(newProps)
          });
          // update original shape
          originalProps = {
            mathShape: newProps.mathShape,
            referencePoints: newProps.referencePoints
          };
        }
        // reset anchors
        this.#shapeEditor.setAnchorsActive(true);
        this.#shapeEditor.resetAnchors();
      }
      // draw
      konvaLayer.draw();
      // reset start position
      dragStartPos = {
        x: shape.x(),
        y: shape.y()
      };
    });
  }

  /**
   * Add label listeners.
   *
   * @param {Konva.Group} shapeGroup The shape group to get the label from.
   * @param {Annotation} annotation The associated annotation.
   * @param {DrawLayer} drawLayer The origin draw layer.
   */
  #addLabelListeners(shapeGroup, annotation, drawLayer) {
    const label = shapeGroup.getChildren(isNodeNameLabel)[0];
    if (!(label instanceof Konva.Label)) {
      return;
    }
    label.draggable(true);

    // cache vars
    let dragStartPos;
    let originalLabelPosition;

    // drag start event handling
    label.on('dragstart.draw', (/*event*/) => {
      // store pos
      dragStartPos = {
        x: label.x(),
        y: label.y()
      };
      // store original position
      originalLabelPosition = annotation.labelPosition;
    });

    // drag end event handling
    label.on('dragend.draw', (/*event*/) => {
      const translation = {
        x: label.x() - dragStartPos.x,
        y: label.y() - dragStartPos.y
      };
      if (translation.x !== 0 || translation.y !== 0) {
        const newLabelPosition = new Point2D(label.x(), label.y());
        // set label position
        annotation.labelPosition = newLabelPosition;
        // update annotation command
        const command = new UpdateAnnotationCommand(
          annotation,
          {labelPosition: originalLabelPosition},
          {labelPosition: newLabelPosition},
          drawLayer.getDrawController()
        );
        // add command to undo stack
        this.#app.addToUndoStack(command);
        // fire event manually since command is not executed
        this.#eventCallback({
          type: 'annotationupdate',
          data: annotation,
          dataid: drawLayer.getDataId(),
          keys: ['labelPosition']
        });
        // update original position
        originalLabelPosition = newLabelPosition;
      }
      dragStartPos = {x: label.x(), y: label.y()};
    });
  }

  /**
   * Remove shape group listeners.
   *
   * @param {Konva.Group} shapeGroup The shape group to set off.
   */
  removeShapeListeners(shapeGroup) {
    // mouse over
    this.#removeShapeOverListeners(shapeGroup);
    // double click
    shapeGroup.off('dblclick');
    // remove listeners from shape
    const shape = shapeGroup.getChildren(isNodeNameShape)[0];
    if (shape instanceof Konva.Shape) {
      shape.draggable(false);
      shape.off('dragstart.draw');
      shape.off('dragmove.draw');
      shape.off('dragend.draw');
    }
    // remove listeners from label
    const label = shapeGroup.getChildren(isNodeNameLabel)[0];
    if (label instanceof Konva.Label) {
      label.draggable(false);
      label.off('dragstart.draw');
      label.off('dragend.draw');
    }
  }
} // DrawShapeHandler class