react-diagrams icon indicating copy to clipboard operation
react-diagrams copied to clipboard

How to limit out-ports to only have a single link, allow moving of links, and how to delete a link

Open asnaseer-resilient opened this issue 3 years ago • 17 comments

I am using v6.2.0 of this amazing library. I want a system with nodes that have a single in-port and multiple out-ports. I want to restrict this such that the user can only link an out-port to an in-port (i.e. the source port of a link must be an out-port and the target port must be an in-port). The user should not be able to create "loose links" (i.e. links that have one end not connected to any port). In addition, each out-port can only have a single link attached to it. I have managed to achieve this by doing the following:

...
  const engine = createEngine();
  const state = engine.getStateMachine().getCurrentState();
  if (state instanceof DefaultDiagramState) {
    state.dragNewLink.config.allowLooseLinks = false;
  }
...
  const model = new DiagramModel();
  ...
  model.registerListener({
    linksUpdated: (e: any) => {
      if (e.isCreated) {
        const link = e.link;
        const sourcePort = link.getSourcePort() as DefaultPortModel;

        if (Object.keys(sourcePort.getLinks()).length > 1) {
          link.remove();
        } else if (sourcePort.getOptions().in) {
          link.remove();
        }
      }
    }
  });
  engine.setModel(model);
...

Here is an example of what I can create so far: Screenshot 2020-08-16 at 17 37 59

Questions

  1. Is this the best way of achieving this?

  2. I also want the ability for the user to drag the endpoint of a link from one out-port to another out-port (or from one in-port to another in-port). How can this be done?

  3. I am using the DefaultLinkModel and see that when you click on a link it adds a new control point to it. However, the nice Bezier curves then turn into straight lines. Is there a way to keep these lines as curves when additional control points are added? With a new point added, I can press the delete key on the keyboard to delete that point if I want to. However, I cannot work out how to just select a link so that I can delete it using the same mechanism. Could you please explain how to delete links?

asnaseer-resilient avatar Aug 16 '20 16:08 asnaseer-resilient

user can only link an out-port to an in-port and each out-port can only have a single link attached to it

See the canLinkToPort method. (You can provide this logic through extending the base models)

The user should not be able to create "loose links"

see allowLooseLinks in DragNewLinkState

dylanvorster avatar Sep 23 '20 10:09 dylanvorster

Thanks for the pointers.

I have already defined my own PortModel with canLinkToPort as follows:

  public canLinkToPort(port: PortModel): boolean {
    if (port instanceof CallFlowPortModel) {
      return this.options.in !== port.getOptions().in;
    }

    return true;
  }

So I guess I am doing this in the recommended manner?

Also, I am already setting allowLooseLinks to false in the code snippet I showed:

...
  if (state instanceof DefaultDiagramState) {
    state.dragNewLink.config.allowLooseLinks = false;
  }
...

Or did you mean I should set this flag using a different mechanism?

Any thoughts on my last two points in the original post?

asnaseer-resilient avatar Sep 25 '20 09:09 asnaseer-resilient

In your canLinkToPort, it seems like you're missing the "each out-port can only have a single link attached to it" part.

Here's how I implemented it: logossim PortModel

Or, in other words, something like that:

public canLinkToPort(port: PortModel) {
  if (!(port instanceof CallFlowPortModel)) return true;
  if (this.options.in === port.getOptions().in) return false; // in->in / out->out

  const out = this.options.in ? port : this; // one of the ports is necessarily out
  const outLinks = Object.values(out.getLinks()); // gets all links attached to this out port
  if (outLinks.length > 0) return false; // if there is any already, disallow linking

  return true;
}

renato-bohler avatar Sep 25 '20 11:09 renato-bohler

@renato-bohler Thanks for your suggestion but I found with your method, when the user clicks and drags from a port that is already linked, it shows the dotted link line but when they try to attach it to any other port it disappears at that point. With the way I have done (i.e. using model.registerListener), if the user clicks and drags from a port that already has a link it doesn't even show them a dotted link line. I prefer this approach - i.e. fail early on invalid links.

BTW: Your suggestion had a minor error (I think) - the last check should have been:

