projectstorm / react-diagrams

a super simple, no-nonsense diagramming library written in react that just works
https://projectstorm.cloud/react-diagrams
MIT License
8.6k stars 1.17k forks source link

Unable to use mouse to select input elements inside a custom node #738

Closed asnaseer-resilient closed 3 years ago

asnaseer-resilient commented 3 years ago

Hi, I am using v6.2.0 of this library and have customised my nodes to allow users to set certain attributes. Here is an image of such a setup:

Screenshot 2020-10-04 at 22 32 11

The white rectangles are customised nodes. The empty circle above a node represents a customised input port and the empty circles below a node represent customised output ports. If the user clicks inside the Admin telephone number field in the above image, then, as soon as they try to select the existing text inside that field (+44 in this case) by pressing the mouse down and then dragging to select the +44, it seems that the library takes over that event and start moving the actual node itself. Is there a simple way to disable this behaviour while the user is in any one of these fields of a custom node?

renato-bohler commented 3 years ago

Hey @asnaseer-resilient

You can achieve this by customizing the MoveItemsState, here:

https://github.com/projectstorm/react-diagrams/blob/8df5ff94b6ba5304624f8d739d9905331595f9ae/packages/react-canvas-core/src/states/MoveItemsState.ts#L21-L37

You can add a check like this:

// ...
fire: (event: ActionEvent<React.MouseEvent>) => {
  // If the event happened on a <input />, skip moving the node
  if (event.event.target.tagName === 'INPUT') {
    this.eject();
    return;
  }
  // ...
}
// ...

You can change the condition to whatever you want... for example, you can use:

if (event.event.target.hasAttribute('data-avoid-dragging'))

And add

<input
  name="my-input"
+ data-avoid-dragging
/>

This way, you'll be more specific about what DOM elements should activate dragging on the nodes.


Here's an example of how to customize State on your project:

https://github.com/projectstorm/react-diagrams/blob/8ded9cd30e2d605c31f24270d38b1053dbda64dc/packages/diagrams-demo-gallery/demos/demo-alternative-linking/index.tsx#L23-L24

asnaseer-resilient commented 3 years ago

@renato-bohler Thank you very much yet again for your excellent pointers. I will try this out this week.

asnaseer-resilient commented 3 years ago

