Labels on custom elements disappear after entering


#1

Hello there!

I want to add labels to some custom elements I implemented. I already wrote a customLabelEditingProvider. When I finish editing the label in the modeler, the text vanishes.

Has someone any idea of what I am missing?

Many thanks!


#2

Can you provide more context? Is there an example you used as a starting point? How do your custom elements look like? How does you provider look like? Code snippets would be helpful.


#3

I used the custom elements example as my starting point.

My CustomRenderer looks like this:

import inherits from 'inherits';

import BaseRenderer from 'diagram-js/lib/draw/BaseRenderer';

import {
  componentsToPath,
  createLine
} from 'diagram-js/lib/util/RenderUtil';

import {
  append as svgAppend,
  attr as svgAttr,
  create as svgCreate
} from 'tiny-svg';

/**
 * A renderer that knows how to render custom elements.
 */
export default function CustomRenderer(eventBus, styles) {

  BaseRenderer.call(this, eventBus, 2000);

  var computeStyle = styles.computeStyle;

  // CUSTOM
  this.drawBarrier = function(p, width, height) {
    var x = 0,
        y = 0,
        w = width,
        h = height,
        rx = 0,
        ry = 0;

    var attrs = computeStyle(attrs, {
      stroke: '#000000',
      strokeWidth: 4,
      fill: 'white'
    });
    var barrier = svgCreate('rect');
    svgAttr(barrier, {
      x: x,
      y: y,
      width: w,
      height: h,
      rx: rx,
      ry: ry
    });
    svgAttr(barrier, attrs);
    svgAppend(p, barrier);
    return barrier;
  };

  this.getBarrierPath = function(shape) {
    var cx = shape.x + shape.width / 2,
        cy = shape.y + shape.height / 2,
        radius = shape.width / 2;
    var barrierPath = [
      ['M', cx, cy],
      ['m', 0, -radius],
      ['a', radius, radius, 0, 1, 1, 0, 2 * radius],
      ['a', radius, radius, 0, 1, 1, 0, -2 * radius],
      ['z']
    ];
    return componentsToPath(barrierPath);
  };

  this.drawEvent = function(p, width, height) {
    var cx = width / 2,
        cy = height / 2;

    var attrs = computeStyle(attrs, {
      stroke: '#000000',
      strokeWidth: 4,
      fill: 'white'
    });
    var custom_event = svgCreate('ellipse');
    svgAttr(custom_event, {
      cx: cx,
      cy: cy,
      rx: Math.round((width + height) / 2),
      ry: Math.round((width + height) / 4)
    });
    svgAttr(custom_event, attrs);
    svgAppend(p, custom_event);
    return custom_event;
  };


  this.getEventPath = function(shape) {
    var cx = shape.x + shape.width / 2,
        cy = shape.y + shape.height / 2,
        radius = shape.width / 2;
    var eventPath = [
      ['M', cx, cy],
      ['m', 0, -radius],
      ['a', radius, radius/2, 0, 0, 0, 0, 2 * radius],
      ['a', radius, radius/2, 0, 0, 0, 0, -2 * radius],
      ['z']
    ];
    return componentsToPath(eventPath);
  };

  this.drawTopEvent = function(p, width, height, element) {
    var cx = width / 2,
        cy = height / 2;
    var attrs = computeStyle(attrs, {
      stroke: '#000000',
      strokeWidth: 3,
      fill: 'white'
    });
    var outer = svgCreate('ellipse');
    svgAttr(outer, {
      cx: cx,
      cy: cy,
      rx: Math.round((width + height) / 2),
      ry: Math.round((width + height) / 4)
    });
    svgAttr(outer, attrs);
    var inner = svgCreate('ellipse');
    svgAttr(inner, {
      cx: cx,
      cy: cy,
      rx: Math.round(((width + height) / 2)-10),
      ry: Math.round(((width + height) / 4)-10)
    });
    svgAttr(inner, attrs);
    svgAppend(p, outer);
    svgAppend(p, inner);

    return outer;
  };


  this.getTopEventPath = function(shape) {
    var cx = shape.x + shape.width / 2,
        cy = shape.y + shape.height / 2,
        radiusX = shape.width / 2,
        radiusY = shape.height / 2;
    
    var topEventPath = [
      ['M', cx, cy],
      ['m', 0, -radiusX],
      ['a', radiusX, radiusY, 0, 0, 0, 0, 2 * radiusX],
      ['a', radiusX, radiusY, 0, 0, 0, 0, -2 * radiusX],
      ['z']
    ];
    return componentsToPath(topEventPath);
  };
  // END_CUSTOM

  this.drawCustomConnection = function(p, element) {
    var attrs = computeStyle(attrs, {
      stroke: '#0000ff',
      strokeWidth: 2
    });

    return svgAppend(p, createLine(element.waypoints, attrs));
  };

  this.getCustomConnectionPath = function(connection) {
    var waypoints = connection.waypoints.map(function(p) {
      return p.original || p;
    });

    var connectionPath = [
      ['M', waypoints[0].x, waypoints[0].y]
    ];

    waypoints.forEach(function(waypoint, index) {
      if (index !== 0) {
        connectionPath.push(['L', waypoint.x, waypoint.y]);
      }
    });

    return componentsToPath(connectionPath);
  };

}

