Skip to main content

Flowchart Builder (React)

This app is written using Hooks and is designed to work with React 17 or 18, but we import React 18. The code is very similar to the code in the vanilla flowchart builder, but it's just arranged slightly differently. The biggest difference is in the node rendering - in the Toolkit's React integration we render each node as a component. We also use components from the Toolkit's react integration for the miniview and pan/zoom controls.

On this page we discuss the React implementation. There are implementations available for each of our library integrations, and also for vanilla JS:

The source code for this application is available on Github at https://github.com/jsplumb/jsplumbtoolkit-applications.

Overview

The main features of this application are:

  • A drawing canvas which renders nodes as SVG shapes, and allows a user to resize nodes. Nodes are dragged on a grid, and resizing is constrained to the same grid.

  • A palette of SVG shapes which can be dragged onto the canvas.

  • Editable edge paths, labels and colors

  • Editable node labels, fill/outline color and text color

  • A miniview

  • A set of controls for managing zoom, undo, redo, etc.

Note that the code snippets on this page refer to constants in many places, which, for the sake of brevity, we have not included. You can find these in the source code on Github.

Throughout the code you will see references to a variable called anchorPositions (which is defined in and exported by FlowchartComponent). This is an array of anchor positions that dictate where on each node edges can be connected:

export const anchorPositions = [
{ x:0, y:0.5, ox:-1, oy:0, id:"left" },
{ x:1, y:0.5, ox:1, oy:0, id:"right" },
{ x:0.5, y:0, ox:0, oy:-1, id:"top" },
{ x:0.5, y:1, ox:0, oy:1, id:"bottom" }
]

Each anchor position has x, y, ox and oy values - see anchors for details of this. In addition, we set an id on each anchor, which is not part of the data model but rather is used for CSS purposes. This is discussed below.

Imports


"dependencies":{
"@jsplumbtoolkit/browser-ui-react":"^6.2.0"
}

Setup

App.js

The top level component wraps FlowchartComponent:

import './App.css';

import FlowchartComponent from './FlowchartComponent'

function App() {
return (
<div className="App">
<FlowchartComponent/>
</div>
);
}

export default App;

FlowchartComponent

This is where the bulk of the code resides. The initial setup inside the component is as follows:

export default function FlowchartComponent() {

const shapeLibrary = new ShapeLibraryImpl(FLOWCHART_SHAPES)

const pathEditor = useRef(null)
const surfaceComponent = useRef(null)
const miniviewContainer = useRef(null)
const controlsContainer = useRef(null)
const paletteContainer = useRef(null)
const inspectorContainer = useRef(null)

/**
* Generator for data for nodes dragged from palette.
* @param el
*/
const dataGenerator = (el) => {
return {
fill:DEFAULT_FILL,
outline:DEFAULT_STROKE,
textColor:DEFAULT_TEXT_COLOR
}
}

const toolkit = newInstance({
// set the Toolkit's selection mode to 'isolated', meaning it can select a set of edges, or a set of nodes, but it
// cannot select a set of nodes and edges. In this demonstration we use an inspector that responds to events from the
// toolkit's selection, so setting this to `isolated` helps us ensure we dont try to inspect edges and nodes at the same
// time.
selectionMode:SelectionModes.isolated,
// This is the payload to set when a user begins to drag an edge - we return values for the
// edge's label, color and line style. If you wanted to implement a mechanism whereby you have
// some "current style" you could update this method to return some dynamically configured
// values.
beforeStartConnect:(node, edgeType) => {
return {
[PROPERTY_LABEL]:"",
[PROPERTY_COLOR]:DEFAULT_STROKE,
[PROPERTY_LINE_STYLE]:EDGE_TYPE_TARGET_ARROW
}
}
})


initializeOrthogonalConnectorEditors()

...

}