if (outLinks.length > 1) return false; // if there is any already, disallow linking

asnaseer-resilient avatar Sep 25 '20 12:09 asnaseer-resilient

Hmm yeah, the canLinkToPort method will be activated once the user drops the link to a port. If it returns false, the link will be deleted.

If you want to disallow links from being even dragged on output ports that have a link already, I would suggest you to implement your own DragNewLinkState

After this line:

https://github.com/projectstorm/react-diagrams/blob/361fbe4ffed7d5b485c9d7e871cf891c55d6c4d7/packages/react-diagrams-core/src/states/DragNewLinkState.ts#L43

You could add something like:

if (
    port instanceof CallFlowPortModel &&
    !port.getOptions().in &&
    Object.values(port.getLinks()).length > 0 // greater than zero, means that the port has at least one link
) {
    this.eject();
    return;
}

renato-bohler avatar Sep 25 '20 12:09 renato-bohler

Interesting... Looking at the code in DragNewLinkState I am now wondering if I could achieve what I want by just locking the port once it has been linked - what do you think?

asnaseer-resilient avatar Sep 25 '20 12:09 asnaseer-resilient

Ok, I tried modifying my code to this:

  public canLinkToPort(port: PortModel): boolean {
    if (port instanceof CallFlowPortModel) {
      const canLink = this.options.in !== port.getOptions().in;

      if (canLink) {
        this.setLocked(true);
      }

      return canLink;
    }

    return true;
  }

and then removing the model.registerListener({ linksUpdated: (e: any) => {...} }) and it still gives me what I want. Thanks for the insights.

I now just have two remaining questions:

  1. I also want the ability for the user to drag the endpoint of a link from one out-port to another out-port (or from one in-port to another in-port). This is to allow them to move a link between two existing ports. How can this be done?

  2. I am using the DefaultLinkModel and see that when you click on a link it adds a new control point to it. However, the nice Bezier curves then turn into straight lines. Is there a way to keep these lines as curves when additional control points are added? With a new point added, I can press the delete key on the keyboard to delete that point if I want to. However, I cannot work out how to just select a link so that I can delete it using the same mechanism. Could you please explain how to delete links?

asnaseer-resilient avatar Sep 25 '20 13:09 asnaseer-resilient

Interesting, that'll work aswell. It may have a downside, though: once a link is removed, you'll have unlock the ports this link was connected to. If you don't do that, you'll end up "bricking" ports that have been used.


  1. I'm not sure if I understand correctly. You want to be able to grab a port and detach its link, so the user can move it to another port? If that is the case, customizing DragNewLinkState might be a good idea. You can add a logic like (just a draft, not tested hehe):
this.registerAction(
  new Action({
    type: InputType.MOUSE_DOWN,
    fire: (event: ActionEvent<MouseEvent, PortModel>) => {
      this.port = this.engine.getMouseElement(event.event) as PortModel;
      if (!this.config.allowLinksFromLockedPorts && this.port.isLocked()) {
        this.eject();
        return;
      }
      const portLink = Object.values(this.port.getLinks())[0];
      if (portLink) { // if port has link already
        this.link = portLink; // the existing link is the one to be dragged
        this.link.setTargetPort(null); // remove the target port, but keep the source port
      } else {
        this.link = this.port.createLinkModel();

        // if no link is given, just eject the state
        if (!this.link) {
          this.eject();
          return;
        }
        this.link.setSourcePort(this.port);
      }

      this.link.setSelected(true);
      this.engine.getModel().addLink(this.link);
      this.port.reportPosition();
    }
  })
  );
  1. To customize how your link behaves/looks, you'll have to implement a custom link. The behavior you described is implemented here:

https://github.com/projectstorm/react-diagrams/blob/51c6bd2391f7a122a114b2bac1965e7957744b1d/packages/react-diagrams-defaults/src/link/DefaultLinkModel.ts#L57-L82

And here:

https://github.com/projectstorm/react-diagrams/blob/51c6bd2391f7a122a114b2bac1965e7957744b1d/packages/react-diagrams-defaults/src/link/DefaultLinkWidget.tsx#L108-L141

