Content Driven Elements

JointJS provides its users with a lot of flexibility when it comes to creating custom shapes. You may have already taken advantage of this flexibility by using traditional JointJS mechanisms such as markup and attrs. This is a well-established method for creating custom elements, but it's not the only approach that you could utilize.

Another approach to creating custom elements is to allow the content to drive the size of the element. That means we don't set the element sizing explicitly, but allow the element dimensions to be derived from the content itself.

Depending on your use case, a content driven element may not be essential for your workflow, so your next question might be, why would I need to use one?

Imagine working with a lot of data points, what if each one has a label of different length, and you want to make sure the label doesn't extend beyond its element bounds? In this instance, it might not be desirable to set the element's size in retrospect. It's possible a more efficient workflow might be to allow the label content to drive element sizing instead.

With content driven elements, you are not limited to what content drives the element sizing, but as the most common types of content are probably text and images, we will focus on those in this tutorial.

JointJS source code: content-driven-element.js

Shape Definition

The first step towards content driven elements is to define our shape. We start by creating a Shape class which inherits from joint.dia.Element. Since we aren't using traditional markup or attrs, we just provide a few default properties we want to work with in our shape.

type is a unique path identifier where JointJS looks for our shape. As joint.shapes is implied, the full path is joint.shapes.custom.Shape. The remaining default attributes on our model are related to the visual aspects of our shape. As we update these attributes on our model, we would like the view to update itself too.

class Shape extends joint.dia.Element {
    defaults() {
        return {
            ...super.defaults,
            type: 'custom.Shape',
            fillColor: 'red',
            outlineColor: 'blue',
            label: '',
            image: ''
        };
    }
}

The main magic of our content driven element happens in a layout function. As we want our layout to have access to certain model properties during calculation, we should preinitialize() our instances with those properties.

preinitialize() is a specialized method for use with models as ES classes. If we define it, it is invoked when the model is first created, before any instantiation logic is run for the model.

preinitialize() {
    this.spacing = 10;
    this.labelAttributes = {
      'font-size': 14,
      'font-family': 'sans-serif',
    };
    this.imageAttributes = {
      'width': 50,
      'height': 50,
      'preserveAspectRatio': 'none'
    };
    this.cache = {};
}

When we initialize() our shape instance, we accomplish a number of things. We add an event listener to detect any attribute changes in our model, and we also set the size of our element based on the values returned from our layout calculation.

The onAttributeChange() method checks if attributes that affect the size of our element have changed. If there is no label present in the changes, that means we don't need to recalculate the label size, and can use the dimensions stored in the cache.

If either the image or label are present in the changes, we then need to recalculate the size of our element. We achieve this in the setSizeFromContent() method which derives the width and height from our layout.

initialize() {
    super.initialize();
    this.on('change', this.onAttributeChange);
    this.setSizeFromContent();
}

/* Attributes that affects the size of the model. */
onAttributeChange() {
    const {
      changed,
      cache
    } = this;
    if ('label' in changed) {
      // invalidate the cache only if the text of the `label` has changed
      delete cache.label;
    }
    if ('label' in changed || 'image' in changed) {
      this.setSizeFromContent();
    }
}

setSizeFromContent() {
    delete this.cache.layout;
    const {
      width,
      height
    } = this.layout();
    this.resize(width, height);
}

As we mentioned earlier, most of the action relating to the dimensions of our content driven element happens in the layout. In the following code, you will see how we utilize properties introduced in the preinitialize() method to create a flexible layout.

The layout() method first determines if there are any layout metrics already present in the cache, and if not, calls the calcLayout() method to create them.

layout() {
    const {
        cache
    } = this;
    let {
        layout
    } = cache;
    if (layout) {
        return layout;
    } else {
        const layout = this.calcLayout();
        cache.layout = layout;
        return layout;
    }
}

calcLayout() {
    const {
        attributes,
        labelAttributes,
        imageAttributes,
        spacing,
        cache
    } = this;
    let width = spacing * 2;
    let height = spacing * 2;
    let x = spacing;
    let y = spacing;
    // image metrics
    let $image;
    if (attributes.image) {
        const {
            width: w,
            height: h
        } = imageAttributes;
        $image = {
            x,
            y,
            width: w,
            height: h
        };
        height += spacing + h;
        y += spacing + h;
        width += w;
    } else {
        $image = null;
    }
    // label metrics
    let $label; {
        let w, h;
        if ('label' in cache) {
            w = cache.label.width;
            h = cache.label.height;
        } else {
            const {
                width,
                height
            } = measureText(svg, attributes.label, labelAttributes);
            w = width;
            h = height;
            cache.label = {
                width,
                height
            };
        }
        width = Math.max(width, spacing + w + spacing);
        height += h;
        if (!h) {
            // no text
            height -= spacing;
        }
        $label = {
            x,
            y,
            width: w,
            height: h
        };
    }
    // root metrics
    return {
        x: 0,
        y: 0,
        width,
        height,
        $image,
        $label
    };
}

