We often get asked how to incorporate TypeScript with JointJS. As JointJS is a standard JavaScript library, the integration process is quite simple and straightforward. In the following tutorial, we are going to create our very own custom shape using TypeScript, and also try to provide you with some useful information along the way.
To get started, we create a separate shapes.ts
file. We will define our custom shape here, and later you can import it in
your main file. To define our custom shape in TypeScript, we are going to extend the default dia.Element
class.
The syntax is quite simple, and will seem familiar to those of you who have used JavaScript classes before. In the following code, you
can see our custom element MyShape
.
import { dia } from './vendor/joint';
export class MyShape extends dia.Element {
defaults() {
return {
...super.defaults,
}
}
}
The defaults
function will return an object that contains the attributes for our model. It is possible to use an
object for our defaults
, but as objects are referenced, not copied in JavaScript, our function will return a different
object each time.
In JavaScript classes, you may be used to working with super. In our use case, we want our child subtype to take attributes from its
parent type. Using ...super.defaults
, if an attribute is undefined in the child, the parent attribute will be assigned
instead. Similarly, once a property is set in the child, additional values of the same property from the parent are replaced.
This is the most basic boilerplate for a custom shape, but you have to agree it's not very exciting, so let's add some more attributes.
The first attribute we will look at is type
. We will speak about it in more detail later, but for now all we do is give
type the name myNamespace.MyShape
. The type
is a unique path identifier to help us find our shape. The first
part of the name joint.shapes
is implied, therefore the final name of the type to be accessed will be
joint.shapes.myNamespace.MyShape
.
defaults() {
return {
...super.defaults,
type: 'myNamespace.MyShape',
size: { width: 100, height: 80 },
attrs: {
body: {
cx: 'calc(0.5*w)',
cy: 'calc(0.5*h)',
rx: 'calc(0.5*w)',
ry: 'calc(0.5*h)',
strokeWidth: 2,
stroke: '#333333',
fill: '#FFFFFF'
},
label: {
textVerticalAnchor: 'middle',
textAnchor: 'middle',
x: 'calc(0.5*w)',
y: 'calc(0.5*h)',
fontSize: 14,
fill: '#333333'
}
}
}
}
After type
, the size
of our model is provided. Remember that ...super.defaults
we talked
about earlier? size
is one of those attributes that a child can take from its parent. If we didn't provide it here,
the default size would be size: { width: 1, height: 1 }
. As we do provide the property, the default is replaced.
As we have already set up any parent attributes, we should now add some unique attributes for each instance of our custom
shape. In order to do this, we create an attrs
object with keys that correspond to our subelement selectors which are defined in
markup
. body
and label
are the respective keys in our example. The attributes can consist of
standard SVG attributes, but also special
JointJS attributes.
We already mentioned the next building block of our custom shape, and that is the Markup.
defaults() {
return {
...super.defaults,
type: 'myNamespace.MyShape',
size: { width: 100, height: 80 },
attrs: {
body: {
// Attributes
},
label: {
// Attributes
}
}
}
}
markup = [{
tagName: 'ellipse',
selector: 'body'
}, {
tagName: 'text',
selector: 'label'
}]
Each member of the markup array defines one subelement of our custom shape. Markup is a template that all instances of a shape
are expected to have in common. It is used to build elements on the fly while the cellView is rendered. tagName
is
the type of element, and selector
is used to target the element within the attrs
attribute.
The final part of our custom shape structure will be methods. We can create prototype methods to call on our constructor, or we can create static methods to call on the class itself.
defaults() {
return {
...super.defaults,
type: 'myNamespace.MyShape',
size: { width: 100, height: 80 },
attrs: {
body: {
// Attributes
},
label: {
// Attributes
}
}
}
}
markup = [{
// Markup
}]
test(): void {
console.log(`A prototype method test for ${this.get('type')}`);
}
static staticTest(i: number): void {
console.log(`A static method test with an argument: ${i}`);
}
Let's look at a prototype method in a little more detail. If we wanted to create a custom text label for each instance
of our shape, we could create a function called setText
. This function will take a string as its only parameter,
and set the label text using this value. Then, when we create our instance, we can pass our custom text as an
argument to the constructor.
// Custom shape prototype method
setText(text: string): dia.Element {
return this.attr('label/text', text || '');
}
// Create instance using the method
const myNewShape = new MyShape().setText(text);
You may have noticed that our code so far didn't contain a lot of TypeScript. Luckily for us, that's because the JointJS
@types
do most of the heavy lifting. Parameter and return types in methods is one area where you will use the
typical TypeScript features you are familiar with. Great, and that's basically it! You have now created a basic custom shape.
As you can see, it wasn't so difficult to gain some nice features to work with.
Remember when I said we would talk about type
in a little more detail? Our final piece of the puzzle is making
sure our namespaces are set up correctly for our custom shape. Firstly, ensure that we are importing shapes
to our
project, because JointJS reads cell view definitions from the joint.shapes namespace by default. That now allows us to assign
our namespace under the shape definition using Object.assign()
.
import { shapes, dia } from './vendor/joint';
export class MyShape extends dia.Element {
defaults() {
return {
...super.defaults,
type: 'myNamespace.MyShape',
size: { width: 100, height: 80 },
attrs: {
body: {
cx: 'calc(0.5*w)',
cy: 'calc(0.5*h)',
rx: 'calc(0.5*w)',
ry: 'calc(0.5*h)',
strokeWidth: 2,
stroke: '#333333',
fill: '#FFFFFF'
},
label: {
textVerticalAnchor: 'middle',
textAnchor: 'middle',
x: 'calc(0.5*w)',
y: 'calc(0.5*h)',
fontSize: 14,
fill: '#333333'
}
}
}
}
markup = [{
tagName: 'ellipse',
selector: 'body'
}, {
tagName: 'text',
selector: 'label'
}]
test(): void {
console.log(`A prototype method test for ${this.get('type')}`);
}
static staticTest(i: number): void {
console.log(`A static method test with an argument: ${i}`);
}
}
Object.assign(shapes, {
myNamespace: {
MyShape
}
});
The type
path is very important, especially if you want to import a graph from JSON. graph
would look at joint.shapes.myNamespace.MyShape
path to find the correct constructor. If for some reason,
you don't want to use the joint.shapes
default, it is possible to set up a custom namespace too. We can achieve
this by combining the cellNamespace
and cellViewNamespace
options which can be found on
graph
and paper
respectively. Let's see how that might look.
const canvas = document.getElementById('canvas') as HTMLElement;
const myNamespace = {};
const graph = new dia.Graph({}, { cellNamespace: myNamespace });
const paper = new dia.Paper({
el: canvas,
model: graph,
width: 800,
height: 800,
gridSize: 1,
interactive: true,
async: true,
frozen: false,
sorting: dia.Paper.sorting.APPROX,
background: { color: '#F3F7F6' },
cellViewNamespace: myNamespace,
});
class MyShape extends dia.Element {
defaults() {
return {
...super.defaults,
type: 'myShapeGroup.MyShape',
size: { width: 100, height: 30 },
position: { x: 10, y: 10 },
attrs: {
body: {
width: 'calc(w)',
height: 'calc(h)'
}
}
}
}
markup = [{
tagName: 'rect',
selector: 'body',
attributes: {
'cursor': 'pointer',
}
}]
}
Object.assign(myNamespace, {
myShapeGroup: {
MyShape
},
standard: {
Rectangle: shapes.standard.Rectangle
}
});
graph.fromJSON({
cells: [
{ type: 'myShapeGroup.MyShape'},
{
type: 'standard.Rectangle',
size: { width: 40, height: 40 },
position: {x: 40, y: 50 }
}
]
});
As you can see, in this example we are not adding shape instances to the graph, but we are creating a graph from JSON.
If we were to use the incorrect type in graph.fromJSON()
, that would mean graph
is unable to find the
correct constructor, and we wouldn't see our custom shape.
There is one alternative way to return attributes in our custom shape, and you can see that in the following example.
import { shapes, dia, util } from './vendor/joint';
defaults() {
return util.defaultsDeep({
size: {
width: 80,
// `height` will be taken from the parent class
},
type: 'myNamespace.MyShape',
size: { width: 100, height: 80 },
attrs: {
body: {
cx: 'calc(0.5*w)',
cy: 'calc(0.5*h)',
rx: 'calc(0.5*w)',
ry: 'calc(0.5*h)',
strokeWidth: 2,
stroke: '#333333',
fill: '#FFFFFF'
},
label: {
textVerticalAnchor: 'middle',
textAnchor: 'middle',
x: 'calc(0.5*w)',
y: 'calc(0.5*h)',
fontSize: 14,
fill: '#333333'
}
}
}, super.defaults)
}
Earlier, we used the spread operator with super.defaults
. That allowed us to replace any parent properties if we
defined those properties on the child. In our new example, we are using util.defaultsDeep()
. This works a little
differently, and recursively assigns default properties. That means if a property already exists on the child, the child property
won't be replaced even if the parent property of same name has a different value.
Congratulations! You now know how to set up custom shapes in JointJS with TypeScript, and unlock the powerful functionality it gives to the user. Of course, there are many other ways to define shapes in JointJS, and you can see some other methods of doing this in our custom elements tutorial. We think this method is a nice way to work with TypeScript, and you can see that we use it throughout our Rappid demos.
Custom views provide some extra flexibility when it comes to working with our models. If we want some additional behaviour, but don't believe that behaviour should live alongside the presentational attributes on our models, custom views can provide this for us. Maybe you want to capture user input, see a minimal version of the same graph, or apply some interesting effect to your elements, those are just a few of the reasons you may want to explore custom views. In the following example, we create a fade view effect to change the opacity of elements.
class MyShape extends dia.Element {
defaults() {
return {
...super.defaults,
type: 'myShapeGroup.MyShape',
size: { width: 100, height: 30 },
position: {x: 10, y: 10 },
attrs: {
body: {
width: 'calc(w)',
height: 'calc(h)'
}
}
}
}
markup = [{
tagName: 'rect',
selector: 'body',
attributes: {
'cursor': 'pointer',
}
}]
}
const MyShapeView: dia.ElementView = dia.ElementView.extend({
// Make sure that all super class presentation attributes are preserved
presentationAttributes: dia.ElementView.addPresentationAttributes({
// mapping the model attributes to flag labels
faded: 'flag:opacity'
}),
confirmUpdate(flags, ...args) {
dia.ElementView.prototype.confirmUpdate.call(this, flags, ...args);
if (this.hasFlag(flags, 'flag:opacity')) this.toggleFade();
},
toggleFade() {
this.el.style.opacity = this.model.get('faded') ? 0.5 : 1;
}
});
const graph = new dia.Graph({}, { cellNamespace: shapes });
const paper = new dia.Paper({
model: graph,
width: 800,
height: 800,
gridSize: 1,
interactive: true,
async: true,
frozen: false,
sorting: dia.Paper.sorting.APPROX,
background: { color: '#F3F7F6' },
cellViewNamespace: shapes,
});
document.getElementById('canvas').appendChild(paper.el);
Object.assign(shapes, {
myShapeGroup: {
MyShape,
MyShapeView
},
standard: {
Rectangle: shapes.standard.Rectangle
}
});
graph.fromJSON({
cells: [
{ type: 'myShapeGroup.MyShape'},
{
type: 'standard.Rectangle',
size: { width: 40, height: 40 },
position: {x: 40, y: 50 }
}
]
});
graph.getElements()[0].set('faded', true);
The custom view implementation looks quite similar to our custom namespace example from earlier, but with a few key differences.
The first major difference is that we define our custom view using dia.ElementView.extend({})
. When the custom view is
defined, it will listen to the underlying model changes, and update itself. When we are satisfied with the functionality we have
created in our view, we then need to set up the namespace correctly once more. In our example, we do this by extending our namespace
with MyShapeView
. The Paper will search for any model types with a suffix of 'View' in our namespace.
As we have completed the set up, that means the behaviour defined in our custom view is now available for us to use how we want.
A more powerful alternative is to override the default view found in the namespace. Which approach you use will depend on your specific
needs. To override the default view, we use the elementView
setting in our Paper options. You can see that in action below.
const MyShapeView = dia.ElementView.extend({
// Custom view functionality
});
const graph = new dia.Graph({}, { cellNamespace: shapes });
const paper = new dia.Paper({
// Options
elementView: () => MyShapeView
});
That's all we will cover in this tutorial. Thanks for staying with us if you got this far. If you would like to explore any of the features mentioned here in more detail, you can find extra information in our JointJS documentation.