Points to note:

  • we create a shape library which we will use to render SVG inside of our nodes, and also to provide a palette of nodes that can be dragged onto the canvas.
  • we assign a number of refs for things that we want to create in our useEffect (discussed below)
  • dataGenerator is the method used to get an initial dataset when the user is dragging new nodes onto the canvas.
  • We create an instance of the Toolkit and provide two arguments - what selection mode to use, and a method to get an initial payload when the user drags a new edge:
{
// set the Toolkit's selection mode to 'isolated', meaning it can select a set of edges, or a set of nodes, but it cannot select a set of nodes and edges. In this demonstration we use an inspector that responds to events from the toolkit's selection, so setting this to `isolated` helps us ensure we dont try to inspect edges and nodes at the same time.
selectionMode:SelectionModes.isolated,
// This is the payload to set when a user begins to drag an edge - we return values for the edge's label, color and line style. If you wanted to implement a mechanism whereby some "current style" you could update this method to return some dynamically configured values.
beforeStartConnect:(node, edgeType) => {
return {
[PROPERTY_LABEL]:"",
[PROPERTY_COLOR]:DEFAULT_STROKE,
[PROPERTY_LINE_STYLE]:EDGE_TYPE_TARGET_ARROW
}
}
}
  • We make a call to initializeOrthogonalConnectorEditors(). This is just an insurance policy against tree shaking. Because of the way in which connector editors are instantiated in 6.x (ie. by name), in some setups a tree shaker will omit them from the bundle. In 7.x this will not be an issue.

Rendering the canvas

The template for the flowchart component is as follows:

<div style={{width:"100%",height:"100%",display:"flex"}}>
<div className="jtk-demo-canvas">
<JsPlumbToolkitSurfaceComponent renderParams={renderParams} toolkit={toolkit} view={view} ref={ surfaceComponent }/>
<div className="controls" ref={ controlsContainer }/>
<div className="miniview" ref={ miniviewContainer }/>
</div>
<div className="jtk-demo-rhs">
<div className="node-palette sidebar" ref={paletteContainer}></div>
<div ref={inspectorContainer}/>
</div>
</div>

The canvas is rendered via the JsPlumbToolkitSurfaceComponent component, which ships in the Toolkit's React integration. We pass in a view and renderParams (which are defined in this class - see below), the Toolkit instance we just created, and a ref to use so that we can access the underlying surface in our useEffect method.

View params

const view = {
nodes: {
[DEFAULT]: {
jsx: (ctx) => {
return <NodeComponent ctx={ctx} shapeLibrary={shapeLibrary}/>
},
// allowed anchor positions for this node type
anchorPositions,
// allow any number of connections to this node type
maxConnections:-1,
events: {
[EVENT_TAP]: (params) => {
pathEditor.current.stopEditing()
// if zero nodes currently selected, or the shift key wasnt pressed, make this node the only one in the selection.
if (toolkit.getSelection()._nodes.length < 1 || params.e.shiftKey !== true) {
toolkit.setSelection(params.obj)
} else {
// if multiple nodes already selected, or shift was pressed, add this node to the current selection.
toolkit.addToSelection(params.obj)
}
}
}
}
},
// There are two edge types defined - 'yes' and 'no', sharing a common
// parent.
edges: {
[DEFAULT]: {
endpoint: BlankEndpoint.type,
connector: {
type: OrthogonalConnector.type,
options: {
cornerRadius: 5
}
},
cssClass:CLASS_FLOWCHART_EDGE,
labelClass:CLASS_EDGE_LABEL,
label:"{{label}}",
outlineWidth:10,
events: {
[EVENT_DBL_CLICK]: (params) => {
toolkit.removeEdge(params.edge)
},
[EVENT_CLICK]: (params) => {
toolkit.setSelection(params.edge)
pathEditor.current.startEditing(params.edge, {
deleteButton:true
})
}
}
}
}
}

This view is almost identical to the view in the render call for the vanilla app. The key difference is that in the React integration nodes are rendered by React components.

Render params

