bpmn-js/lib/features/auto-place/AutoPlaceUtil.js
Nico Rehwaldt ae96f3714d feat(modeling): add auto placement from context menu
Elements will automatically be created at appropriate
positions when context menu create entries are being
clicked (rather than dragged).

This marks a major step forward for mobile modeling,
too as dragging, especially dragging out from very small
controls is very cumbersome to do.

Things we take into account:

* for bpmn:FlowNodes, we try to compute the current
  distance between elements on the flow based on
  connections going in and out of the flow nodes
  source element
* for bpmn:TextAnnotation we assume placement of the
  element top right of the source shape
* for bpmn:DataObject and friends we assume a
  placement bottom right of the source shape
* for all elements, we try not to place elements on
  top of each other; i.e. new elements will be pushed
  up or down accordingly, if an element at a chosen
  position does already exist

Integration into other services:

* context pad provider works with autoPlace, if
  available and defaults to drag start without
* auto placed elements are selected and direct editing
  may conditionally be activated based on element type
  (LabelEditingProvider knows the rules)

Users can out out of autoPlace by specifying the configuration
property `config.contextPad.autoPlace = false`.

Closes #563

BREAKING CHANGE:

* This breaks the default interaction from the context
  pad; if you rely on clicking to start the drag
  you can opt out of autoPlace:

  ```
  new BpmnJS({ contextPad: { autoPlace: false } });
  ```
2017-12-22 10:30:44 +01:00

262 lines
5.9 KiB
JavaScript

'use strict';
var is = require('../../util/ModelUtil').is;
var getMid = require('diagram-js/lib/layout/LayoutUtil').getMid,
asTRBL = require('diagram-js/lib/layout/LayoutUtil').asTRBL,
getOrientation = require('diagram-js/lib/layout/LayoutUtil').getOrientation;
var find = require('lodash/collection/find'),
reduce = require('lodash/collection/reduce'),
filter = require('lodash/collection/filter');
var DEFAULT_HORIZONTAL_DISTANCE = 50;
var MAX_HORIZONTAL_DISTANCE = 250;
// padding to detect element placement
var PLACEMENT_DETECTION_PAD = 10;
/**
* Always try to place element right of source;
* compute actual distance from previous nodes in flow.
*/
function getFlowNodePosition(source, element) {
var sourceTrbl = asTRBL(source);
var sourceMid = getMid(source);
var horizontalDistance = getFlowNodeDistance(source, element);
var position = {
x: sourceTrbl.right + horizontalDistance + element.width / 2,
y: sourceMid.y
};
var existingTarget;
// make sure we don't place targets a
while ((existingTarget = getConnectedAtPosition(source, position, element))) {
position = {
x: position.x,
y: Math.max(
existingTarget.y + existingTarget.height + 30 + element.height / 2,
position.y + 80
)
};
}
return position;
}
module.exports.getFlowNodePosition = getFlowNodePosition;
/**
* Compute best distance between source and target,
* based on existing connections to and from source.
*
* @param {djs.model.Shape} source
* @param {djs.model.Shape} element
*
* @return {Number} distance
*/
function getFlowNodeDistance(source, element) {
var sourceTrbl = asTRBL(source);
// is connection a reference to consider?
var isReference = function(c) {
return is(c, 'bpmn:SequenceFlow');
};
var nodes = [].concat(
filter(source.outgoing, isReference).map(function(c) {
return {
shape: c.target,
weight: 5,
distanceTo: function(shape) {
var shapeTrbl = asTRBL(shape);
return shapeTrbl.left - sourceTrbl.right;
}
};
}),
filter(source.incoming, isReference).map(function(c) {
return {
shape: c.source,
weight: 1,
distanceTo: function(shape) {
var shapeTrbl = asTRBL(shape);
return sourceTrbl.left - shapeTrbl.right;
}
};
})
);
// compute distances between source and incoming nodes;
// group at the same time by distance and expose the
// favourite distance as { fav: { count, value } }.
var distancesGrouped = reduce(nodes, function(result, node) {
var shape = node.shape,
weight = node.weight,
distanceTo = node.distanceTo;
var fav = result.fav,
currentDistance,
currentDistanceCount,
currentDistanceEntry;
currentDistance = distanceTo(shape);
// ignore too far away peers
// or non-left to right modeled nodes
if (currentDistance < 0 || currentDistance > MAX_HORIZONTAL_DISTANCE) {
return result;
}
currentDistanceEntry = result[String(currentDistance)] =
result[String(currentDistance)] || {
value: currentDistance,
count: 0
};
// inc diff count
currentDistanceCount = currentDistanceEntry.count += 1 * weight;
if (!fav || fav.count < currentDistanceCount) {
result.fav = currentDistanceEntry;
}
return result;
}, { });
if (distancesGrouped.fav) {
return distancesGrouped.fav.value;
} else {
return DEFAULT_HORIZONTAL_DISTANCE;
}
}
module.exports.getFlowNodeDistance = getFlowNodeDistance;
/**
* Always try to place text annotations top right of source.
*/
function getTextAnnotationPosition(source, element) {
var sourceTrbl = asTRBL(source);
// todo: adaptive
var position = {
x: sourceTrbl.right + 30 + element.width / 2,
y: sourceTrbl.top - 50 - element.height / 2
};
var existingTarget;
while ((existingTarget = getConnectedAtPosition(source, position, element))) {
// escape to top
position = {
x: position.x,
y: Math.min(
existingTarget.y - 30 - element.height / 2,
position.y - 30
)
};
}
return position;
}
module.exports.getTextAnnotationPosition = getTextAnnotationPosition;
/**
* Always put element bottom right of source.
*/
function getDataElementPosition(source, element) {
var sourceTrbl = asTRBL(source);
var position = {
x: sourceTrbl.right + 50 + element.width / 2,
y: sourceTrbl.bottom + 40 + element.width / 2
};
var existingTarget;
while ((existingTarget = getConnectedAtPosition(source, position, element))) {
// escape to right
position = {
x: Math.min(
existingTarget.x + existingTarget.width + 30 + element.width / 2,
position.x + 80
),
y: position.y
};
}
return position;
}
module.exports.getDataElementPosition = getDataElementPosition;
/**
* Always put element right of source per default.
*/
function getDefaultPosition(source, element) {
var sourceTrbl = asTRBL(source);
var sourceMid = getMid(source);
// simply put element right next to source
return {
x: sourceTrbl.right + DEFAULT_HORIZONTAL_DISTANCE + element.width / 2,
y: sourceMid.y
};
}
module.exports.getDefaultPosition = getDefaultPosition;
/**
* Return target at given position, if defined.
*/
function getConnectedAtPosition(source, position, element) {
var bounds = {
x: position.x - (element.width / 2),
y: position.y - (element.height / 2),
width: element.width,
height: element.height
};
var targets = source.outgoing.map(function(c) {
return c.target;
});
var sources = source.incoming.map(function(c) {
return c.source;
});
var allConnected = [].concat(targets, sources);
return find(allConnected, function(target) {
var orientation = getOrientation(target, bounds, PLACEMENT_DETECTION_PAD);
return orientation === 'intersect';
});
}
module.exports.getConnectedAtPosition = getConnectedAtPosition;