Actually I just tried your suggestion and found that I had to do a little bit more to get this to work. This is what I ended up doing:

  1. Customised MoveItemsState with a slight modification to what you suggested by getting it to walk up the DOM tree until it either finds an element with the data-no-drag attribute or it reaches the top of the DOM tree. I did this because all my node form elements are contained in a common form container so I only had to add that attribute to this common form container. Here is a snippet of my code:

        ...
        fire: event => {
          const target = event.event.target as HTMLElement | null;
          let parentElement = target;
          while (parentElement && !parentElement.hasAttribute('data-no-drag')) {
            parentElement = parentElement.parentElement;
          }
    
          if (parentElement && parentElement.hasAttribute('data-no-drag')) {
            this.eject();
            return;
          }
         ...
  2. Customised DragDiagramItemsState to extend the customised MoveItemsState instead of the default one
  3. Customised DefaultState to create the customised MoveItemsState instead of the default one
  4. Enhanced my already customised DefaultDiagramState to construct the customised DragDiagramItemsState instead of the default one

Everything now works just as I wanted.

I really appreciate the time you take to explain things really well.

rzfzr commented 2 years ago

@asnaseer-resilient could you please give us a little sample code? The examples are lacking user input as showed in your image. I guess #911 would be thankful as well

asnaseer-resilient commented 2 years ago

@asnaseer-resilient could you please give us a little sample code? The examples are lacking user input as showed in your image. I guess #911 would be thankful as well

I placed all the customised components in their own folder in my project - here is the code for each of the customisations that I mentioned in my comments above:

MoveItemsState.ts

import { MouseEvent } from 'react';

import {
  Action,
  ActionEvent,
  AbstractDisplacementState,
  AbstractDisplacementStateEvent,
  BaseModel,
  BasePositionModel,
  CanvasEngine,
  InputType,
  State
} from '@projectstorm/react-canvas-core';
import { Point } from '@projectstorm/geometry';

export class MoveItemsState<E extends CanvasEngine = CanvasEngine> extends AbstractDisplacementState<E> {
  initialPositions: {
    [id: string]: {
      point: Point;
      item: BaseModel;
    };
  } = {};

  constructor() {
    super({
      name: 'move-items'
    });
    this.registerAction(
      new Action({
        type: InputType.MOUSE_DOWN,
        fire: event => {
          const target = event.event.target as HTMLElement | null;
          // find the first parent element that is allowed to be dragged (an element can be marked as non-draggable by specifying the "data-no-drag" attribute)
          let parentElement = target;
          while (parentElement && !parentElement.hasAttribute('data-no-drag')) {
            parentElement = parentElement.parentElement;
          }

          // if we could not find any draggable parent element then reject the drag
          if (parentElement && parentElement.hasAttribute('data-no-drag')) {
            this.eject();
            return;
          }

          const element = this.engine.getActionEventBus().getModelForEvent(event as ActionEvent<MouseEvent<Element, globalThis.MouseEvent>>);
          if (!element) {
            return;
          }
          if (!element.isSelected()) {
            this.engine.getModel().clearSelection();
          }
          element.setSelected(true);
          this.engine.repaintCanvas();
        }
      })
    );
  }

  activated(previous: State) {
    super.activated(previous);
    this.initialPositions = {};
  }

  fireMouseMoved(event: AbstractDisplacementStateEvent) {
    const items = this.engine.getModel().getSelectedEntities();
    const model = this.engine.getModel();
    for (const item of items) {
      if (item instanceof BasePositionModel) {
        if (item.isLocked()) {
          continue;
        }
        if (!this.initialPositions[item.getID()]) {
          this.initialPositions[item.getID()] = {
            point: item.getPosition(),
            item: item
          };
        }

        const pos = this.initialPositions[item.getID()].point;
        item.setPosition(model.getGridPosition(pos.x + event.virtualDisplacementX), model.getGridPosition(pos.y + event.virtualDisplacementY));
      }
    }
    this.engine.repaintCanvas();
  }
}

DragDiagramItemsState.ts

import { MouseEvent } from 'react';
import * as _ from 'lodash';

import { Action, InputType } from '@projectstorm/react-canvas-core';
import { DiagramEngine, LinkModel, PointModel, PortModel } from '@projectstorm/react-diagrams';

import { MoveItemsState } from './MoveItemsState';

export class DragDiagramItemsState extends MoveItemsState<DiagramEngine> {
  constructor() {
    super();
    this.registerAction(
      new Action({
        type: InputType.MOUSE_UP,
        fire: event => {
          const item = this.engine.getMouseElement(event.event as MouseEvent<Element, globalThis.MouseEvent>);
          if (item instanceof PortModel) {
            _.forEach(this.initialPositions, position => {
              if (position.item instanceof PointModel) {
                const link = position.item.getParent() as LinkModel;

                // only care about the last links
                if (link.getLastPoint() !== position.item) {
                  return;
                }
                if (link.getSourcePort().canLinkToPort(item)) {
                  link.setTargetPort(item);
                  item.reportPosition();
                  this.engine.repaintCanvas();
                }
              }
            });
          }
        }
      })
    );
  }
}

DefaultState.ts

import { MouseEvent } from 'react';

import { Action, ActionEvent, DragCanvasState, InputType, SelectingState, State } from '@projectstorm/react-canvas-core';

import { MoveItemsState } from './MoveItemsState';

export class DefaultState extends State {
  constructor() {
    super({
      name: 'default'
    });
    this.childStates = [new SelectingState()];

    // determine what was clicked on
    this.registerAction(
      new Action({
        type: InputType.MOUSE_DOWN,
        fire: event => {
          const element = this.engine.getActionEventBus().getModelForEvent(event as ActionEvent<MouseEvent<Element, globalThis.MouseEvent>>);

          // the canvas was clicked on, transition to the dragging canvas state
          if (!element) {
            this.transitionWithEvent(new DragCanvasState(), event);
          } else {
            this.transitionWithEvent(new MoveItemsState(), event);
          }
        }
      })
    );
  }
}

DefaultDiagramState.ts

import { MouseEvent } from 'react';

import { SelectingState, State, Action, InputType, ActionEvent, DragCanvasState } from '@projectstorm/react-canvas-core';
import { DiagramEngine, PortModel } from '@projectstorm/react-diagrams';

import { DragNewLinkState } from './DragNewLinkState';
import { DragDiagramItemsState } from './DragDiagramItemsState';

export class DefaultDiagramState extends State<DiagramEngine> {
  dragCanvas: DragCanvasState;
  dragNewLink: DragNewLinkState;
  dragItems: DragDiagramItemsState;

  constructor() {
    super({
      name: 'default-diagrams'
    });
    this.childStates = [new SelectingState()];
    this.dragCanvas = new DragCanvasState();
    this.dragNewLink = new DragNewLinkState({ allowLooseLinks: false }); // Do not allow dangling links
    this.dragItems = new DragDiagramItemsState();

    // determine what was clicked on
    this.registerAction(
      new Action({
        type: InputType.MOUSE_DOWN,
        fire: event => {
          const element = this.engine.getActionEventBus().getModelForEvent(event as ActionEvent<MouseEvent<Element, globalThis.MouseEvent>>);

          // the canvas was clicked on, transition to the dragging canvas state
          if (!element) {
            this.transitionWithEvent(this.dragCanvas, event);
          } else {
            // initiate dragging a new link
            if (element instanceof PortModel) {
              this.transitionWithEvent(this.dragNewLink, event);
            }
            // move the items (and potentially link points)
            else {
              this.transitionWithEvent(this.dragItems, event);
            }
          }
        }
      })
    );

    // touch drags the canvas
    this.registerAction(
      new Action({
        type: InputType.TOUCH_START,
        fire: event => {
          this.transitionWithEvent(this.dragCanvas, event);
        }
      })
    );
  }
}

I then created a widget to display the nodes. In my case this widget display a single in-port at the top, followed by a header, followed by a form containing user input elements (this has the data-no-drag marker on it), followed by zero or more out-ports. Rough skeleton of this code is:

<div>
  <div>...elements to render the single in-port go here...</div>
  <div>...elements to render the node header go here...</div>
  <div data-no-drag>...elements to render the user input elements go here...</div>
  <div>...elements to render zero or more out-ports go here...</div>
</div>

Hope this helps.

rzfzr commented 2 years ago

Greatly appreciated!