const renderParams = {
layout:{
type:AbsoluteLayout.type
},
grid:{
size:GRID_SIZE
},
events: {
[EVENT_CANVAS_CLICK]: (e) => {
toolkit.clearSelection()
pathEditor.current.stopEditing()
}
},
propertyMappings:{
edgeMappings:edgeMappings()
},
consumeRightClick: false,
dragOptions: {
filter: ".jtk-draw-handle, .node-action, .node-action i"
},
plugins:[
{
type:DrawingToolsPlugin.type,
options:{
widthAttribute:"width",
heightAttribute:"height"
}
},
{
type:LassoPlugin.type,
options: {
lassoInvert:true,
lassoEdges:true
}
},
{
type:BackgroundPlugin.type,
options:GRID_BACKGROUND_OPTIONS
}
],
zoomToFit:true
}

The render params are almost identical to the render params for the vanilla app, with one notable exception being that in the React app we use a JsPlumbToolkitMiniview (instantiated in the useEffect), whereas in the vanilla app we configure a miniview as a plugin. Another difference is that we do not have a modelEvents section in the render params for the React app, because we write that logic directly into the node component.

useEffect

We declare a useEffect method to complete our setup:

useEffect(() => {

pathEditor.current = new EdgePathEditor(surfaceComponent.current.surface, {activeMode:true})

// controls component. needs to be done here as it needs a reference to the surface.
const c = createRoot(controlsContainer.current)
c.render(<ControlsComponent surface={surfaceComponent.current.surface}/>)

// a miniview.
const m = createRoot(miniviewContainer.current)
m.render(
<JsPlumbToolkitMiniviewComponent surface={surfaceComponent.current.surface}/>
);

// palette from which to drag new shapes onto the canvas
const slp = createRoot(paletteContainer.current)
slp.render(<ShapeLibraryPaletteComponent surface={surfaceComponent.current.surface} shapeLibrary={shapeLibrary} container={paletteContainer.current} dataGenerator={dataGenerator}/>);

// node/edge inspector.
const ic = createRoot(inspectorContainer.current)
ic.render(<Inspector surface={surfaceComponent.current.surface} container={inspectorContainer.current} edgeMappings={edgeMappings()}/>)

// load an initial dataset
toolkit.load({url:"/copyright.json"})
}, [])

This method does several things:

  • Instantiates and stores on a ref an edge path editor.
  • Mounts a ControlsComponent (see below)
  • Mounts a JsPlumbToolkitMiniviewComponent (see Miniview)
  • Mounts a ShapeLibraryPaletteComponent (see dragging new nodes)
  • Mounts an inspector (see editing nodes and editing edges)
  • Loads an initial dataset.

Nodes

Rendering Nodes

In the view above, we map a single node type DEFAULT to a react component:

nodes: {
[DEFAULT]: {
jsx: (ctx) => {
return <NodeComponent ctx={ctx} shapeLibrary={shapeLibrary}/>
},
// allowed anchor positions for this node type
anchorPositions,
// allow any number of connections to this node type
maxConnections:-1,
events: {
[EVENT_TAP]: (params) => {
pathEditor.current.stopEditing()
// if zero nodes currently selected, or the shift key wasnt pressed, make this node the only one in the selection.
if (toolkit.getSelection()._nodes.length < 1 || params.e.shiftKey !== true) {
toolkit.setSelection(params.obj)
} else {
// if multiple nodes already selected, or shift was pressed, add this node to the current selection.
toolkit.addToSelection(params.obj)
}
}
}
}
}

ctx comes from the Toolkit's React integration. shapeLibrary was created at the start of the flowchart component, and is what this node component uses to render the SVG shape.

The component's code looks like this:

import * as React from 'react';

import { ShapeComponent } from "@jsplumbtoolkit/browser-ui-react"

import { anchorPositions } from "./FlowchartComponent";