renato-bohler avatar Sep 25 '20 17:09 renato-bohler

@renato-bohler Thanks again for your pointers on how to do this.

In order to customise DragNewLinkState, I think I have to copy its code and also the code for DefaultDiagramState into my project, and then customise DragNewLinkState as you suggested. I would then do something like this:

  const engine = createEngine();
  engine.getStateMachine().setState(new MyDefaultDiagramState());

Is this the correct way of doing this type of customisation?

Also, in my project, the copied code for DragNewLinkState gives me these Typescript errors for the fire properties on lines 42 and 66 of the original DragNewLinkState (i.e. without any customisations at all):

Type '(event: ActionEvent<MouseEvent, PortModel>) => void' is not assignable to type '(event: ActionEvent<MouseEvent<Element, MouseEvent> | WheelEvent<Element> | KeyboardEvent<Element>, BaseModel<BaseModelGenerics>>) => void'.
  Types of parameters 'event' and 'event' are incompatible.
    Type 'ActionEvent<MouseEvent<Element, MouseEvent> | WheelEvent<Element> | KeyboardEvent<Element>, BaseModel<BaseModelGenerics>>' is not assignable to type 'ActionEvent<MouseEvent<Element, MouseEvent>, PortModel<PortModelGenerics>>'.
      Type 'MouseEvent<Element, MouseEvent> | WheelEvent<Element> | KeyboardEvent<Element>' is not assignable to type 'MouseEvent<Element, MouseEvent>'.
        Type 'KeyboardEvent<Element>' is missing the following properties from type 'MouseEvent<Element, MouseEvent>': button, buttons, clientX, clientY, and 9 more.ts(2322)
Action.d.ts(26, 5): The expected type comes from property 'fire' which is declared here on type 'ActionOptions'

These are the dependencies in my package.json file:

    "@emotion/core": "^10.0.28",
    "@emotion/styled": "^10.0.27",
    "@material-ui/core": "^4.11.0",
    "@projectstorm/geometry": "^6.2.0",
    "@projectstorm/react-canvas-core": "^6.2.0",
    "@projectstorm/react-diagrams": "^6.2.0",
    "@projectstorm/react-diagrams-core": "^6.2.0",
    "@projectstorm/react-diagrams-defaults": "^6.2.0",
    "@projectstorm/react-diagrams-routing": "^6.2.0",
    "@testing-library/jest-dom": "^4.2.4",
    "@testing-library/react": "^9.5.0",
    "@testing-library/user-event": "^7.2.1",
    "@types/classnames": "^2.2.10",
    "@types/jest": "^24.9.1",
    "@types/node": "^12.12.54",
    "@types/react": "^16.9.46",
    "@types/react-dom": "^16.9.8",
    "classnames": "^2.2.6",
    "closest": "0.0.1",
    "dagre": "^0.8.5",
    "lodash": "^4.17.19",
    "ml-matrix": "^6.5.1",
    "pathfinding": "^0.4.18",
    "paths-js": "^0.4.11",
    "react": "^16.13.1",
    "react-dom": "^16.13.1",
    "react-scripts": "3.4.1",
    "resize-observer-polyfill": "^1.5.1",
    "typescript": "^3.7.5"

Would you know how to resolve these Typescript errors?

asnaseer-resilient avatar Sep 27 '20 20:09 asnaseer-resilient

I managed to solve the Typescript error by changing the fire handler to this:

		this.registerAction(
			new Action({
				type: InputType.MOUSE_DOWN,
				fire: (event) => {
					this.port = this.engine.getMouseElement(event.event as MouseEvent<Element, globalThis.MouseEvent>) as PortModel;
					if (!this.config.allowLinksFromLockedPorts && this.port.isLocked()) {
						this.eject();
						return;
					}
					this.link = this.port.createLinkModel();

					// if no link is given, just eject the state
					if (!this.link) {
						this.eject();
						return;
					}
					this.link.setSelected(true);
					this.link.setSourcePort(this.port);
					this.engine.getModel().addLink(this.link);
					this.port.reportPosition();
				}
			})
		);

