Search code examples
javascriptcanvasfabricjs

fabric.js arrows and arrow head rotating


I want to create arrows in fabric.js that connect two objects together.

I have a jsFiddle link here: http://jsfiddle.net/xvcyzh9p/45/ (thanks to @gco).

The above allows you to create two objects (two rectangles) and connect them together with a line.

function addChildLine(options) {
canvas.off('object:selected', addChildLine);

// add the line
var fromObject = canvas.addChild.start;
var toObject = options.target;
var from = fromObject.getCenterPoint();
var to = toObject.getCenterPoint();
var line = new fabric.Line([from.x, from.y, to.x, to.y], {
    fill: 'red',
    stroke: 'red',
    strokeWidth: 5,
    selectable: false
});
canvas.add(line);
// so that the line is behind the connected shapes
line.sendToBack();

// add a reference to the line to each object
fromObject.addChild = {
    // this retains the existing arrays (if there were any)
    from: (fromObject.addChild && fromObject.addChild.from) || [],
    to: (fromObject.addChild && fromObject.addChild.to)
}
fromObject.addChild.from.push(line);
toObject.addChild = {
    from: (toObject.addChild && toObject.addChild.from),
    to: (toObject.addChild && toObject.addChild.to) || []
}
toObject.addChild.to.push(line);

// to remove line references when the line gets removed
line.addChildRemove = function () {
    fromObject.addChild.from.forEach(function (e, i, arr) {
        if (e === line)
            arr.splice(i, 1);
    });
    toObject.addChild.to.forEach(function (e, i, arr) {
        if (e === line)
            arr.splice(i, 1);
    });
}
canvas.addChild = undefined;
}

function addChildMoveLine(event) {
    canvas.on(event, function (options) {
        var object = options.target;
        var objectCenter = object.getCenterPoint();
        // udpate lines (if any)
        if (object.addChild) {
            if (object.addChild.from)
                object.addChild.from.forEach(function (line) {
                    line.set({ 'x1': objectCenter.x, 'y1': objectCenter.y });
                })
                if (object.addChild.to)
                    object.addChild.to.forEach(function (line) {
                        line.set({ 'x2': objectCenter.x, 'y2': objectCenter.y });
                    })
                    }

        canvas.renderAll();
    });
}

I have tried looking at other examples to create an arrow in fabric.js but to implement in gco's fiddle has been a pain.

My best attempt at this can be found here: http://example.legalobjects.com/

It looks something like this: enter image description here

Some of the problems I have experience are:

  • The arrow head not moving around the in the correct direction
  • The arrow head (or multiple arrow heads) breaking when more than one arrow is added to the canvas - they get "stuck" for some reason.
  • Arrow head/line not moving around the object

When adding more than one arrow this happens: enter image description here

If anyone has any ideas or can help at all, I would be very appreciative!

Thanks.