export default function NodeComponent({ctx, shapeLibrary}) {

const { vertex, toolkit } = ctx;
const data = vertex.data;

return <div style={{width:data.width + 'px',height:data.height + 'px',color:data.textColor}} className="flowchart-object" data-jtk-target="true" data-jtk-target-port-type="target">
<span>{data.text}</span>
<ShapeComponent obj={data} shapeLibrary={shapeLibrary}/>

{anchorPositions.map(ap => <div className={"jtk-connect jtk-connect-" + ap.id} data-jtk-anchor-x={ap.x} data-jtk-anchor-y={ap.y} data-jtk-orientation-x={ap.ox} data-jtk-orientation-y={ap.oy} data-jtk-source="true" data-jtk-port-type="source"></div>)}

<div className="node-delete node-action delete" onClick={() => toolkit.removeNode(vertex)}></div>
</div>
}


Points to note:

  • We write out values for width, height and color into the root element's style. In this app, each node's backing data contains values for width and height, and we use a Drawing Tools plugin to allow the user to resize elements.
  • We use a ShapeComponent to write out the SVG for each node. See below.
  • We import anchorPositions from the main component and iterate through its values to write out a set of elements from which connections can be dragged
  • We use the removeNode() method from the Toolkit's BaseNodeComponent to remove a node from the dataset

Shape library

This application uses a shape library to support rendering SVG into node elements.

Configuration for this comes in two parts:

Instantiating a shape library

We do this in the constructor of the FlowchartComponent:


import { FLOWCHART_SHAPES } from "@jsplumbtoolkit/browser-ui"

export default function FlowchartComponent() {

const shapeLibrary = new ShapeLibraryImpl(FLOWCHART_SHAPES)

...
}

Rendering shapes

The shape library is injected into each node in the view:

const view = {
nodes: {
[DEFAULT]: {
jsx: (ctx) => {
return <NodeComponent ctx={ctx} shapeLibrary={shapeLibrary}/>
}
}
}
...
}

The node templates uses a ShapeComponent, which ships in the Toolkit's React integration:

<ShapeComponent obj={data} shapeLibrary={shapeLibrary}/>

ShapeComponent extracts the type member from the given object data and then renders an appropriate piece of SVG.


Dragging new nodes

This application makes use of a ShapeLibraryPaletteComponent to support dragging new nodes onto the canvas. It is mounted inside the FlowchartComponent's useEffect:

const slp = createRoot(paletteContainer.current)
slp.render(<ShapeLibraryPaletteComponent surface={surfaceComponent.current.surface} shapeLibrary={shapeLibrary} container={paletteContainer.current} dataGenerator={dataGenerator}/>);

We pass in 4 values:

  • surface The surface to attach to
  • shapeLibrary The shape library we created, which has the SVG to render our various node types.
  • container The DOM element to render into
  • dataGenerator A method to get an initial payload for any element that is being dragged out of the palette:
/**
* Generator for data for nodes dragged from palette.
* @param el
*/
const dataGenerator = (el) => {
return {
fill:DEFAULT_FILL,
outline:DEFAULT_STROKE,
textColor:DEFAULT_TEXT_COLOR
}
}

Resizing nodes

Node resize is supported via the inclusion of the drawing tools plugin, in the renderParams:

plugins:[
{
type:DrawingToolsPlugin.type,
options:{
widthAttribute:"width",
heightAttribute:"height"
}
}
]

In this application we set widthAttribute and heightAttribute because their defaults are w and h, and we are transitioning the Toolkit's UI code over to use width and height throughout the code, because those are the variable names used in the DOM. In 7.x the drawing tools plugin will be updated to use width and height by default.


Editing nodes

Nodes are edited via the InspectorComponent, which is a React component that internally creates an Inspector. The code is as follows:

export default function InspectorComponent({surface, edgeMappings}) {

const container = useRef(null)
const [currentType, setCurrentType] = useState('')
const [inspector, setInspector] = useState(null)

useEffect(() => {

setInspector(new Inspector({
container:container.current,
surface,
renderEmptyContainer:() => setCurrentType(''),
refresh:(obj, cb) => {
setCurrentType(obj.objectType)
// next tick
setTimeout(cb)
}
}))

}, [])

return <div ref={container}>

{ currentType === Node.objectType &&
<div className="jtk-inspector jtk-node-inspector">
<div>Text</div>
<input type="text" jtk-att="text" jtk-focus="true"/>
<div>Fill</div>
<input type="color" jtk-att="fill"/>
<div>Color</div>
<input type="color" jtk-att="textColor"/>
<div>Outline</div>
<input type="color" jtk-att="outline"/>
</div>
}

{ currentType === Edge.objectType &&
<div className="jtk-inspector jtk-edge-inspector">
<div>Label</div>
<input type="text" jtk-att="label"/>
<div>Line style</div>
<EdgeTypePickerComponent edgeMappings={edgeMappings} propertyName="lineStyle" inspector={inspector}/>
<div>Color</div>
<input type="color" jtk-att="color"/>
</div>
}

</div>

}

