Custom shape with TypeScript

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.

A basic shape

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.

Attributes, a closer look

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.

Markup

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.

Methods

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.

"type", a few more notes

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,
    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.

Defaults alternative

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 JointJS+ demos.

Custom Views

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,
    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.