inherits(CustomRenderer, BaseRenderer);

CustomRenderer.$inject = [ 'eventBus', 'styles' ];


CustomRenderer.prototype.canRender = function(element) {
  return /^custom:/.test(element.type);
};

CustomRenderer.prototype.drawShape = function(p, element) {
  var type = element.type;

  //CUSTOM
  if (type === 'custom:barrier') {
    return this.drawBarrier(p, element.width, element.height);
  }
  if (type === 'custom:event') {
    return this.drawEvent(p,element.width, element.height);
  }
  if (type === 'custom:topevent') {
    return this.drawTopEvent(p,element.width, element.height, element);
  }
  //END_CUSTOM

};

CustomRenderer.prototype.getShapePath = function(shape) {
  var type = shape.type;

  //CUSTOM
  if (type === 'custom:barrier') {
    return this.getBarrierPath(shape);
  }
  if (type === 'custom:event') {
    return this.getEventPath(shape);
  }
  if (type === 'custom:topevent') {
    return this.getTopEventPath(shape);
  }
  //END_CUSTOM
};

CustomRenderer.prototype.drawConnection = function(p, element) {

  var type = element.type;

  if (type === 'custom:connection') {
    return this.drawCustomConnection(p, element);
  }
};


CustomRenderer.prototype.getConnectionPath = function(connection) {

  var type = connection.type;

  if (type === 'custom:connection') {
    return this.getCustomConnectionPath(connection);
  }
};

(I removed custom:triangle and custom:circle - Code for the sake of clarity)


The CustomLabelEditingProvider looks as follows:

import {
  assign
} from 'min-dash';

import { getLabel } from 'bpmn-js/lib/features/label-editing/LabelUtil';

import { is } from 'bpmn-js/lib/util/ModelUtil';
import { isAny } from 'bpmn-js/lib/features/modeling/util/ModelingUtil';
import { isExpanded } from 'bpmn-js/lib/util/DiUtil';

import {
  getExternalLabelMid,
  isLabelExternal,
  hasExternalLabel,
  isLabel
} from 'bpmn-js/lib/util/LabelUtil';

export default function CustomLabelEditingProvider(
    eventBus, canvas, directEditing,
    modeling, resizeHandles, textRenderer) {

  this._canvas = canvas;
  this._modeling = modeling;
  this._textRenderer = textRenderer;

  directEditing.registerProvider(this);

  // listen to dblclick on non-root elements
  eventBus.on('element.dblclick', function(event) {
    activateDirectEdit(event.element, true);
  });

  // complete on followup canvas operation
  eventBus.on([
    'element.mousedown',
    'drag.init',
    'canvas.viewbox.changing',
    'autoPlace',
    'popupMenu.open'
  ], function(event) {

    if (directEditing.isActive()) {
      directEditing.complete();
    }
  });

  // cancel on command stack changes
  eventBus.on([ 'commandStack.changed' ], function(e) {
    if (directEditing.isActive()) {
      directEditing.cancel();
    }
  });


  eventBus.on('directEditing.activate', function(event) {
    resizeHandles.removeResizers();
  });

  eventBus.on('create.end', 500, function(event) {

    var element = event.shape,
        canExecute = event.context.canExecute,
        isTouch = event.isTouch;

    // TODO(nikku): we need to find a way to support the
    // direct editing on mobile devices; right now this will
    // break for desworkflowediting on mobile devices
    // as it breaks the user interaction workflow

    // TODO(nre): we should temporarily focus the edited element
    // here and release the focused viewport after the direct edit
    // operation is finished
    if (isTouch) {
      return;
    }

    if (!canExecute) {
      return;
    }

    activateDirectEdit(element);
  });

  eventBus.on('autoPlace.end', 500, function(event) {
    activateDirectEdit(event.shape);
  });


  function activateDirectEdit(element, force) {
    //CUSTOM
    if (force ||
        isAny(element, [ 'bpmn:Task', 'bpmn:TextAnnotation', 'custom:topevent' ]) ||
        isCollapsedSubProcess(element)) {

      directEditing.activate(element);
    }
  }

}