The call to create a new inspector takes four arguments:

  • container the DOM element the inspector should use to apply/extract values. Here it is the component's native element.
  • surface The surface to attach the inspector to.
  • renderEmptyContainer The inspector calls this method when nothing is selected. Here, we set the currentType member to an empty string, which will result in the template rendering an empty div element (see below).
  • refresh The inspector calls this method when it needs this component to render an appropriate UI for a newly selected object (or objects). This method passes in the selected object and a callback to invoke once the UI is ready. We set currentType to the object's type, and then we set a timeout which will invoke the callback after this tick is complete. When the timeout fires and the callback is invoked the UI will have been updated.

Edges

Rendering edges

Edges are rendered via the DEFAULT mapping in the edges section of the viewParams shown above. For more information on views, see this page.

edges: {
[DEFAULT]: {
endpoint:BlankEndpoint.type,
connector: {
type:OrthogonalConnector.type,
options:{
cornerRadius: 3,
alwaysRespectStubs:true
}
},
cssClass:CLASS_FLOWCHART_EDGE,
labelClass:CLASS_EDGE_LABEL,
label:"{{label}}",
events: {
[EVENT_CLICK]:(p) => {
toolkit.setSelection(p.edge)
pathEditor.startEditing(p.edge, {
deleteButton:true
})
}
}
}
}

One particular point to note here is the EVENT_CLICK handler:

[EVENT_CLICK]:(p) => {
toolkit.setSelection(p.edge)
pathEditor.startEditing(p.edge, {
deleteButton:true
})
}

This method does two things:

  • it sets the clicked edge to be the currently selected object in the Toolkit, which the attached inspector will detect, and then display an editor for the edge
  • it calls startEditing on the edge path editor, requesting a deleteButton be attached. Prior to version 6.3.0 you will want to pass anchorPositions in here, as the path editor did not automatically discover them. But from 6.3.0 onwards the path editor will automatically discover the anchor positions.

Edge overlays

The default edge type mapping declares a label:

label:"{{label}}"

This instructs the Toolkit to extract label from each edge's backing data and use it to display a label overlay (at location 0.5). The other overlays you see in this app are achieved via the use of edge property mappings to render different types of edges.

We support 5 types - a plain edge, a dashed edge, an edge with an arrow at the source, an edge with an arrow at the target, and an edge with an arrow at both ends.

These types are declared in the edge-mappings.ts file in the source:

export default function edgeMappings(arrowWidth, arrowLength) {

arrowWidth = arrowWidth || ARROW_WIDTH
arrowLength = arrowLength || ARROW_LENGTH

return [
{
property:PROPERTY_LINE_STYLE,
mappings:{
[EDGE_TYPE_SOURCE_ARROW]:{
overlays:[ { type:ArrowOverlay.type, options:{location:0, direction:-1, width:arrowWidth, Length:arrowLength} } ]
},
[EDGE_TYPE_TARGET_ARROW]:{
overlays:[ { type:ArrowOverlay.type, options:{location:1, width:arrowWidth, length:arrowLength} } ]
},
[EDGE_TYPE_BOTH_ARROWS]:{
overlays:[ {
type:ArrowOverlay.type,
options:{
location:1,
width:arrowWidth,
length:arrowLength
}
}, {
type:ArrowOverlay.type,
options:{
location:0,
direction:-1,
width:arrowWidth,
length:arrowLength
}
} ]
},
[EDGE_TYPE_PLAIN]:{},
[EDGE_TYPE_DASHED]:{
cssClass:CLASS_DASHED_EDGE
}
}
}
]

}