When calculating the label dimensions, if no label is present in the cache, we use a helper function measureText() to get the dimensions for us. While a little unorthodox, we need to temporarily render a text element in the DOM to get the correct measurement, and it proves to work nicely.

const svg = paper.svg;

function measureText(svgDocument, text, attrs) {
    const vText = V('text').attr(attrs).text(text);
    vText.appendTo(svgDocument);
    const bbox = vText.getBBox();
    vText.remove();
    return bbox;
}

Custom Shape View

The other important aspect of our content driven element is a custom element view. The view is responsible for rendering our shape, and working with our element visually. Our custom view also listens to underlying model changes, and updates itself.

The first thing we notice in our view is the presentationAttributes. The attributes property of our model contains the presentationAttributes. You can see we are extending the existing presentation attributes, while making sure the original CellView attributes are preserved.

An important note about the view is that, when it needs an update, it first requests that update from the paper. Update requests are sent to the paper via flags. presentationAttributes is simply an object that maps attributes to flag labels.

confirmUpdate() receives all scheduled flags, and based on them updates the view. In our example, it isn't necessary to perform updates for resizing DOM elements if the received flag is '@color'.

const ElementView = joint.dia.ElementView;

const ShapeView = ElementView.extend({

    presentationAttributes: ElementView.addPresentationAttributes({
        // attributes that changes the position and size of the DOM elements
        label: [ElementView.Flags.UPDATE],
        image: [ElementView.Flags.UPDATE],
        // attributes that do not affect the size
        outlineColor: ['@color'],
        fillColor: ['@color'],
    }),

    confirmUpdate: function(...args) {
        let flags = ElementView.prototype.confirmUpdate.call(this, ...args);
        if (this.hasFlag(flags, '@color')) {
            // if only a color is changed, no need to resize the DOM elements
            this.updateColors();
            flags = this.removeFlag(flags, '@color');
        }
        // must return 0
        return flags;
    }

    // Other Methods
});

joint.shapes.custom = {
    Shape,
    ShapeView
};

The render() function runs once during initialization. It is responsible for creating the DOM elements, and updates during the initial render.

update() and updateColors() are methods responsible for updating our view, and will run when the appropriate flags have been received by confirmUpdate().

/* Runs only once while initializing */
render: function() {
    const {
        vel,
        model
    } = this;
    const body = this.vBody = V('rect').addClass('body');
    const label = this.vLabel = V('text').addClass('label').attr(model.labelAttributes);
    this.vImage = V('image').addClass('image').attr(model.imageAttributes);
    vel.empty().append([
        body,
        label
    ]);
    this.update();
    this.updateColors();
    this.translate(); // default element translate method
},

update: function() {
    const layout = this.model.layout();
    this.updateBody();
    this.updateImage(layout.$image);
    this.updateLabel(layout.$label);
},

updateColors: function() {
    const {
        model,
        vBody
    } = this;
    vBody.attr({
        fill: model.get('fillColor'),
        stroke: model.get('outlineColor')
    });
},

updateBody: function() {
    const {
        model,
        vBody
    } = this;
    const {
        width,
        height
    } = model.size();
    const bodyAttributes = {
        width,
        height
    };
    vBody.attr(bodyAttributes);
},

updateImage: function($image) {
    const {
        model,
        vImage,
        vel
    } = this;
    const image = model.get('image');
    if (image) {
        if (!vImage.parent()) {
            vel.append(vImage);
        }
        vImage.attr({
            'xlink:href': image,
            x: $image.x,
            y: $image.y
        });

    } else {
        vImage.remove();
    }
},

updateLabel: function($label) {
    const {
        model,
        vLabel
    } = this;
    vLabel.attr({
        'text-anchor': 'middle',
        x: $label.x + $label.width / 2,
        y: $label.y + $label.height / 2
    });
    vLabel.text(model.get('label'), {
        textVerticalAnchor: 'middle'
    });
}

That's it for configuration, and now it's finally time to see our content driven element on screen. We do this by creating an instance of our shape, and setting some attribute values with techniques you are already familiar with.

Once we add our element to the graph, you'll notice that our content, including a rather long label, doesn't extend beyond the bounds of its element. A content driven approach may not be required for your use case, but I'm sure you'll agree it has its advantages after seeing it in action.

const customShape4 = new Shape();
customShape4
    .set('image', 'https://via.placeholder.com/150/FF0000')
    .set('label', 'Lorem ipsum dolor sit amet,\nconsectetur adipiscing elit.\nInteger vehicula.')
    .set('outlineColor', 'red')
    .position(200, 50)
    .prop('fillColor', 'lightgray')
    .addTo(graph);

Thanks for reading. I hope you consider content driven elements if it's suitable for your application. If you would like to explore any of the features mentioned here in more detail, you can find extra information in our JointJS documentation.