Solution

  • Let's go through your issues.

    The arrow head (or multiple arrow heads) breaking when more than one arrow is added to the canvas - they get "stuck" for some reason.

    This is caused by you using a single triangle global variable, which means there can only really be one triangle. This is trivial to resolve though - just replace triangle with line.triangle, so that it becomes a property of the line it belongs to.

    E.g. instead of

    triangle = new fabric.Triangle({
    

    simply use

    line.triangle = new fabric.Triangle({
    

    and consequently replace

    line.addChildRemove();
    line.remove();
    line.triangle.remove();
    

    with

    line.triangle.remove();
    line.addChildRemove();
    line.remove();
    

    The arrow head not moving around the in the correct direction

    This is a simple shortcoming of your direction logic. There's no part that's really wrong, except that it doesn't do what you desire. I reworked it to the following snippet:

    object.addChild.to.forEach(function(line) {
        var x = objectCenter.x;
        var y = objectCenter.y;
        var xdis = REC_WIDTH/2 + TRI_WIDTH/2;
        var ydis = REC_HEIGHT/2 + TRI_HEIGHT/2;
        var horizontal = Math.abs(x - line.x2) > Math.abs(y - line.y2);
        line.set({
            'x2': x + xdis * (horizontal ? (x < line.x2 ? 1 : -1) :                      0),
            'y2': y + ydis * (horizontal ?                      0 : (y < line.y2 ? 1 : -1))
        });
        line.triangle.set({
            'left': line.x2, 'top': line.y2,
            'angle': calcArrowAngle(line.x1, line.y1, line.x2, line.y2)
        });
    });
    

    The basic idea is to offset either the X or the Y coordinate, based on whether the X or Y distance of the two objects are greater.

    Arrow head/line not moving around the object.

    Another logic flaw in the snippet posted just above. Using x2 and y2 for the calculation of the triangle is correct, since you're basing its location of the target rectangle. For the line however, you want to base calculations on the location of the source rectangle, so you need to use x1 and y1 respectively. So we can change the above code again to:

    object.addChild.to.forEach(function(line) {
        var x = objectCenter.x;
        var y = objectCenter.y;
        var xdis = REC_WIDTH/2 + TRI_WIDTH/2;
        var ydis = REC_HEIGHT/2 + TRI_HEIGHT/2;
        var horizontal = Math.abs(x - line.x1) > Math.abs(y - line.y1);
        line.set({
            'x2': x + xdis * (horizontal ? (x < line.x1 ? 1 : -1) :                      0),
            'y2': y + ydis * (horizontal ?                      0 : (y < line.y1 ? 1 : -1))
        });
        line.triangle.set({
            'left': line.x2, 'top': line.y2,
            'angle': calcArrowAngle(line.x1, line.y1, line.x2, line.y2)
        });
    });
    

    For a more seamless experience, you also need to change how the recalculations are invoked. Currently you only update the part of the line that is connected to the rectangle that you're actually moving, but not the other half of the connection.
    I implemented this by replacing the to and from arrays with a simple, single lines array, and adding properties fromObject and toObject to each line element - on every update, both ends of the line get updated, like so:

    if (object.addChild && object.addChild.lines) {
        object.addChild.lines.forEach(function(line) {
            var fcenter = line.fromObject.getCenterPoint(),
                fx = fcenter.x,
                fy = fcenter.y,
                tcenter = line.toObject.getCenterPoint(),
                tx = tcenter.x,
                ty = tcenter.y,
                xdis = REC_WIDTH/2 + TRI_WIDTH/2,
                ydis = REC_HEIGHT/2 + TRI_HEIGHT/2,
                horizontal = Math.abs(tx - line.x1) > Math.abs(ty - line.y1)
            line.set({
                'x1': fx,
                'y1': fy,
                'x2': tx + xdis * (horizontal ? (tx < line.x1 ? 1 : -1) :                       0),
                'y2': ty + ydis * (horizontal ?                       0 : (ty < line.y1 ? 1 : -1)),
            });
            line.triangle.set({
                'left': line.x2, 'top': line.y2,
                'angle': calcArrowAngle(line.x1, line.y1, line.x2, line.y2)
            });
        });
    }
    

    In addition to that you have an unhandled use case: You get an error if you select a box, delete it and then click "add child". You can prevent that by simply testing for null at the top of the addChild function like so:

    if(canvas.getActiveObject() == null)
    {
        return;
    }
    

    With all of the above, I created an updated fiddle.


    Update, years later: it turns out that you can drag to select multiple rectangles, which puts them in a group that you can move as a whole, and doing that would leave the arrow behind in my solution above (as noted by "sugars" in the comments). The solution to that is to embed the updating code into its own function and recursively call that for each object of the target object of the event is a group. Then you also run into the problem of the object coordinates becoming relative to the center of the group, so you have to adjust for that too. I made an updated fiddle with that fixed as well.

    The code for the new update function is:

    function addChildMoveLine(event) {
        canvas.on(event, function(options) {
            if((function updateObject(object, offset) {
                // update child objects, if any
                if (object.getObjects) {
                    var off = object.getCenterPoint();
                    return object.getObjects().reduce(function(flag, obj) {
                        return updateObject(obj, off) || flag;
                    }, false);
                }
                if (!offset)
                    offset = { x: 0, y: 0 };
                // otherwise udpate lines, if any
                if (object.addChild && object.addChild.lines) {
                    object.addChild.lines.forEach(function(line) {
                        var fcenter = line.sourceObject.getCenterPoint(),
                            fx = fcenter.x + offset.x,
                            fy = fcenter.y + offset.y,
                            tcenter = line.targetObject.getCenterPoint(),
                            tx = tcenter.x + offset.x,
                            ty = tcenter.y + offset.y,
                            xdis = REC_WIDTH/2 + TRI_WIDTH/2,
                            ydis = REC_HEIGHT/2 + TRI_HEIGHT/2,
                            horizontal = Math.abs(tx - line.x1) > Math.abs(ty - line.y1);
                        line.set({
                            'x1': fx,
                            'y1': fy,
                            'x2': tx + xdis * (horizontal ? (tx < line.x1 ? 1 : -1) :                       0),
                            'y2': ty + ydis * (horizontal ?                       0 : (ty < line.y1 ? 1 : -1)),
                        });
                        line.triangle.set({
                            'left': line.x2, 'top': line.y2,
                            'angle': calcArrowAngle(line.x1, line.y1, line.x2, line.y2)
                        });
                    });
                    return true; // re-render
                }
                return false; // no re-render needed
            })(options.target)) {
                canvas.renderAll();
            }
        });
    }