and are set on the surface in the render params.

Default edge type

The beforeStartConnect method that we pass in to the Toolkit's constructor is the means we use to set the default payload for a new edge dragged by the user:

// This is the payload to set when a user begins to drag an edge - we return values for the
// edge's label, color and line style. If you wanted to implement a mechanism whereby you have
// some "current style" you could update this method to return some dynamically configured
// values.
beforeStartConnect:(node, edgeType) => {
return {
[PROPERTY_LABEL]:"",
[PROPERTY_COLOR]:DEFAULT_STROKE,
[PROPERTY_LINE_STYLE]:EDGE_TYPE_TARGET_ARROW
}
}

Editing edges

Edge label, color and "line style" are also edited via the FlowchartInspector: it contains a template for both nodes and for edges, and picks the appropriate one when something is selected.

Edge label and color are both set/changed by making use of the simple edge styles concept, in which there is a basic set of properties that the Toolkit will use to automatically set certain parts of the appearance of an edge.

The "line style" of an edge in this demonstration is a reference to what, if any, overlays the edge has, and whether it is a dashed line or not. As mentioned in the edge rendering section above, this is accomplished by edge property mappings.

In the edge inspector of this demonstration you will see a Line style section:

We use the EdgeTypePicker tag, discussed here to achieve this. The code that does this - and which uses the tag in the edge inspector template - is all in flowchart-inspector.js.


Editing edge paths

Paths can be edited in this application, via an EdgePathEditor. By default (since 6.2.11) this is created in active mode:

pathEditor.current = new EdgePathEditor(renderer, {activeMode: true})

..but we leave the activeMode setter in the code to allow for versions prior to 6.2.11.

The call shown above creates an edge path editor but does not activate it. The path editor is activated in the click handler for edges:

pathEditor.startEditing(p.edge, {
// Show a delete button.
deleteButton:true
})

Background grid

The application renders a background grid via the background plugin. The options we pass (shown above as GRID_BACKGROUND_OPTIONS) are:

{
dragOnGrid:true,
showGrid:true,
showBorder:false,
autoShrink:true,
minWidth:10000,
maxWidth:null,
minHeight:10000,
maxHeight:null,
showTickMarks:false,
type:GeneratedGridBackground.type
}

There is a discussion of each of these options on the background plugin page.


Miniview

We use a miniview in this starter app, via the JsPlumbToolkitMiniviewComponent component that ships with the React integration.

This is mounted inside the useEffect:

// a miniview.
const m = createRoot(miniviewContainer.current)
m.render(
<JsPlumbToolkitMiniviewComponent surface={surfaceComponent.current.surface}/>
);

Controls

Here we use another component that was created for our demonstrations but which now ships with the Toolkit, as we thought it might be useful for others:

// controls component. needs to be done here as it needs a reference to the surface.
const c = createRoot(controlsContainer.current)
c.render(<ControlsComponent surface={surfaceComponent.current.surface}/>)

CSS

Styles for the app itself are in app.css and index.css.

styles.css also imports a few of the CSS files that ship with the Toolkit:

@import "../node_modules/@jsplumbtoolkit/browser-ui/css/jsplumbtoolkit.css";
@import "../node_modules/@jsplumbtoolkit/browser-ui/css/jsplumbtoolkit-connector-editors.css";
@import "../node_modules/@jsplumbtoolkit/browser-ui/css/jsplumbtoolkit-controls.css";

jsplumbtoolkit.css provides basic 'structural' CSS, and it is recommended that you include this stylesheet in your cascade, at least when you first start developing with the Toolkit.

jsplumbtoolkit-connector-editors.css contains a mixture of structural and also theming styles, for use by the EdgePathEditor.

jsplumbtoolkit-controls.css contains styles for the controls component, which was created for our demonstrations but since 6.2.0 has been shipped with the Toolkit as it may be useful for others.