CustomLabelEditingProvider.$inject = [
  'eventBus',
  'canvas',
  'directEditing',
  'modeling',
  'resizeHandles',
  'textRenderer'
];


/**
 * Activate direct editing for activities and text annotations.
 *
 * @param  {djs.model.Base} element
 *
 * @return {Object} an object with properties bounds (position and size), text and options
 */
CustomLabelEditingProvider.prototype.activate = function(element) {

  // text
  var text = getLabel(element);

  // CUSTOM
  // prevent execution of return; in code below, because getLabel(element) of topevent is "undefined"
  if (element.type == 'custom:topevent') {
    text = '';
  }
  //END_CUSTOM

  if (text === undefined) {
    return;
  }

  var context = {
    text: text
  };

  // bounds
  var bounds = this.getEditingBBox(element);

  assign(context, bounds);

  var options = {};

  // tasks
  if (
    isAny(element, [
      'bpmn:Task',
      'bpmn:Participant',
      'bpmn:Lane',
      'bpmn:CallActivity',
      //CUSTOM
      'custom:topevent'
      //END_CUSTOM
    ]) ||
    isCollapsedSubProcess(element)
  ) {
    assign(options, {
      centerVertically: true
    });
  }

  // external labels
  if (isLabelExternal(element)) {
    assign(options, {
      autoResize: true
    });
  }

  // text annotations
  if (is(element, 'bpmn:TextAnnotation')) {
    assign(options, {
      resizable: true,
      autoResize: true
    });
  }

  assign(context, {
    options: options
  });

  //CUSTOM
  console.log('context:');
  console.log(context);
  //END_CUSTOM


  return context;
};


/**
 * Get the editing bounding box based on the element's size and position
 *
 * @param  {djs.model.Base} element
 *
 * @return {Object} an object containing information about position
 *                  and size (fixed or minimum and/or maximum)
 */
CustomLabelEditingProvider.prototype.getEditingBBox = function(element) {
  var canvas = this._canvas;

  var target = element.label || element;

  var bbox = canvas.getAbsoluteBBox(target);

  var mid = {
    x: bbox.x + bbox.width / 2,
    y: bbox.y + bbox.height / 2
  };

  // default position
  var bounds = { x: bbox.x, y: bbox.y };

  var zoom = canvas.zoom();

  var defaultStyle = this._textRenderer.getDefaultStyle(),
      externalStyle = this._textRenderer.getExternalStyle();

  // take zoom into account
  var externalFontSize = externalStyle.fontSize * zoom,
      externalLineHeight = externalStyle.lineHeight,
      defaultFontSize = defaultStyle.fontSize * zoom,
      defaultLineHeight = defaultStyle.lineHeight;

  var style = {
    fontFamily: this._textRenderer.getDefaultStyle().fontFamily,
    fontWeight: this._textRenderer.getDefaultStyle().fontWeight
  };

  // adjust for expanded pools AND lanes
  if (is(element, 'bpmn:Lane') || isExpandedPool(element)) {

    assign(bounds, {
      width: bbox.height,
      height: 30 * zoom,
      x: bbox.x - bbox.height / 2 + (15 * zoom),
      y: mid.y - (30 * zoom) / 2
    });

    assign(style, {
      fontSize: defaultFontSize + 'px',
      lineHeight: defaultLineHeight,
      paddingTop: (7 * zoom) + 'px',
      paddingBottom: (7 * zoom) + 'px',
      paddingLeft: (5 * zoom) + 'px',
      paddingRight: (5 * zoom) + 'px',
      transform: 'rotate(-90deg)'
    });
  }


  // internal labels for tasks and collapsed call activities,
  // sub processes and participants
  if (isAny(element, [ 'bpmn:Task', 'bpmn:CallActivity']) ||
      isCollapsedPool(element) ||
      isCollapsedSubProcess(element)) {

    assign(bounds, {
      width: bbox.width,
      height: bbox.height
    });

    assign(style, {
      fontSize: defaultFontSize + 'px',
      lineHeight: defaultLineHeight,
      paddingTop: (7 * zoom) + 'px',
      paddingBottom: (7 * zoom) + 'px',
      paddingLeft: (5 * zoom) + 'px',
      paddingRight: (5 * zoom) + 'px'
    });
  }


  // internal labels for expanded sub processes
  if (isExpandedSubProcess(element)) {
    assign(bounds, {
      width: bbox.width,
      x: bbox.x
    });

    assign(style, {
      fontSize: defaultFontSize + 'px',
      lineHeight: defaultLineHeight,
      paddingTop: (7 * zoom) + 'px',
      paddingBottom: (7 * zoom) + 'px',
      paddingLeft: (5 * zoom) + 'px',
      paddingRight: (5 * zoom) + 'px'
    });
  }

  var width = 90 * zoom,
      paddingTop = 7 * zoom,
      paddingBottom = 4 * zoom;

  // external labels for events, data elements, gateways and connections
  if (target.labelTarget) {
    assign(bounds, {
      width: width,
      height: bbox.height + paddingTop + paddingBottom,
      x: mid.x - width / 2,
      y: bbox.y - paddingTop
    });

    assign(style, {
      fontSize: externalFontSize + 'px',
      lineHeight: externalLineHeight,
      paddingTop: paddingTop + 'px',
      paddingBottom: paddingBottom + 'px'
    });
  }

  // external label not yet created
  if (isLabelExternal(target)
      && !hasExternalLabel(target)
      && !isLabel(target)) {

    var externalLabelMid = getExternalLabelMid(element);

    var absoluteBBox = canvas.getAbsoluteBBox({
      x: externalLabelMid.x,
      y: externalLabelMid.y,
      width: 0,
      height: 0
    });

    var height = externalFontSize + paddingTop + paddingBottom;

    assign(bounds, {
      width: width,
      height: height,
      x: absoluteBBox.x - width / 2,
      y: absoluteBBox.y - height / 2
    });

    assign(style, {
      fontSize: externalFontSize + 'px',
      lineHeight: externalLineHeight,
      paddingTop: paddingTop + 'px',
      paddingBottom: paddingBottom + 'px'
    });
  }

  // text annotations
  if (is(element, 'bpmn:TextAnnotation')) {
    assign(bounds, {
      width: bbox.width,
      height: bbox.height,
      minWidth: 30 * zoom,
      minHeight: 10 * zoom
    });

    assign(style, {
      textAlign: 'left',
      paddingTop: (5 * zoom) + 'px',
      paddingBottom: (7 * zoom) + 'px',
      paddingLeft: (7 * zoom) + 'px',
      paddingRight: (5 * zoom) + 'px',
      fontSize: defaultFontSize + 'px',
      lineHeight: defaultLineHeight
    });
  }

  return { bounds: bounds, style: style };
};


