This is a port of the Flowchart Builder application that demonstrates the Toolkit's Vue 2 ES6 module based integration, in a Vue CLI 3 application.
This page gives you an in-depth look at how the application is put together.
This file was created for us by the CLI, to which we then added these entries:
{
"dependencies":{
...
"jsplumbtoolkit": "file:./jsplumbtoolkit.tgz",
"jsplumbtoolkit-vue2": "file:./jsplumbtoolkit-vue2.tgz",
"jsplumbtoolkit-undo-redo": "file:./jsplumbtoolkit-undo-redo.tgz"
...
}
}
You need to copy these files into the project root from either your licensed or evaluation bundle prior to running npm install
.
As this application was generated by the CLI, the setup was done for us. We just had to add the appropriate entries to package.json
.
A CLI application is bootstrapped through src/main.js
. Ours looks like this:
import Vue from 'vue'
import App from './App.vue'
import { JsPlumbToolkitVue2Plugin } from 'jsplumbtoolkit-vue2';
Vue.config.productionTip = false
require('@/assets/css/jsplumbtoolkit.css');
require('@/assets/css/jsplumbtoolkit-demo-support.css');
require('@/assets/css/jsplumbtoolkit-editable-connectors.css');
require('@/assets/css/app.css');
Vue.use(JsPlumbToolkitVue2Plugin);
new Vue({ render: h => h(App) }).$mount('#app')
If you were using the Vue integration with a version prior to 2.1.0 your setup will have been slightly different - you would not have the Vue.use(JsPlumbToolkitVue2Plugin)
call. From 2.1.0 onwards the Vue2 integration is provided with the templates precompiled, meaning you do not need to include the runtime compiler in your app. If for some reason you don't want to make any changes to your pre-2.1.0 application, you can import jsplumbtoolkit-vue2-runtime.tgz
instead of jsplumbtoolkit-vue2.tgz
, which contains the original Vue2 integration code. The inclusion of this package is only a temporary measure, though - we will stop including it at some point in the not so distant future.
If you're using jsplumbtoolkit-vue2-runtime.tgz
then your main.js
would look like this:
import Vue from 'vue'
import App from './App.vue'
Vue.config.productionTip = false
require('@/assets/css/jsplumbtoolkit.css');
require('@/assets/css/jsplumbtoolkit-demo-support.css');
require('@/assets/css/jsplumbtoolkit-editable-connectors.css');
require('@/assets/css/app.css');
new Vue({ render: h => h(App) }).$mount('#app')
The component that acts as the entry point of the application is defined in App.vue
, which looks like this:
<template>
<div id="app">
<div class="jtk-demo-main" id="jtk-demo-flowchart">
<div style="display:flex">
<Palette surface-id="surface" selector="[data-node-type]"/>
<div id="canvas" class="jtk-demo-canvas">
<Controls surface-id="surface" />
<Flowchart surface-id="surface" />
</div>
</div>
<div class="description">
<p>
This sample application is a copy of the Flowchart Builder application, using the Toolkit's
Vue 2 integration components and Vue CLI 3.
</p>
<ul>
<li>Drag new nodes from the palette on the left onto the workspace to add nodes</li>
<li>Drag from the grey border of any node to any other node to establish a link, then provide a description for the link's label</li>
<li>Click a link to edit its label.</li>
<li>Click the 'Pencil' icon to enter 'select' mode, then select several nodes. Click the canvas to exit.</li>
<li>Click the 'Home' icon to zoom out and see all the nodes.</li>
</ul>
</div>
</div>
</div>
</template>
<script>
import Vue from "vue"
import { Dialogs, jsPlumbToolkit } from "jsplumbtoolkit"
import Flowchart from './components/Flowchart.vue'
import Palette from './components/Palette.vue'
import Controls from './components/Controls.vue'
export default {
name: 'app',
components: {
Flowchart, Palette, Controls
},
mounted:function() {
jsPlumbToolkit.ready(() => {
Dialogs.initialize({
selector: ".dlg"
});
});
}
}
</script>
Points to note:
- The template uses 3 components that are also declared in this app -
Flowchart
,Palette
andControls
. A discussion of each of these is below. - We initialise the Toolkit's Dialogs inside a
jsPlumbToolkit.ready(..)
function in themounted
callback of this component. You may not wish to use the Toolkit's Dialogs; they are something we created for our demonstrations. It isn't always necessary to wrap their initialisation in aready(..)
call, either, but in this demonstration we load them from an external file calledtemplates.html
and need to be sure they have loaded before we try to initialise them.
This is where most of the functionality is coordinated. We'll break it up into sections and go through each one.
<template>
<jsplumb-toolkit
ref="toolkitComponent"
url="flowchart-1.json"
v-bind:render-params="renderParams"
v-bind:view="view"
id="toolkit"
surface-id="surface"
v-bind:toolkit-params="toolkitParams">
</jsplumb-toolkit>
</template>
We use the jsplumb-toolkit
component, providing several pieces of information:
- ref="toolkitComponent" we want to be able to retrieve this component after mounting, as we need a reference to the underlying Toolkit instance for some of our business logic
- url="flowchart-1.json" we provide the url for an initial dataset to load. This is of course optional.
- v-bind:render-params="renderParams" These are the parameters passed to the Surface component that renders the dataset. As we will see below, we declare these in the
data
section of the Flowchart component. - v-bind:view="view" This the view passed to the Surface component that renders the dataset. As we will see below, we declare this in the
data
section of the Flowchart component. - id="toolkit" We give an id to the Toolkit instance we are creating, which is optional, and in fact in this app we do not use it.
- surface-id="surface" We assign an ID to the surface for a couple of reasons: first, we need to nominate which surface to attach our miniview, controls and palette components to. Second, we need to access the surface for some of our app's functionality.
- v-bind:toolkit-params="toolkitParams" These are the parameters passed to constructor of the Toolkit instance. As we will see below, we declare these in the
data
section of the Flowchart component.
We'll break this up into parts too.
import Vue from 'vue'
import { jsPlumbToolkit, jsPlumb, Dialogs, DrawingTools, jsPlumbUtil } from 'jsplumbtoolkit'
import { jsPlumbToolkitVue2, Palette } from 'jsplumbtoolkit-vue2'
import StartNode from './StartNode.vue'
import ActionNode from './ActionNode.vue'
import QuestionNode from './QuestionNode.vue'
import OutputNode from './OutputNode.vue'
We import Vue and a few bits and pieces from the Toolkit, and from its Vue integration. We also import each of the components used to render our nodes.
We have a few methods defined in the component's scope - things to perform operations on edges, plus the code we use as our Node Factory:
let toolkitComponent;
let toolkit;
function maybeRemoveEdge(params) {
Dialogs.show({
id: "dlgConfirm",
data: {
msg: "Delete Edge"
},
onOK: function () {
params.toolkit.removeEdge(params.edge);
}
});
}
function editEdge(params) {
Dialogs.show({
id: "dlgText",
data: {
text: params.edge.data.label || ""
},
onOK: function (data) {
toolkit.updateEdge(params.edge, {label:data.text});
}
});
}
function nodeFactory(type, data, callback) {
Dialogs.show({
id: "dlgText",
title: "Enter " + type + " name:",
onOK: function (d) {
data.text = d.text;
// if the user entered a name...
if (data.text) {
// and it was at least 2 chars
if (data.text.length >= 2) {
// set an id and continue.
data.id = jsPlumbUtil.uuid();
callback(data);
}
else
// else advise the user.
alert(type + " names must be at least 2 characters!");
}
// else...do not proceed.
}
});
}
The key pieces to note here are:
- we've declared
toolkitParams
,renderParams
andview
in ourdata
object. You can find a discussion of these concepts in the documentation. - we map Vue components to node types in the
view
- we initialise the DrawingTools when the component is mounted.
export default {
name: 'jsp-toolkit',
props:["surfaceId"],
data:() => {
return {
toolkitParams:{
nodeFactory:nodeFactory,
beforeStartConnect:function(node, edgeType) {
// limit edges from start node to 1. if any other type of node, return
return (node.data.type === "start" && node.getEdges().length > 0) ? false : { label:"..." };
}
},
renderParams:{
layout:{
type:"Spring"
},
jsPlumb:{
Connector:"StateMachine",
Endpoint:"Blank"
},
events:{
modeChanged:function (mode) {
let controls = document.querySelector(".controls");
jsPlumb.removeClass(controls.querySelectorAll("[mode]"), "selected-mode");
jsPlumb.addClass(controls.querySelectorAll("[mode='" + mode + "']"), "selected-mode");
},
edgeAdded:(params) => {
if (params.addedByMouse) {
editEdge(params);
}
}
},
lassoInvert:true,
elementsDroppable:true,
consumeRightClick: false,
dragOptions: {
filter: ".jtk-draw-handle, .node-action, .node-action i"
}
},
view:{
nodes: {
"start": {
component:StartNode
},
"selectable": {
events: {
tap: (params) => params.toolkit.toggleSelection(params.node)
}
},
"question": {
parent: "selectable",
component:QuestionNode
},
"action": {
parent: "selectable",
component:ActionNode
},
"output":{
parent:"selectable",
component:OutputNode
}
},
// There are two edge types defined - 'yes' and 'no', sharing a common
// parent.
edges: {
"default": {
anchor:"AutoDefault",
endpoint:"Blank",
connector: ["Flowchart", { cornerRadius: 5 } ],
paintStyle: { strokeWidth: 2, stroke: "#f76258", outlineWidth: 3, outlineStroke: "transparent" }, // paint style for this edge type.
hoverPaintStyle: { strokeWidth: 2, stroke: "rgb(67,67,67)" }, // hover paint style for this edge type.
events: {
"dblclick": maybeRemoveEdge
},
overlays: [
[ "Arrow", { location: 1, width: 10, length: 10 }],
[ "Arrow", { location: 0.3, width: 10, length: 10 }]
]
},
"connection":{
parent:"default",
overlays:[
[
"Label", {
label: "${label}",
events:{
click:editEdge
}
}
]
]
}
},
ports: {
"start": {
edgeType: "default"
},
"source": {
maxConnections: -1,
edgeType: "connection"
},
"target": {
maxConnections: -1,
isTarget: true,
dropOptions: {
hoverClass: "connection-drop"
}
}
}
}
};
},
mounted() {
toolkitComponent = this.$refs.toolkitComponent;
toolkit = toolkitComponent.toolkit;
jsPlumbToolkitVue2.getSurface(this.surfaceId, (s) => {
new DrawingTools({
renderer: s
});
});
}
}
Each of the four node types is rendered with a specific Vue component. With the exception of the StartNode
component, they each include
the BaseEditableNode
mixin, whose definition is:
<script>
import { Dialogs } from 'jsplumbtoolkit'
import { BaseNodeComponent } from 'jsplumbtoolkit-vue2'
export default {
mixins:[ BaseNodeComponent ],
methods:{
edit:function() {
let node = this.getNode();
Dialogs.show({
id: "dlgText",
data: node.data,
title: "Edit " + node.data.type + " name",
onOK: (data) => {
if (data.text && data.text.length > 2) {
// if name is at least 2 chars long, update the underlying data and
// update the UI.
this.updateNode(data);
}
}
});
},
maybeDelete:function() {
let node = this.getNode();
Dialogs.show({
id: "dlgConfirm",
data: {
msg: "Delete '" + node.data.text + "'"
},
onOK:() => {
this.removeNode();
}
});
}
}
}
</script>
It offers 2 common methods - to handle editing of a node's label, and to handle a node's deletion.
Note in the node templates we write a v-pre
attribute on jtk-source
and jtk-target
elements. This instructs Vue to ignore these; without v-pre
Vue would try to render these as Vue components.
<template>
<div v-bind:style="{left:obj.left + 'px', top:obj.top + 'px', width:obj.w + 'px', height:obj.h + 'px'}" class="flowchart-object flowchart-start">
<div style="position:relative">
<svg :width="obj.w" :height="obj.h">
<ellipse :cx="obj.w/2" :cy="obj.h/2" :rx="obj.w/2" :ry="obj.h/2" class="outer"/>
<ellipse :cx="obj.w/2" :cy="obj.h/2" :rx="(obj.w/2) - 10" :ry="(obj.h/2) - 10" class="inner"/>
<text text-anchor="middle" :x="obj.w / 2" :y="obj.h / 2" dominant-baseline="central">{{obj.text}}</text>
</svg>
</div>
<jtk-source port-type="start" filter=".outer" filter-negate="true" v-pre/>
</div>
</template>
<script>
export default { }
</script>
<template>
<div v-bind:style="{left:obj.left + 'px', top:obj.top + 'px', width:obj.w + 'px', height:obj.h + 'px'}" class="flowchart-object flowchart-action">
<div style="position:relative">
<div class="node-edit node-action">
<i class="fa fa-pencil-square-o" v-on:click="edit()"/>
</div>
<div class="node-delete node-action">
<i class="fa fa-times" v-on:click="maybeDelete()"/>
</div>
<svg :width="obj.w" :height="obj.h">
<rect x="0" y="0" :width="obj.w" :height="obj.h" class="outer drag-start"/>
<rect x="10" y="10" :width="obj.w-20" :height="obj.h-20" class="inner"/>
<text text-anchor="middle" :x="obj.w/2" :y="obj.h/2" dominant-baseline="central">{{obj.text}}</text>
</svg>
</div>
<jtk-target port-type="target" v-pre/>
<jtk-source port-type="source" filter=".outer" v-pre/>
</div>
</template>
<script>
import BaseEditableNode from './BaseEditableNode.vue'
export default {
mixins:[BaseEditableNode]
}
</script>
<template>
<div v-bind:style="{left:obj.left + 'px', top:obj.top + 'px', width:obj.w + 'px', height:obj.h + 'px'}" class="flowchart-object flowchart-output">
<div style="position:relative">
<div class="node-edit node-action">
<i class="fa fa-pencil-square-o" v-on:click="edit()"/>
</div>
<div class="node-delete node-action">
<i class="fa fa-times" v-on:click="maybeDelete()"/>
</div>
<svg :width="obj.w" :height="obj.h">
<rect x="0" y="0" :width="obj.w" :height="obj.h"/>
<text text-anchor="middle" :x="obj.w/2" :y="obj.h/2" dominant-baseline="central">{{obj.text}}</text>
</svg>
</div>
<jtk-target port-type="target" v-pre/>
</div>
</template>
<script>
import BaseEditableNode from './BaseEditableNode.vue'
export default {
mixins:[BaseEditableNode]
}
</script>
<template>
<div v-bind:style="{left:obj.left + 'px', top:obj.top + 'px', width:obj.w + 'px', height:obj.h + 'px'}" class="flowchart-object flowchart-action">
<div style="position:relative">
<div class="node-edit node-action">
<i class="fa fa-pencil-square-o" v-on:click="edit()"/>
</div>
<div class="node-delete node-action">
<i class="fa fa-times" v-on:click="maybeDelete()"/>
</div>
<svg :width="obj.w" :height="obj.h">
<path :d="'M ' + (obj.w/2) + ' 0 L ' + obj.w + ' ' + (obj.h/2) + ' L ' + (obj.w/2) + ' ' + obj.h + ' L 0 ' + (obj.h/2) + ' Z'" class="outer"/>
<path :d="'M ' + (obj.w/2) + ' 10 L ' + (obj.w-10) + ' ' + (obj.h/2) + ' L ' + (obj.w/2) + ' ' + (obj.h-10) + ' L 10 ' + (obj.h/2) + ' Z'" class="inner"/>
<text text-anchor="middle" :x="obj.w/2" :y="obj.h/2" dominant-baseline="central">{{obj.text}}</text>
</svg>
</div>
<jtk-target port-type="target" v-pre/>
<jtk-source port-type="source" filter=".outer" v-pre/>
</div>
</template>
<script>
import BaseEditableNode from './BaseEditableNode.vue'
export default {
mixins:[BaseEditableNode]
}
</script>
In this demonstration, new nodes can be dragged on to whitespace in the canvas, to create new, unconnected, nodes. They can also be dragged onto an existing edge, in which case the new node is injected in between the two nodes at either end of the edge on which the new node was dropped.
We declare a Palette
component in the app's template:
<Palette surface-id="surface"
selector="[data-node-type]"
v-bind:data-generator="dataGenerator"
allowDropOnEdges="true">
</Palette>
Palette
is a component declared in this application, which uses the DragDrop
mixin from the Toolkit's Vue integration:
<template>
<div class="sidebar node-palette">
<div class="sidebar-item" :data-node-type="entry.type" title="Drag to add new" v-for="entry in data" :key="entry.type">
<i :class="entry.icon"></i>{{entry.label}}
</div>
</div>
</template>
<script>
import { DragDrop } from 'jsplumbtoolkit-vue2-drop';
export default {
mixins:[ DragDrop ],
data:function() {
return {
data:[
{ icon:"icon-tablet", label:"Question", type:"question" },
{ icon:"icon-eye-open", label:"Action", type:"action" },
{ type:"output", icon:"icon-eye-open", label:"Output" }
]
};
},
methods:{
onCanvasDrop:function(surface, data, positionOnSurface) {
data.left = positionOnSurface.left;
data.top = positionOnSurface.top;
surface.getToolkit().addFactoryNode(data.type, data);
},
// disabling linter so you can see all of the method arguments
// eslint-disable-next-line
onEdgeDrop:function(surface, data, edge, positionOnSurface, el, evt, pageLocation) {
let toolkit = surface.getToolkit();
toolkit.addFactoryNode(data.type, data,
function(newNode) {
let currentSource = edge.source; // the current source node
let currentTarget = edge.target; // the target node
toolkit.removeEdge(edge);
toolkit.addEdge({source:currentSource, target:newNode, data:{label:"...", type:"connection"}});
toolkit.addEdge({source:newNode, target:currentTarget, data:{label:"...", type:"connection"}});
surface.setPosition(newNode, positionOnSurface.left, positionOnSurface.top);
}
);
}
}
}
</script>
The onCanvasDrop
method here handles dropping a new node onto the canvas. We copy in the left
and top
values from
the positionOnSurface
argument to the data
object. We then call addFactoryNode
on the underlying Toolkit instance,
with the type of the new node and the data for the node. addFactoryNode
is a method that will cause the current
nodeFactory
to be invoked - in this demonstration, we provide a nodeFactory
that pops up a dialog, requesting the
user enter a label for the new node.
The onEdgeDrop
method handles this case. There are a number of arguments passed to this callback method. positionOnSurface
is
an object with { left:.., top:... }
values that are in the coordinate space of the surface, adjusted for its current pan
and zoom.
In this callback we again set left
and top
on the data object, and we call addFactoryNode
, but here we provide a callback
function as the third argument. This method is called at the very end of the process of adding a node via the node factory. We
store the source and target of the edge on which the new node was dropped, then we remove that edge. We then add an edge from
the original source to the new node, and another edge from the new node to the original target. Finally, we instruct the surface to
place the new node at the location on the canvas at which the user dropped the object.
The buttons in the top left of the screen are handled by the component defined in Controls.vue
. Here's the full code for the component; a discussion follows below.
<template>
<div class="controls" ref="container">
<i class="fa fa-arrows selected-mode" mode="pan" title="Pan Mode" v-on:click="panMode()"></i>
<i class="fa fa-pencil" mode="select" title="Select Mode" v-on:click="selectMode()"></i>
<i class="fa fa-home" reset title="Zoom To Fit" v-on:click="zoomToFit()"></i>
<i class="fa fa-undo" undo title="Undo last action" v-on:click="undo()"></i>
<i class="fa fa-repeat" redo title="Redo last action" v-on:click="redo()"></i>
<i class="fa fa-times" title="Clear" v-on:click="clear()"></i>
</div>
</template>
<script>
import { jsPlumbToolkitVue2 } from "jsplumbtoolkit-vue2";
import { jsPlumbToolkitUndoRedo } from "jsplumbtoolkit-undo-redo";
let undoManager;
let container;
let surfaceId;
// a wrapper around getSurface, which expects a callback, as the surface may or may not have been
// initialised when calls are made to it.
function getSurface(cb) {
jsPlumbToolkitVue2.getSurface(surfaceId, cb);
}
export default {
props:["surfaceId"],
methods:{
panMode:function() {
getSurface((s) => s.setMode("pan"));
},
selectMode:function() {
getSurface((s) => s.setMode("select"));
},
zoomToFit:function() {
getSurface((s) => s.zoomToFit());
},
undo:function() {
undoManager.undo();
},
redo:function() {
undoManager.redo();
},
clear: function() {
getSurface((s) => {
const t = s.getToolkit();
if (t.getNodeCount() === 0 || confirm("Clear canvas?")) {
t.clear();
}
});
}
},
mounted:function() {
surfaceId = this.surfaceId;
container = this.$refs.container;
getSurface((surface) => {
undoManager = new jsPlumbToolkitUndoRedo({
surface:surface,
compound:true,
onChange:(mgr, undoSize, redoSize) => {
container.setAttribute("can-undo", undoSize > 0);
container.setAttribute("can-redo", redoSize > 0);
}
});
surface.bind("canvasClick", () => {
surface.getToolkit().clearSelection();
});
});
}
}
</script>
The Controls component is created by the template in App.vue
:
<div id="canvas" class="jtk-demo-canvas">
<Controls surface-id="surface"></Controls>
<Flowchart surface-id="surface"></Flowchart>
</div>
We pass the same value for surface-id
as we pass to the Flowchart component. Each component uses the underlying Toolkit's Vue2 service to access the Surface with this id. Note we pass the value in "kebab case" but the actual property is in camel case.
The component's mounted
function does four things:
- Sets the component wide
surfaceId
property (from the value specified in App.vue's template) - Sets the component's
container
property. This is the DOM element hosting the component. - Creates a new undo/redo manager referencing the Surface in use by the app
- Binds an event listener to the
canvasClick
event to clear the current selection whenever a user clicks on whitespace in the canvas.
Each button is mapped to a method specified in the component's exports. Note, in these methods, the use of the getSurface(..)
helper method to access the Surface. This method simply abstracts out the surfaceId
from each of the individual handlers.
Puts the Surface into "pan" mode (the lasso is disabled)
panMode:function() {
getSurface((s) => s.setMode("pan"));
}
Puts the Surface into "select" mode (the lasso is enabled)
selectMode:function() {
getSurface((s) => s.setMode("select"));
}
This will adjust the zoom and pan so that the content is centered and zoomed to fit.
zoomToFit:function() {
getSurface((s) => s.zoomToFit());
}
We call the undo()
method of the Undo/Redo Manager we created:
undo:function() {
undoManager.undo();
}
We call the redo()
method of the Undo/Redo Manager we created:
redo:function() {
undoManager.redo();
}
To clear the canvas we call the clear
method of the underlying Toolkit. Note that we call clear()
on the Toolkit even if there are no nodes in the dataset, which may seem odd, but we do this because then a few internal events are fired which will restore the canvas to the state the user expects.
clear: function() {
getSurface((s) => {
const t = s.getToolkit();
if (t.getNodeCount() === 0 || confirm("Clear canvas?")) {
t.clear();
}
});
}