Custom shapes
JointJS comes with several collections of built-in element shapes. You can find more information on using these built-in shapes in this section.
However, even though there are many default shapes to choose from, you might find that you are missing one and want to create your own shape definition. Creating new shapes is very simple in JointJS if you already know SVG. The most important SVG elements are rect
, text
, circle
, ellipse
, image
and path
. Their thorough description with examples and illustrations can be found elsewhere on the Internet, e.g. on MDN. What is important for us is to realize that combining the basic SVG shapes, we can define any 2D shape we need.
If you like video, the following youtube content will show you how to create custom JointJS elements using SVG. This tutorial will still provide you with a comprehensive guide to defining custom shapes, even if you decide to watch the video or not.
A basic shape
There are two primary ways to create a basic shape in JointJS:
- Using the
Cell.define()
function. - Defining a shape as a new ES6 class that inherits from
dia.Element
ordia.Link
.
While all shapes are descendants of dia.Cell
class, when creating new shapes you should inherit either from dia.Element
or dia.Link
classes depending on your desired shape. In addition to the base classes you can also inherit from any shape in the predefined JointJS shape collections and even any custom element subtype you have previously defined.
We suggesting you to use ES6 classes for defining new shapes, as it is more flexible and allows you to use the full power of JavaScript or TypeScript. However, if you prefer the Cell.define()
function, you can use it as well.
JointJS is fully compatible with TypeScript and you can create your custom shapes using TypeScript classes. The following examples are written in JavaScript, but can be used in TypeScript as well. We provide TypeScript definitions for all JointJS classes and functions, so you can use them in your projects without any issues.
Using Cell.define()
function
You can use Cell.define()
function to declare custom shapes. To use it properly you should call define method on the dia.Element
or dia.Link
class as well as on the already existing shape.
Here is how the parameters of the define()
function map to familiar building blocks of elements:
type
- the type of the subtype class.defaultInstanceProperties
- object that contains properties to be assigned to every constructed instance of the subtype. Used for specifying default attributes.prototypeProperties
- object that contains properties to be assigned on the subtype prototype. Intended for properties intrinsic to the subtype, not usually modified. Used for specifying shape markup and for any functionality that is shared by all instances of the subtype (e.g. property validation).staticProperties
- object that contains properties to be assigned on the subtype constructor. Not very common, used mostly for alternative constructor functions.
Let's use the familiar shapes.standard.Rectangle
shape definition as an example:
import { dia } from "@joint/core";
dia.Element.define('standard.Rectangle', {
attrs: {
body: {
width: 'calc(w)',
height: 'calc(h)',
strokeWidth: 2,
stroke: '#000000',
fill: '#FFFFFF'
},
label: {
textVerticalAnchor: 'middle',
textAnchor: 'middle',
x: 'calc(0.5*w)',
y: 'calc(0.5*h)',
fontSize: 14,
fill: '#333333'
}
}
}, {
markup: [{
tagName: 'rect',
selector: 'body',
}, {
tagName: 'text',
selector: 'label'
}]
});
Let's define a basic custom shape using ES6 classes:
import { dia } from '@joint/core';
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.
Attributes
During your custom shape declaration you should specify default attributes for your model. These attributes will be used as a template for every instance of your custom shape. The attributes are defined in the defaults()
function of your custom shape class.
Type
The first attribute we will look at is type
. The type
is a unique path identifier to help JointJS find our shape. The most important usage of a type is to provide cellNamespace for your graph. You can find more information about cellNamespaces in the following section.section.
Lets modify our MyShape
class to include a type
attribute:
import { dia, shapes } from '@joint/core';
export class MyShape extends dia.Element {
defaults() {
return {
type: 'myNamespace.MyShape',
...super.defaults,
}
}
}
The type
path is very important, especially if you want to import a graph from JSON. graph
would look at relevant namespace path to find the correct constructor. Let's use our custom shape type in the graph cell namespace:
import { dia, shapes } from '@joint/core';
const namespace = { ...shapes, myNamespace: { MyShape }};
const graph = new dia.Graph({}, { cellNamespace: namespace });
Optional attributes
Besides the type
attribute, you may want to specify default values for various attributes you may want to use with your shapes. Elements and links have their own special model attributes like size
, source
, router
etc. You can learn about model attributes in dedicated element and link sections.
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 presentation attributes.
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'
}
}
}
}
Using defaultsDeep
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 '@joint/core';
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 example above, 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.
Markup
The markup
is a special prototype property which is specified as a JSON array. Each member of the array is taken to define one subelement of the new shape. Subelements are defined with objects containing a tagName
(a string with the SVG tag name of the subelement) and a selector
(a string identifier for this subelement in the shape). Although JointJS can also understand SVG markup in string form, that approach is noticeably slower due to the need for parsing and lack of capacity for custom selectors.
The preferred way to specify markup is to use util.svg()
tagged template. This function is a helper that allows you to write SVG markup in a more readable way. The markup
is treated as a prototype property and should exist before the element is instantiated. To achieve it you should declare your markup in special preinitialize()
function. Let's add markup to our custom shape:
import { dia, shapes, util } from '@joint/core';
const markup = util.svg`
<ellipse @selector="body"/>
<text @selector="label"/>
`;
export class MyShape extends dia.Element {
preinitialize() {
this.markup = markup;
}
defaults() {
return {
...super.defaults,
type: 'myNamespace.MyShape',
size: { width: 100, height: 80 },
attrs: {
body: {
// Attributes
},
label: {
// Attributes
}
}
}
}
}
We are markup in a separate variable to avoid creating a new instance of the template for each new shape instance. This is a good practice to avoid unnecessary memory usage and increase performance.
You can use selectors you defined in your markup to target SVG subelements to get or set specific attributes:
// set the <rect/> color to red
el.attr('body/fill', 'red');
Methods
Additionally, let's create several methods for our custom shape. We can create prototype methods to call on our constructor, or we can create static methods to call on the class itself.
export class MyShape extends dia.Element {
/**
*
*/
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);
Custom Views
Custom views provide some extra flexibility when it comes to working with our models. If we want some additional behavior, but don't believe that behavior 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.
import { dia, util } from '@joint/core';
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 = util.svg`
<rect @selector="body" 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,
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 behavior 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
});
Custom elements example
Let's apply everything we learned so far and create two custom elements: Node
class based on dia.Element
and ButtonNode
element based on Node
shape, which contains HTML element inside (more on this topic in our HTML inside shapes section). Keep in mind that the provided instance properties are mixined with the parent definition, but prototype and static properties are not. This means that it is enough for us to record only the attributes that changed in the definition of the custom element subtype (however, if we wanted to change the markup, we would have to do so explicitly).
import { dia, shapes, util } from '@joint/core';
const nodeMarkup = util.svg/* xml */`
<rect @selector="nodeBody"/>
<clipPath @selector="clipPath"><rect @selector="clipPathRect"/></clipPath>
<rect @selector="nodeHeader"/>
<text @selector="nodeHeaderLabel"/>
<text @selector="label"/>
`
class Node extends dia.Element {
preinitialize() {
this.markup = nodeMarkup;
}
defaults() {
const clipId = util.uuid();
return {
...super.defaults,
type: 'examples.Node',
size: {
width: 150,
height: 80
},
attrs: {
root: {
cursor: 'move'
},
nodeBody: {
width: 'calc(w)',
height: 'calc(h)',
fill: '#023047',
rx: 6,
},
nodeHeaderLabel: {
x: 15,
y: 20 / 2 + 1,
textAnchor: 'start',
textVerticalAnchor: 'middle',
fill: '#023047',
text: 'Node',
fontSize: 10,
},
nodeHeader: {
x: 0,
y: 0,
width: 'calc(w)',
height: 20,
clipPath: `url(#${clipId})`,
fill: '#ffb703'
},
clipPath: {
id: clipId
},
clipPathRect: {
width: 'calc(w)',
height: 'calc(h)',
rx: 6,
},
label: {
x: 'calc(w/2)',
y: 'calc(h/2+10)',
textAnchor: 'middle',
textVerticalAnchor: 'middle',
fill: 'white'
},
}
}
}
}
We add our shapes to the graph in the same manner described in our quick start tutorial. In the code below, we add a examples.Node
shape with custom color and a multiline label. The multiline label respects the x
and y
values that we calculated with the calc()
function earlier.
const n2 = new Node();
n2.position(50, 125);
n2.resize(200, 100);
n2.attr({
nodeBody: {
fill: '#219ebc'
},
label: {
text: 'Node\nanother color'
}
});
n2.addTo(graph);
To create ButtonNode
shape, we will extend our Node
class and add a button element to the markup. Notice, that we concatenate the markup
property of the parent class with the new markup for the button element. To style the HTML inside the element we are using CSS classes which we can setup in the markup
or in the attrs
property.
import { dia, shapes, util } from '@joint/core';
const buttonNodeMarkup = util.svg/* xml */`
<foreignObject @selector="foreignObject">
<div class="button-container" @selector="buttonContainer">
<button class="button" @selector="button"></button>
</div>
</foreignObject>
`;
class ButtonNode extends Node {
preinitialize() {
super.preinitialize();
this.markup = this.markup.concat(buttonNodeMarkup);
}
defaults() {
const clipId = util.uuid();
return util.defaultsDeep({
type: 'examples.ButtonNode',
buttonTextVisible: 'Hide text',
buttonTextHidden: 'Show text',
attrs: {
nodeHeaderLabel: {
text: 'Button node'
},
label: {
y: 'calc(h/2+25)',
visibility: 'hidden'
},
foreignObject: {
x: 0,
y: 0,
width: 'calc(w)',
height: 'calc(h)',
}
}
}, super.defaults());
}
}
To implement listen to button events we are creating custom view for our ButtonNode
shape:
export class ButtonNodeView extends dia.ElementView {
render() {
super.render();
this.el.getElementsByTagName('button')[0].textContent =
this.model.get('buttonTextHidden');
}
events() {
return {
'click button': (evt) => { this.onButtonClick(evt) }
}
}
onButtonClick(evt) {
const visibility = this.model.attr('label/visibility');
if (visibility === 'hidden') {
this.model.attr('label/visibility', 'visible');
evt.target.textContent = this.model.get('buttonTextVisible');
} else {
this.model.attr('label/visibility', 'hidden');
evt.target.textContent = this.model.get('buttonTextHidden');
}
}
}
Here we are listening for a click event on a button element. When the button is clicked, we change the visibility of the label text and the text of the button.
If you want to learn more about creating custom shapes using Typescript, you can find more information in the Typescript integration section.
The following example shows the default look of our Node
and ButtonNode
shapes with different attributes.
Custom links example
Default label
When creating new links you may want to change default link label attributes. The second argument of the define()
function (default instance properties) is also where defaultLabel
for custom Link subtypes may be specified. This allows you to provide custom default markup, size, attrs and position for labels that are created on an instance of your custom Link type. The defaultLabel
property is explained in separate section.
The shapes.standard.Link
does not define its own custom default label, so the built-in default markup, attributes and position are used unless they are overridden by individual label markup
, attrs
and position
.
The defaultLabel
accepts four optional properties:
markup
- sets the default markup of labels created on this Link subtype. Expected in the JSON markup format or as autil.svg
ES6 tag template. (An SVG-parsable string is also accepted, but it is slower.).size
- sets the default dimensions of labels created on this Link subtype.attrs
- sets the default attributes of label subelements on this Link subtype. Uses selectors defined inmarkup
.position
- sets the default position along the line at which labels will be added for instances of this Link subtype. The default is{distance: 0.5}
(midpoint of the connection path).
Link labels are explained in depth in a separate section.
Example
Let's apply our knowledge and create two custom links: Connection
link based on standard.Link
class and TwoWayConnection
link based on the Connection
shape.
const defaultLabelMarkup = util.svg/* xml */`
<rect @selector="body"/>
<text @selector="label"/>
`;
class Connection extends shapes.standard.Link {
defaults() {
return util.defaultsDeep({
type: 'examples.Connection',
defaultLabel: {
markup: defaultLabelMarkup,
attrs: {
label: {
fill: 'black', // default text color
fontSize: 12,
textAnchor: 'middle',
yAlignment: 'middle',
pointerEvents: 'none'
},
body: {
ref: 'label',
fill: 'white',
stroke: 'cornflowerblue',
strokeWidth: 2,
width: 'calc(1.2*w)',
height: 'calc(1.2*h)',
x: 'calc(x-calc(0.1*w))',
y: 'calc(y-calc(0.1*h))'
}
},
position: {
distance: 100, // default absolute position
args: {
absoluteDistance: true
}
}
},
attrs: {
line: {
stroke: 'cornflowerblue',
strokeWidth: 5,
targetMarker: {
'type': 'rect',
'width': 10,
'height': 20,
'y': -10,
'stroke': 'none'
}
}
},
}, super.defaults);
}
};
class TwoWayConnection extends Connection {
defaults() {
return util.defaultsDeep({
type: 'examples.TwoWayConnection',
attrs: {
line: {
sourceMarker: {
'type': 'rect',
'width': 10,
'height': 20,
'y': -10,
'stroke': 'none'
}
}
},
}, super.defaults());
}
};
We did not specify any defaultLabel.size
object in our example, so JointJS was missing overarching reference width and height dimensions for use in the various attrs/body
calc()
operations. In order to use calc()
operations anyways - specifically on attrs/body
- we instead provided a ref
special attribute to identify a reference subelement from which the dimensions could be determined. In our example, the reference subelement was the label
SVGTextElement, so JointJS used its dimensions for the calc()
operations inside attrs/body
. (If we had specified neither defaultLabel.size
nor an individual ref
attribute on attrs/body
, however, the calculations would have used 0
as the reference width and height, which would have been unexpected.)
Additionally, note that in our example the x
and y
position of the body
subelement is also determined by reference to the label
subelement - via calc()
operations which refer to the x
and y
variables.
The following example shows our Connection
and TwoWayConnection
links in action:
Frequently asked questions
Can I have two labels on an element?
Absolutely. You can have as many labels as you need on an element - see below. Similarly, you can also have any number of labels on links and ports.
How can I give multiple labels to an element?
You can give multiple labels to an element by creating a new element type with extra label subelements inside its markup
property.
Learn more...
Element labels are part of the element type definition. This is a difference from links, which have a built-in API for adding and removing labels (to any link type).
In this example, the new element type is called TwoLabelsElement
, and the two <text/>
label subelements have selectors label1
and label2
:
How can I create an element with a hyperlink?
It is possible to define custom JointJS elements with hyperlinks pointing to external sites - by including the <a> SVG element in the markup of a custom element. The navigation path is then specified in individual elements' attributes of the custom type.
Learn more...
The two most important XLink attributes in this context are the following:
xlink:href
- set the target resource URL.xlink:show
- how should the linked content be presented? Possible values are'replace'
(show in current window; default) and'new'
(show in new window).
Why can't I click on a link's arrowhead?
In the built-in link types, the arrowhead does not capture pointer events because it's made using <marker/>
SVGElements (which are not a part of the DOM tree). You can get around this limitation by specifying a custom link arrowhead as part of the link's markup
- see below.
How can I make a link's arrowhead clickable?
You can make a link's arrowhead clickable by creating a new link type with an extra <path/>
subelement inside its markup
property (instead of the usual approach which relies on JointJS marker attributes, like sourceMarker
, targetMarker
, vertexMarker
).
Learn more...
The example utilizes the atConnectionRatio attribute to position the arrowhead subelement (selector: targetArrowhead
) and event attribute to fire a custom event.
How can I give a link two colors?
You can give a link two colors by defining a new link type with custom logic for the stroke-dasharray
attribute.
Learn more...
In this example, we define a custom link type with two extra subelements (selectors: first
and second
) to be colored, differentiated by the custom index
property. We refer to these index
values inside the custom indexAttributeSetter()
function, which calculates the stroke-dasharray
value for each subelement based on the current length of the Link.
To find out more about defining and using custom attributes, see the Working with model attributes section in the JointJS documentation.