CustomLabelEditingProvider.prototype.update = function(
    element, newLabel,
    activeContextText, bounds) {

  var newBounds,
      bbox;

  if (is(element, 'bpmn:TextAnnotation')) {

    bbox = this._canvas.getAbsoluteBBox(element);

    newBounds = {
      x: element.x,
      y: element.y,
      width: element.width / bbox.width * bounds.width,
      height: element.height / bbox.height * bounds.height
    };
  }

  if (isEmptyText(newLabel)) {
    newLabel = null;
  }

  this._modeling.updateLabel(element, newLabel, newBounds);
};



// helpers //////////////////////

function isCollapsedSubProcess(element) {
  return is(element, 'bpmn:SubProcess') && !isExpanded(element);
}

function isExpandedSubProcess(element) {
  return is(element, 'bpmn:SubProcess') && isExpanded(element);
}

function isCollapsedPool(element) {
  return is(element, 'bpmn:Participant') && !isExpanded(element);
}

function isExpandedPool(element) {
  return is(element, 'bpmn:Participant') && isExpanded(element);
}

function isEmptyText(label) {
  return !label || !label.trim();
}

What I want to do is to write labels for “custom:topevent” (later on also for custom:event and custom:barrier). I can already doubleclick on a TopEvent and enter text (what’s strange about it is that I get no width or height values in “context”.

I would be enourmously glad if I get help for this problem.

Many thanks!


#4

The custom shapes have no businessObject since they have no business logic. Therefore there is no name property that could be updated. Please explain what you’re trying to achieve. What is your use case for custom elements. The example you’re referring to merely exists to show how far you can possibly modify the toolkit but in most cases this is not the right approach to customize elements.


#5

Thanks for your answer @philippfromme

The custom shapes have no businessObject since they have no business logic.

Can you tell me, where I have to change my custom elements to behave like a businessObject or is this the wrong approach?

What I want to achieve is to build a tool for drawing “Bow-Tie-Diagrams”. The elements schould be double-bordered ellipse as “Top event”, a single border ellipse as “normal events” and rectangles as “barriers”. The TopEvent and Events should be labeled inside of the form and the barrier with an external label. All the elements should be able to be connected with eachother via arrows (special rules are not required at the moment). At the far end there should be an XML-Export of the Diagram (as well as an Import).

Would you be so kind to sketch how this can be achieved?
This would be really helpful!


#6

If your diagrams don’t have anything to do with the BPMN 2.0 domain it doesn’t make sense to use bpmn-js. Instead you can use diagram-js to build your custom modeling tool.