@renato-bohler I would really appreciate it if you could please confirm if I have understood correctly how to customise DragNewLinkState.

asnaseer-resilient avatar Sep 28 '20 10:09 asnaseer-resilient

It's hard to confirm without looking at the full code :sweat_smile:

But in my case, I've used pushState instead of setState. Not sure if that makes any difference (see here).

And this is my States class.

If you did something similar to this, and your custom states are called when dragging a new link, then it is working.

renato-bohler avatar Sep 28 '20 12:09 renato-bohler

@renato-bohler Ok, my customised DragNewLinkState is working nicely now - thanks.

I found I can actually use this as a way to delete links. If I have linked an out port to an in port and now wish to delete it, all I do is drag from the out port and leave it loose which then causes it to be removed.

All that is left now is how to get smooth lines for the links. I can see from your hints that I will need to customise DefaultLinkModel.ts and DefaultLinkWidget.tsx. These are used by many of the classes in this repo so I cannot see any easy way for me to customise them. I think the only way I can do it is if I fork the entire repo and then customise them unless you can suggest a better way of achieving this?

asnaseer-resilient avatar Sep 28 '20 16:09 asnaseer-resilient

@asnaseer-resilient I am trying to resolve same issue. Are you able to customise link so that there will be single link between two nodes?

ShrutiChandak19 avatar Feb 17 '22 13:02 ShrutiChandak19

@ShrutiChandak19 I have managed to restrict the number of links to 1 by providing my own port model that extends this libraries PortModel. In the constructor of this, I call the super class and pass it some options, one of which is maximumLinks: 1.

Here is some sample code that might illustrate this better:

import { PortModel, PortModelGenerics, PortModelOptions } from '@projectstorm/react-diagrams';
...

export class MyPortModel extends PortModel<PortModelGenerics> {
  constructor(options: PortModelOptions) {
    super({ ...options, maximumLinks: 1 });
  }
  ...
}

Hope that helps.

asnaseer-resilient avatar Feb 17 '22 21:02 asnaseer-resilient

@wevertoum and I have worked around it a couple of years ago. What you have to do is customize the PortModel, so you will end up having something like this:

type PortDirection = "in" | "out"

export default class CustomPortModel extends DefaultPortModel {
  constructor(direction: PortDirection) {
    super({
      name: direction,
      locked: direction === "in",
      maximumLinks: direction === "in" ? 1 : undefined,
      type: "horizontal-port",
      alignment: direction === "in" ? PortModelAlignment.RIGHT : PortModelAlignment.LEFT,
    })
  }
  
  canLinkToPort(targetPort: DefaultPortModel) {
    const links = Object.values(this.getLinks() ?? {})
    return (
      this.getParent().getId() !== targetPort.getParent().getId() &&
      links.length === 1 &&
      targetPort.getName() === "in"
    )
  }
}

Using the property name as a type handler for "in" or "out" you could implement special validators for each type (like maximum links, for example).

erickvieira-picpay avatar Feb 18 '22 13:02 erickvieira-picpay

Currently in my code I can connect multiple link between 2 ports and if I restrict outport then it’s not allowing me to connect to other input port as well. Is there any way I can handle this?

ShrutiChandak19 avatar Feb 18 '22 14:02 ShrutiChandak19

That's how I did it

const model = new DiagramModel();
model.registerListener({
	linksUpdated:(event : any) => {
		const { link, isCreated } = event;
		link.registerListener({
			targetPortChanged:(link  :any) => {
				if(isCreated){
					const {sourcePort, targetPort} = link.entity;
					if(Object.keys(targetPort.getLinks()).length > 1){
						model.removeLink(link.entity);
					}else{
						let { parent : 
								{ options : sourceOptions }
							} = sourcePort;

						let { parent : 
								{ options : targetOptions }
							} = targetPort;

						if(sourceOptions.dataType === 'start' && targetOptions.dataType === 'value'){
							model.removeLink(link.entity);
						}
					}
				}
			}
		});				
	  }
})

Thanks

vivek-verse avatar Jun 14 '22 16:06 vivek-verse