From befe8b82c843016355ee6d5ea63d98ee86ccf495 Mon Sep 17 00:00:00 2001 From: Maciej Barelkowski Date: Fri, 10 Jun 2022 08:54:57 +0200 Subject: [PATCH] feat: add alignment and distribution menu multi-element context pad Closes #1680 Closes #1691 --- assets/bpmn-js.css | 37 +++++- lib/Modeler.js | 2 +- .../AlignElementsContextPadProvider.js | 87 ++++++++++++++ .../align-elements/AlignElementsIcons.js | 15 +++ .../AlignElementsMenuProvider.js | 72 +++++++++++ .../align-elements/BpmnAlignElements.js | 39 ++++++ lib/features/align-elements/index.js | 23 ++++ .../resources/align-bottom-tool.svg | 5 + .../align-horizontal-center-tool.svg | 5 + .../resources/align-left-tool.svg | 5 + .../resources/align-right-tool.svg | 5 + .../align-elements/resources/align-tool.svg | 5 + .../resources/align-top-tool.svg | 5 + .../resources/align-vertical-center-tool.svg | 5 + .../BpmnDistributeElements.js | 34 ++++-- .../DistributeElementsIcons.js | 10 ++ .../DistributeElementsMenuProvider.js | 69 +++++++++++ lib/features/distribute-elements/index.js | 11 +- .../distribute-horizontally-tool.svg | 5 + .../resources/distribute-vertically-tool.svg | 5 + test/fixtures/bpmn/align-elements.bpmn | 112 +++++++++--------- .../bpmn/distribute-elements-filtering.bpmn | 47 +++++--- ...bute-elements-filtering.collaboration.bpmn | 50 ++++++++ .../AlignElementsContextPadProviderSpec.js | 100 ++++++++++++++++ .../AlignElementsMenuProviderSpec.js | 93 +++++++++++++++ .../align-elements/BpmnAlignElementsSpec.js | 92 ++++++++++---- .../BpmnDistributeElementsSpec.js | 102 +++++++++++++--- .../DistributeElementsMenuProviderSpec.js | 95 +++++++++++++++ 28 files changed, 1006 insertions(+), 129 deletions(-) create mode 100644 lib/features/align-elements/AlignElementsContextPadProvider.js create mode 100644 lib/features/align-elements/AlignElementsIcons.js create mode 100644 lib/features/align-elements/AlignElementsMenuProvider.js create mode 100644 lib/features/align-elements/BpmnAlignElements.js create mode 100644 lib/features/align-elements/index.js create mode 100644 lib/features/align-elements/resources/align-bottom-tool.svg create mode 100644 lib/features/align-elements/resources/align-horizontal-center-tool.svg create mode 100644 lib/features/align-elements/resources/align-left-tool.svg create mode 100644 lib/features/align-elements/resources/align-right-tool.svg create mode 100644 lib/features/align-elements/resources/align-tool.svg create mode 100644 lib/features/align-elements/resources/align-top-tool.svg create mode 100644 lib/features/align-elements/resources/align-vertical-center-tool.svg create mode 100644 lib/features/distribute-elements/DistributeElementsIcons.js create mode 100644 lib/features/distribute-elements/DistributeElementsMenuProvider.js create mode 100644 lib/features/distribute-elements/resources/distribute-horizontally-tool.svg create mode 100644 lib/features/distribute-elements/resources/distribute-vertically-tool.svg create mode 100644 test/fixtures/bpmn/distribute-elements-filtering.collaboration.bpmn create mode 100644 test/spec/features/align-elements/AlignElementsContextPadProviderSpec.js create mode 100644 test/spec/features/align-elements/AlignElementsMenuProviderSpec.js create mode 100644 test/spec/features/distribute-elements/DistributeElementsMenuProviderSpec.js diff --git a/assets/bpmn-js.css b/assets/bpmn-js.css index ff933e144d..0b6ba32d7a 100644 --- a/assets/bpmn-js.css +++ b/assets/bpmn-js.css @@ -8,7 +8,7 @@ --color-grey-225-10-80: hsl(225, 10%, 80%); --color-grey-225-10-85: hsl(225, 10%, 85%); --color-grey-225-10-90: hsl(225, 10%, 90%); - --color-grey-225-10-95: hsl(225, 10%, 95%); + --color-grey-225-10-95: hsl(225, 10%, 95%); --color-grey-225-10-97: hsl(225, 10%, 97%); --color-blue-205-100-45: hsl(205, 100%, 45%); @@ -24,8 +24,8 @@ --color-red-360-100-97: hsl(360, 100%, 97%); --color-white: hsl(0, 0%, 100%); - --color-black: hsl(0, 0%, 0%); - --color-black-opacity-05: hsla(0, 0%, 0%, 5%); + --color-black: hsl(0, 0%, 0%); + --color-black-opacity-05: hsla(0, 0%, 0%, 5%); --color-black-opacity-10: hsla(0, 0%, 0%, 10%); --breadcrumbs-font-family: var(--bjs-font-family); @@ -113,4 +113,33 @@ .selected .bjs-drilldown-empty { display: inherit; -} \ No newline at end of file +} + +[data-popup="align-elements"] .djs-popup-body { + display: flex; +} + +[data-popup="align-elements"] .djs-popup-body [data-group] + [data-group] { + border-left: 1px solid var(--popup-border-color); +} + +[data-popup="align-elements"] [data-group="align"] { + display: grid; + grid-template-columns: repeat(3, 1fr); +} + +[data-popup="align-elements"] .djs-popup-body .entry { + height: 20px; + width: 20px; + + padding: 6px 8px; +} + +[data-popup="align-elements"] .djs-popup-body .entry img { + height: 100%; + width: 100%; +} + +[data-popup="align-elements"] .bjs-align-elements-menu-entry { + display: inline-block; +} diff --git a/lib/Modeler.js b/lib/Modeler.js index 717232ba98..7aedb9de40 100644 --- a/lib/Modeler.js +++ b/lib/Modeler.js @@ -10,7 +10,7 @@ import MoveCanvasModule from 'diagram-js/lib/navigation/movecanvas'; import TouchModule from 'diagram-js/lib/navigation/touch'; import ZoomScrollModule from 'diagram-js/lib/navigation/zoomscroll'; -import AlignElementsModule from 'diagram-js/lib/features/align-elements'; +import AlignElementsModule from './features/align-elements'; import AutoPlaceModule from './features/auto-place'; import AutoResizeModule from './features/auto-resize'; import AutoScrollModule from 'diagram-js/lib/features/auto-scroll'; diff --git a/lib/features/align-elements/AlignElementsContextPadProvider.js b/lib/features/align-elements/AlignElementsContextPadProvider.js new file mode 100644 index 0000000000..235d053c16 --- /dev/null +++ b/lib/features/align-elements/AlignElementsContextPadProvider.js @@ -0,0 +1,87 @@ +import { + assign +} from 'min-dash'; + +import ICONS from './AlignElementsIcons'; + +var LOW_PRIORITY = 900; + +/** + * A provider for align elements context pad button + */ +export default function AlignElementsContextPadProvider(contextPad, popupMenu, translate, canvas) { + + contextPad.registerProvider(LOW_PRIORITY, this); + + this._contextPad = contextPad; + this._popupMenu = popupMenu; + this._translate = translate; + this._canvas = canvas; +} + +AlignElementsContextPadProvider.$inject = [ + 'contextPad', + 'popupMenu', + 'translate', + 'canvas' +]; + +AlignElementsContextPadProvider.prototype.getMultiElementContextPadEntries = function(elements) { + var actions = {}; + + if (this._isAllowed(elements)) { + assign(actions, this._getEntries(elements)); + } + + return actions; +}; + +AlignElementsContextPadProvider.prototype._isAllowed = function(elements) { + return !this._popupMenu.isEmpty(elements, 'align-elements'); +}; + +AlignElementsContextPadProvider.prototype._getEntries = function(elements) { + var self = this; + + return { + 'align-elements': { + group: 'align-elements', + title: self._translate('Align elements'), + imageUrl: ICONS['align'], + action: { + click: function(event, elements) { + var position = self._getMenuPosition(elements); + + assign(position, { + cursor: { + x: event.x, + y: event.y + } + }); + + self._popupMenu.open(elements, 'align-elements', position); + } + } + } + }; +}; + +AlignElementsContextPadProvider.prototype._getMenuPosition = function(elements) { + var Y_OFFSET = 5; + + var diagramContainer = this._canvas.getContainer(), + pad = this._contextPad.getPad(elements).html; + + var diagramRect = diagramContainer.getBoundingClientRect(), + padRect = pad.getBoundingClientRect(); + + var top = padRect.top - diagramRect.top; + var left = padRect.left - diagramRect.left; + + var pos = { + x: left, + y: top + padRect.height + Y_OFFSET + }; + + return pos; +}; diff --git a/lib/features/align-elements/AlignElementsIcons.js b/lib/features/align-elements/AlignElementsIcons.js new file mode 100644 index 0000000000..86aeb6a5d4 --- /dev/null +++ b/lib/features/align-elements/AlignElementsIcons.js @@ -0,0 +1,15 @@ +/** + * To change the icons, modify the SVGs in `./resources`, execute `npx svgo -f resources --datauri enc -o dist`, + * and then replace respective icons with the optimized data URIs in `./dist`. + */ +var icons = { + align: 'data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%202000%202000%22%3E%3Cpath%20style%3D%22stroke%3AcurrentColor%3Bstroke-width%3A100%3Bstroke-linecap%3Around%22%20d%3D%22M200%20150v1700%22%2F%3E%3Crect%20x%3D%22500%22%20y%3D%22150%22%20width%3D%221300%22%20height%3D%22700%22%20rx%3D%221%22%20style%3D%22fill%3Anone%3Bstroke%3AcurrentColor%3Bstroke-width%3A100%22%2F%3E%3Crect%20x%3D%22500%22%20y%3D%221150%22%20width%3D%22700%22%20height%3D%22700%22%20rx%3D%221%22%20style%3D%22fill%3AcurrentColor%3Bstroke%3AcurrentColor%3Bstroke-width%3A100%3Bopacity%3A.5%22%2F%3E%3C%2Fsvg%3E', + bottom: 'data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%201800%201800%22%3E%3Cpath%20style%3D%22stroke%3AcurrentColor%3Bstroke-width%3A100%3Bstroke-linecap%3Around%22%20d%3D%22M150%201650h1500%22%2F%3E%3Crect%20x%3D%22150%22%20y%3D%22350%22%20width%3D%22600%22%20height%3D%221300%22%20rx%3D%221%22%20style%3D%22fill%3Anone%3Bstroke%3AcurrentColor%3Bstroke-width%3A100%22%2F%3E%3Crect%20x%3D%221050%22%20y%3D%22850%22%20width%3D%22600%22%20height%3D%22800%22%20rx%3D%221%22%20style%3D%22fill%3AcurrentColor%3Bstroke%3AcurrentColor%3Bstroke-width%3A100%3Bopacity%3A.5%22%2F%3E%3C%2Fsvg%3E', + center: 'data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%201800%201800%22%3E%3Cpath%20style%3D%22stroke%3AcurrentColor%3Bstroke-width%3A100%3Bstroke-linecap%3Around%22%20d%3D%22M900%20150v1500%22%2F%3E%3Crect%20x%3D%22250%22%20y%3D%22150%22%20width%3D%221300%22%20height%3D%22600%22%20rx%3D%221%22%20style%3D%22fill%3Anone%3Bstroke%3AcurrentColor%3Bstroke-width%3A100%22%2F%3E%3Crect%20x%3D%22500%22%20y%3D%221050%22%20width%3D%22800%22%20height%3D%22600%22%20rx%3D%221%22%20style%3D%22fill%3AcurrentColor%3Bstroke%3AcurrentColor%3Bstroke-width%3A100%3Bopacity%3A.5%22%2F%3E%3C%2Fsvg%3E', + left: 'data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%201800%201800%22%3E%3Cpath%20style%3D%22stroke%3AcurrentColor%3Bstroke-width%3A100%3Bstroke-linecap%3Around%22%20d%3D%22M100%20150v1500%22%2F%3E%3Crect%20x%3D%22100%22%20y%3D%22150%22%20width%3D%221300%22%20height%3D%22600%22%20rx%3D%221%22%20style%3D%22fill%3Anone%3Bstroke%3AcurrentColor%3Bstroke-width%3A100%22%2F%3E%3Crect%20x%3D%22100%22%20y%3D%221050%22%20width%3D%22800%22%20height%3D%22600%22%20rx%3D%221%22%20style%3D%22fill%3AcurrentColor%3Bstroke%3AcurrentColor%3Bstroke-width%3A100%3Bopacity%3A.5%22%2F%3E%3C%2Fsvg%3E', + right: 'data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%201800%201800%22%3E%3Cpath%20style%3D%22stroke%3AcurrentColor%3Bstroke-width%3A100%3Bstroke-linecap%3Around%22%20d%3D%22M1650%20150v1500%22%2F%3E%3Crect%20x%3D%22350%22%20y%3D%22150%22%20width%3D%221300%22%20height%3D%22600%22%20rx%3D%221%22%20style%3D%22fill%3Anone%3Bstroke%3AcurrentColor%3Bstroke-width%3A100%22%2F%3E%3Crect%20x%3D%22850%22%20y%3D%221050%22%20width%3D%22800%22%20height%3D%22600%22%20rx%3D%221%22%20style%3D%22fill%3AcurrentColor%3Bstroke%3AcurrentColor%3Bstroke-width%3A100%3Bopacity%3A.5%22%2F%3E%3C%2Fsvg%3E', + top: 'data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%201800%201800%22%3E%3Cpath%20style%3D%22stroke%3AcurrentColor%3Bstroke-width%3A100%3Bstroke-linecap%3Around%22%20d%3D%22M150%20150h1500%22%2F%3E%3Crect%20x%3D%22150%22%20y%3D%22150%22%20width%3D%22600%22%20height%3D%221300%22%20rx%3D%221%22%20style%3D%22fill%3Anone%3Bstroke%3AcurrentColor%3Bstroke-width%3A100%22%2F%3E%3Crect%20x%3D%221050%22%20y%3D%22150%22%20width%3D%22600%22%20height%3D%22800%22%20rx%3D%221%22%20style%3D%22fill%3AcurrentColor%3Bstroke%3AcurrentColor%3Bstroke-width%3A100%3Bopacity%3A.5%22%2F%3E%3C%2Fsvg%3E', + middle: 'data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%201800%201800%22%3E%3Cpath%20style%3D%22stroke%3AcurrentColor%3Bstroke-width%3A100%3Bstroke-linecap%3Around%22%20d%3D%22M150%20900h1500%22%2F%3E%3Crect%20x%3D%22150%22%20y%3D%22250%22%20width%3D%22600%22%20height%3D%221300%22%20rx%3D%221%22%20style%3D%22fill%3Anone%3Bstroke%3AcurrentColor%3Bstroke-width%3A100%22%2F%3E%3Crect%20x%3D%221050%22%20y%3D%22500%22%20width%3D%22600%22%20height%3D%22800%22%20rx%3D%221%22%20style%3D%22fill%3AcurrentColor%3Bstroke%3AcurrentColor%3Bstroke-width%3A100%3Bopacity%3A.5%22%2F%3E%3C%2Fsvg%3E' +}; + +export default icons; diff --git a/lib/features/align-elements/AlignElementsMenuProvider.js b/lib/features/align-elements/AlignElementsMenuProvider.js new file mode 100644 index 0000000000..61805a75bf --- /dev/null +++ b/lib/features/align-elements/AlignElementsMenuProvider.js @@ -0,0 +1,72 @@ +import ICONS from './AlignElementsIcons'; + +import { + assign, + forEach, +} from 'min-dash'; + +var ALIGNMENT_OPTIONS = [ + 'left', + 'center', + 'right', + 'top', + 'middle', + 'bottom' +]; + +/** + * A provider for align elements popup menu. + */ +export default function AlignElementsMenuProvider(popupMenu, alignElements, translate, rules) { + + this._alignElements = alignElements; + this._translate = translate; + this._popupMenu = popupMenu; + this._rules = rules; + + popupMenu.registerProvider('align-elements', this); +} + +AlignElementsMenuProvider.$inject = [ + 'popupMenu', + 'alignElements', + 'translate', + 'rules' +]; + +AlignElementsMenuProvider.prototype.getPopupMenuEntries = function(elements) { + var entries = {}; + + if (this._isAllowed(elements)) { + assign(entries, this._getEntries(elements)); + } + + return entries; +}; + +AlignElementsMenuProvider.prototype._isAllowed = function(elements) { + return this._rules.allowed('elements.align', { elements: elements }); +}; + +AlignElementsMenuProvider.prototype._getEntries = function(elements) { + var alignElements = this._alignElements, + translate = this._translate, + popupMenu = this._popupMenu; + + var entries = {}; + + forEach(ALIGNMENT_OPTIONS, function(alignment) { + entries[ 'align-elements-' + alignment ] = { + group: 'align', + title: translate('Align elements ' + alignment), + className: 'bjs-align-elements-menu-entry', + imageUrl: ICONS[alignment], + action: function(event, entry) { + alignElements.trigger(elements, alignment); + popupMenu.close(); + } + }; + }); + + return entries; +}; diff --git a/lib/features/align-elements/BpmnAlignElements.js b/lib/features/align-elements/BpmnAlignElements.js new file mode 100644 index 0000000000..451b8068fb --- /dev/null +++ b/lib/features/align-elements/BpmnAlignElements.js @@ -0,0 +1,39 @@ +import inherits from 'inherits-browser'; + +import RuleProvider from 'diagram-js/lib/features/rules/RuleProvider'; +import { getParents } from 'diagram-js/lib/util/Elements'; + +import { + filter +} from 'min-dash'; + +/** + * Rule provider for alignment of BPMN elements. + */ +export default function BpmnAlignElements(eventBus) { + RuleProvider.call(this, eventBus); +} + +BpmnAlignElements.$inject = [ 'eventBus' ]; + +inherits(BpmnAlignElements, RuleProvider); + +BpmnAlignElements.prototype.init = function() { + this.addRule('elements.align', function(context) { + var elements = context.elements; + + // filter out elements which cannot be aligned + var filteredElements = filter(elements, function(element) { + return !(element.waypoints || element.host || element.labelTarget); + }); + + // filter out elements which are children of any of the selected elements + filteredElements = getParents(filteredElements); + + if (filteredElements.length < 2) { + return false; + } + + return filteredElements; + }); +}; diff --git a/lib/features/align-elements/index.js b/lib/features/align-elements/index.js new file mode 100644 index 0000000000..2ce329e43e --- /dev/null +++ b/lib/features/align-elements/index.js @@ -0,0 +1,23 @@ +import AlignElementsModule from 'diagram-js/lib/features/align-elements'; +import ContextPadModule from 'diagram-js/lib/features/context-pad'; +import PopupMenuModule from 'diagram-js/lib/features/popup-menu'; + +import AlignElementsContextPadProvider from './AlignElementsContextPadProvider'; +import AlignElementsMenuProvider from './AlignElementsMenuProvider'; +import BpmnAlignElements from './BpmnAlignElements'; + +export default { + __depends__: [ + AlignElementsModule, + ContextPadModule, + PopupMenuModule + ], + __init__: [ + 'alignElementsContextPadProvider', + 'alignElementsMenuProvider', + 'bpmnAlignElements' + ], + alignElementsContextPadProvider: [ 'type', AlignElementsContextPadProvider ], + alignElementsMenuProvider: [ 'type', AlignElementsMenuProvider ], + bpmnAlignElements: [ 'type', BpmnAlignElements] +}; diff --git a/lib/features/align-elements/resources/align-bottom-tool.svg b/lib/features/align-elements/resources/align-bottom-tool.svg new file mode 100644 index 0000000000..ff578d93e9 --- /dev/null +++ b/lib/features/align-elements/resources/align-bottom-tool.svg @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/lib/features/align-elements/resources/align-horizontal-center-tool.svg b/lib/features/align-elements/resources/align-horizontal-center-tool.svg new file mode 100644 index 0000000000..699e1a72ce --- /dev/null +++ b/lib/features/align-elements/resources/align-horizontal-center-tool.svg @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/lib/features/align-elements/resources/align-left-tool.svg b/lib/features/align-elements/resources/align-left-tool.svg new file mode 100644 index 0000000000..236f92acaf --- /dev/null +++ b/lib/features/align-elements/resources/align-left-tool.svg @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/lib/features/align-elements/resources/align-right-tool.svg b/lib/features/align-elements/resources/align-right-tool.svg new file mode 100644 index 0000000000..ac040e7087 --- /dev/null +++ b/lib/features/align-elements/resources/align-right-tool.svg @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/lib/features/align-elements/resources/align-tool.svg b/lib/features/align-elements/resources/align-tool.svg new file mode 100644 index 0000000000..7dfa51e48c --- /dev/null +++ b/lib/features/align-elements/resources/align-tool.svg @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/lib/features/align-elements/resources/align-top-tool.svg b/lib/features/align-elements/resources/align-top-tool.svg new file mode 100644 index 0000000000..2cdc23de3f --- /dev/null +++ b/lib/features/align-elements/resources/align-top-tool.svg @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/lib/features/align-elements/resources/align-vertical-center-tool.svg b/lib/features/align-elements/resources/align-vertical-center-tool.svg new file mode 100644 index 0000000000..3b56063d43 --- /dev/null +++ b/lib/features/align-elements/resources/align-vertical-center-tool.svg @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/lib/features/distribute-elements/BpmnDistributeElements.js b/lib/features/distribute-elements/BpmnDistributeElements.js index ce63a419a1..4e641c477c 100644 --- a/lib/features/distribute-elements/BpmnDistributeElements.js +++ b/lib/features/distribute-elements/BpmnDistributeElements.js @@ -1,3 +1,8 @@ +import inherits from 'inherits-browser'; + +import RuleProvider from 'diagram-js/lib/features/rules/RuleProvider'; +import { getParents } from 'diagram-js/lib/util/Elements'; + import { filter } from 'min-dash'; @@ -11,10 +16,19 @@ import { * Registers element exclude filters for elements that * currently do not support distribution. */ -export default function BpmnDistributeElements(distributeElements) { +export default function BpmnDistributeElements(distributeElements, eventBus, rules) { + RuleProvider.call(this, eventBus); +} + +BpmnDistributeElements.$inject = [ 'distributeElements', 'eventBus', 'rules' ]; + +inherits(BpmnDistributeElements, RuleProvider); + +BpmnDistributeElements.prototype.init = function() { + this.addRule('elements.distribute', function(context) { + var elements = context.elements; - distributeElements.registerFilter(function(elements) { - return filter(elements, function(element) { + elements = filter(elements, function(element) { var cannotDistribute = isAny(element, [ 'bpmn:Association', 'bpmn:BoundaryEvent', @@ -22,14 +36,20 @@ export default function BpmnDistributeElements(distributeElements) { 'bpmn:DataOutputAssociation', 'bpmn:Lane', 'bpmn:MessageFlow', - 'bpmn:Participant', 'bpmn:SequenceFlow', 'bpmn:TextAnnotation' ]); return !(element.labelTarget || cannotDistribute); }); - }); -} -BpmnDistributeElements.$inject = [ 'distributeElements' ]; \ No newline at end of file + // filter out elements which are children of any of the selected elements + elements = getParents(elements); + + if (elements.length < 3) { + return false; + } + + return elements; + }); +}; diff --git a/lib/features/distribute-elements/DistributeElementsIcons.js b/lib/features/distribute-elements/DistributeElementsIcons.js new file mode 100644 index 0000000000..b1621a992a --- /dev/null +++ b/lib/features/distribute-elements/DistributeElementsIcons.js @@ -0,0 +1,10 @@ +/** + * To change the icons, modify the SVGs in `./resources`, execute `npx svgo -f resources --datauri enc -o dist`, + * and then replace respective icons with the optimized data URIs in `./dist`. + */ +var icons = { + horizontal: 'data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%201800%201800%22%3E%3Cpath%20style%3D%22fill%3Anone%3Bstroke%3AcurrentColor%3Bstroke-width%3A100%3Bstroke-linejoin%3Around%22%20d%3D%22M450%20400V150h900v250%22%2F%3E%3Crect%20x%3D%22150%22%20y%3D%22450%22%20width%3D%22600%22%20height%3D%221200%22%20rx%3D%221%22%20style%3D%22fill%3Anone%3Bstroke%3AcurrentColor%3Bstroke-width%3A100%22%2F%3E%3Crect%20x%3D%221050%22%20y%3D%22450%22%20width%3D%22600%22%20height%3D%22800%22%20rx%3D%221%22%20style%3D%22fill%3AcurrentColor%3Bstroke%3AcurrentColor%3Bstroke-width%3A100%3Bopacity%3A.5%22%2F%3E%3C%2Fsvg%3E', + vertical: 'data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%201800%201800%22%3E%3Cpath%20style%3D%22fill%3Anone%3Bstroke%3AcurrentColor%3Bstroke-width%3A100%3Bstroke-linejoin%3Around%22%20d%3D%22M400%201350H150V450h250%22%2F%3E%3Crect%20x%3D%22450%22%20y%3D%22150%22%20width%3D%221200%22%20height%3D%22600%22%20rx%3D%221%22%20style%3D%22fill%3Anone%3Bstroke%3AcurrentColor%3Bstroke-width%3A100%22%2F%3E%3Crect%20x%3D%22450%22%20y%3D%221050%22%20width%3D%22800%22%20height%3D%22600%22%20rx%3D%221%22%20style%3D%22fill%3AcurrentColor%3Bstroke%3AcurrentColor%3Bstroke-width%3A100%3Bopacity%3A.5%22%2F%3E%3C%2Fsvg%3E', +}; + +export default icons; diff --git a/lib/features/distribute-elements/DistributeElementsMenuProvider.js b/lib/features/distribute-elements/DistributeElementsMenuProvider.js new file mode 100644 index 0000000000..7fbefd1e7e --- /dev/null +++ b/lib/features/distribute-elements/DistributeElementsMenuProvider.js @@ -0,0 +1,69 @@ +import ICONS from './DistributeElementsIcons'; + +import { assign } from 'min-dash'; + +var LOW_PRIORITY = 900; + +/** + * A provider for distribute elements popup menu. + */ +export default function DistributeElementsMenuProvider( + popupMenu, distributeElements, translate, rules) { + this._distributeElements = distributeElements; + this._translate = translate; + this._popupMenu = popupMenu; + this._rules = rules; + + popupMenu.registerProvider('align-elements', LOW_PRIORITY, this); +} + +DistributeElementsMenuProvider.$inject = [ + 'popupMenu', + 'distributeElements', + 'translate', + 'rules' +]; + +DistributeElementsMenuProvider.prototype.getPopupMenuEntries = function(elements) { + var entries = {}; + + if (this._isAllowed(elements)) { + assign(entries, this._getEntries(elements)); + } + + return entries; +}; + +DistributeElementsMenuProvider.prototype._isAllowed = function(elements) { + return this._rules.allowed('elements.distribute', { elements: elements }); +}; + +DistributeElementsMenuProvider.prototype._getEntries = function(elements) { + var distributeElements = this._distributeElements, + translate = this._translate, + popupMenu = this._popupMenu; + + var entries = { + 'distribute-elements-horizontal': { + group: 'distribute', + title: translate('Distribute elements horizontally'), + className: 'bjs-align-elements-menu-entry', + imageUrl: ICONS['horizontal'], + action: function(event, entry) { + distributeElements.trigger(elements, 'horizontal'); + popupMenu.close(); + } + }, + 'distribute-elements-vertical': { + group: 'distribute', + title: translate('Distribute elements vertically'), + imageUrl: ICONS['vertical'], + action: function(event, entry) { + distributeElements.trigger(elements, 'vertical'); + popupMenu.close(); + } + }, + }; + + return entries; +}; diff --git a/lib/features/distribute-elements/index.js b/lib/features/distribute-elements/index.js index d6ec22c8dc..15ea2f0ff6 100644 --- a/lib/features/distribute-elements/index.js +++ b/lib/features/distribute-elements/index.js @@ -1,12 +1,19 @@ import DistributeElementsModule from 'diagram-js/lib/features/distribute-elements'; +import PopupMenuModule from 'diagram-js/lib/features/popup-menu'; import BpmnDistributeElements from './BpmnDistributeElements'; +import DistributeElementsMenuProvider from './DistributeElementsMenuProvider'; export default { __depends__: [ + PopupMenuModule, DistributeElementsModule ], - __init__: [ 'bpmnDistributeElements' ], - bpmnDistributeElements: [ 'type', BpmnDistributeElements ] + __init__: [ + 'bpmnDistributeElements', + 'distributeElementsMenuProvider' + ], + bpmnDistributeElements: [ 'type', BpmnDistributeElements ], + distributeElementsMenuProvider: [ 'type', DistributeElementsMenuProvider ] }; diff --git a/lib/features/distribute-elements/resources/distribute-horizontally-tool.svg b/lib/features/distribute-elements/resources/distribute-horizontally-tool.svg new file mode 100644 index 0000000000..0c1e2c4c43 --- /dev/null +++ b/lib/features/distribute-elements/resources/distribute-horizontally-tool.svg @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/lib/features/distribute-elements/resources/distribute-vertically-tool.svg b/lib/features/distribute-elements/resources/distribute-vertically-tool.svg new file mode 100644 index 0000000000..446c99aa24 --- /dev/null +++ b/lib/features/distribute-elements/resources/distribute-vertically-tool.svg @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/test/fixtures/bpmn/align-elements.bpmn b/test/fixtures/bpmn/align-elements.bpmn index 9d530b4ec9..2c91d9ccf9 100644 --- a/test/fixtures/bpmn/align-elements.bpmn +++ b/test/fixtures/bpmn/align-elements.bpmn @@ -1,17 +1,17 @@ - + - - SequenceFlow_08zyuyv SequenceFlow_08zyuyv + + @@ -21,14 +21,14 @@ - - Task_lane + + EndEvent_lane SubProcess_lane - - EndEvent_lane + + Task_lane @@ -41,92 +41,92 @@ SequenceFlow_1nrce3c SequenceFlow_0qa7db7 - + - + + + + + + + + + + + + + + + + + + + + + - + - + - + - + - - - + + + + + + + + - - - - + + + + + + + - + - - - - - - - - - - - - + - - - - + + + + - - - - - - - - - - - - - - - - - - - + + diff --git a/test/fixtures/bpmn/distribute-elements-filtering.bpmn b/test/fixtures/bpmn/distribute-elements-filtering.bpmn index 111671e2b6..9a30fba1fa 100644 --- a/test/fixtures/bpmn/distribute-elements-filtering.bpmn +++ b/test/fixtures/bpmn/distribute-elements-filtering.bpmn @@ -1,5 +1,5 @@ - + SequenceFlow_0vrvkcp @@ -15,44 +15,53 @@ SequenceFlow_1jet52k + + + - - + + + - + - - - - + - - + + - - + + - + + + + - + - - - + + + + + + + + - + - + diff --git a/test/fixtures/bpmn/distribute-elements-filtering.collaboration.bpmn b/test/fixtures/bpmn/distribute-elements-filtering.collaboration.bpmn new file mode 100644 index 0000000000..82b45073ab --- /dev/null +++ b/test/fixtures/bpmn/distribute-elements-filtering.collaboration.bpmn @@ -0,0 +1,50 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/test/spec/features/align-elements/AlignElementsContextPadProviderSpec.js b/test/spec/features/align-elements/AlignElementsContextPadProviderSpec.js new file mode 100644 index 0000000000..943408e4c5 --- /dev/null +++ b/test/spec/features/align-elements/AlignElementsContextPadProviderSpec.js @@ -0,0 +1,100 @@ +import { + bootstrapModeler, + getBpmnJS, + inject +} from 'test/TestHelper'; + +import { + query as domQuery +} from 'min-dom'; + +import alignElementsModule from 'lib/features/align-elements'; +import modelingModule from 'lib/features/modeling'; +import coreModule from 'lib/core'; + + + +describe('features/align-elements - context pad', function() { + + var testModules = [ alignElementsModule, modelingModule, coreModule ]; + + var basicXML = require('../../../fixtures/bpmn/align-elements.bpmn'); + + beforeEach(bootstrapModeler(basicXML, { modules: testModules })); + + + it('should provide button to open menu', inject(function(elementRegistry, contextPad) { + + // given + var elements = [ + elementRegistry.get('EndEvent_lane'), + elementRegistry.get('Task_lane'), + elementRegistry.get('SubProcess_lane') + ]; + + // when + contextPad.open(elements); + + // then + expect(getEntry(elements, 'align-elements')).to.exist; + })); + + + it('should NOT provide button if no actions are available', inject( + function(elementRegistry, contextPad, popupMenu) { + + // given + var elements = [ + elementRegistry.get('EndEvent_lane'), + elementRegistry.get('Task_lane'), + elementRegistry.get('SubProcess_lane') + ]; + popupMenu.registerProvider('align-elements', 0, { + getPopupMenuEntries: function() { + return function() { + return {}; + }; + } + }); + + // when + contextPad.open(elements); + + // then + expect(getEntry(elements, 'align-elements')).not.to.exist; + }) + ); + + + it('should open popup menu when item is clicked', inject( + function(elementRegistry, contextPad, popupMenu) { + + // given + var elements = [ + elementRegistry.get('EndEvent_lane'), + elementRegistry.get('Task_lane'), + elementRegistry.get('SubProcess_lane') + ]; + contextPad.open(elements); + + // when + var entry = getEntry(elements, 'align-elements'); + entry.click(); + + // then + expect(popupMenu.isOpen()).to.be.true; + }) + ); +}); + + +// helper ////////////////////////////////////////////////////////////////////// +function getEntry(target, actionName) { + return padEntry(getBpmnJS().invoke(function(contextPad) { + return contextPad.getPad(target).html; + }), actionName); +} + +function padEntry(element, name) { + return domQuery('[data-action="' + name + '"]', element); +} diff --git a/test/spec/features/align-elements/AlignElementsMenuProviderSpec.js b/test/spec/features/align-elements/AlignElementsMenuProviderSpec.js new file mode 100644 index 0000000000..d03a37682b --- /dev/null +++ b/test/spec/features/align-elements/AlignElementsMenuProviderSpec.js @@ -0,0 +1,93 @@ +import { + bootstrapModeler, + getBpmnJS, + inject +} from 'test/TestHelper'; + +import { + query as domQuery +} from 'min-dom'; + +import { + forEach +} from 'min-dash'; + +import alignElementsModule from 'lib/features/align-elements'; +import modelingModule from 'lib/features/modeling'; +import coreModule from 'lib/core'; + + + +describe('features/align-elements - popup menu', function() { + + var testModules = [ alignElementsModule, modelingModule, coreModule ]; + + var basicXML = require('../../../fixtures/bpmn/align-elements.bpmn'); + + beforeEach(bootstrapModeler(basicXML, { modules: testModules })); + + + it('should provide alignment buttons', inject(function(elementRegistry, popupMenu) { + + // given + var elements = [ + elementRegistry.get('EndEvent_lane'), + elementRegistry.get('Task_lane'), + elementRegistry.get('SubProcess_lane') + ]; + + // when + popupMenu.open(elements, 'align-elements', { + x: 0, + y: 0 + }); + + // then + forEach([ + 'left', + 'center', + 'right', + 'top', + 'middle', + 'bottom' + ], function(alignment) { + expect(getEntry('align-elements-' + alignment)).to.exist; + }); + })); + + + it('should close popup menu when button is clicked', inject( + function(elementRegistry, popupMenu) { + + // given + var elements = [ + elementRegistry.get('EndEvent_lane'), + elementRegistry.get('Task_lane'), + elementRegistry.get('SubProcess_lane') + ]; + popupMenu.open(elements, 'align-elements', { + x: 0, + y: 0 + }); + var entry = getEntry('align-elements-center'); + + // when + entry.click(); + + // then + expect(popupMenu.isOpen()).to.be.false; + }) + ); +}); + + +// helper ////////////////////////////////////////////////////////////////////// +function getEntry(actionName) { + return padEntry(getBpmnJS().invoke(function(popupMenu) { + return popupMenu._current.container; + }), actionName); +} + +function padEntry(element, name) { + return domQuery('[data-id="' + name + '"]', element); +} diff --git a/test/spec/features/align-elements/BpmnAlignElementsSpec.js b/test/spec/features/align-elements/BpmnAlignElementsSpec.js index d903003a83..104889da68 100644 --- a/test/spec/features/align-elements/BpmnAlignElementsSpec.js +++ b/test/spec/features/align-elements/BpmnAlignElementsSpec.js @@ -3,7 +3,7 @@ import { inject } from 'test/TestHelper'; -import alignElementsModule from 'diagram-js/lib/features/align-elements'; +import alignElementsModule from 'lib/features/align-elements'; import modelingModule from 'lib/features/modeling'; import coreModule from 'lib/core'; @@ -31,10 +31,10 @@ describe('features/align-elements', function() { alignElements.trigger(elements, 'left'); // then - expect(taskBoundEvt.x).to.equal(136); - expect(task.x).to.equal(136); - expect(subProcess.x).to.equal(136); - expect(endEvent.x).to.equal(136); + expect(taskBoundEvt.x).to.equal(276); + expect(task.x).to.equal(276); + expect(subProcess.x).to.equal(276); + expect(endEvent.x).to.equal(276); })); @@ -51,10 +51,10 @@ describe('features/align-elements', function() { alignElements.trigger(elements, 'right'); // then - expect(task.x).to.equal(720); - expect(taskHello.x).to.equal(720); - expect(subProcess.x).to.equal(470); - expect(endEvent.x).to.equal(784); + expect(task.x).to.equal(860); + expect(taskHello.x).to.equal(860); + expect(subProcess.x).to.equal(610); + expect(endEvent.x).to.equal(924); })); @@ -71,10 +71,10 @@ describe('features/align-elements', function() { alignElements.trigger(elements, 'center'); // then - expect(task.x).to.equal(428); - expect(taskHello.x).to.equal(428); - expect(subProcess.x).to.equal(303); - expect(endEvent.x).to.equal(460); + expect(task.x).to.equal(568); + expect(taskHello.x).to.equal(568); + expect(subProcess.x).to.equal(443); + expect(endEvent.x).to.equal(600); })); @@ -90,9 +90,9 @@ describe('features/align-elements', function() { alignElements.trigger(elements, 'top'); // then - expect(task.y).to.equal(375); - expect(subProcess.y).to.equal(375); - expect(endEvent.y).to.equal(375); + expect(task.y).to.equal(445); + expect(subProcess.y).to.equal(445); + expect(endEvent.y).to.equal(445); })); @@ -108,9 +108,9 @@ describe('features/align-elements', function() { alignElements.trigger(elements, 'bottom'); // then - expect(task.y).to.equal(761); - expect(subProcess.y).to.equal(641); - expect(endEvent.y).to.equal(805); + expect(task.y).to.equal(831); + expect(subProcess.y).to.equal(711); + expect(endEvent.y).to.equal(875); })); @@ -126,11 +126,59 @@ describe('features/align-elements', function() { alignElements.trigger(elements, 'middle'); // then - expect(task.y).to.equal(568); - expect(subProcess.y).to.equal(508); - expect(endEvent.y).to.equal(590); + expect(task.y).to.equal(638); + expect(subProcess.y).to.equal(578); + expect(endEvent.y).to.equal(660); })); }); + + describe('rules', function() { + + it('should not align boundary event', inject(function(alignElements, elementRegistry) { + + // given + var boundaryEvent = elementRegistry.get('BoundaryEvent_1'), + host = elementRegistry.get('Task_boundary_evt'); + var elements = [ + host, + elementRegistry.get('Task_hello'), + boundaryEvent + ]; + var initialRelativePosition = { + x: boundaryEvent.x - host.x, + y: boundaryEvent.y - host.y + }; + + // when + alignElements.trigger(elements, 'middle'); + + // then + expect(boundaryEvent.x).to.equal(initialRelativePosition.x + host.x); + expect(boundaryEvent.y).to.equal(initialRelativePosition.y + host.y); + })); + + + it('should not align container children', inject( + function(alignElements, elementRegistry) { + + // given + var elements = elementRegistry.getAll('SubProcessChild').slice(1), + child = elementRegistry.get('Task_hello'); + var initialRelativePosition = { + x: child.x - child.parent.x, + y: child.y - child.parent.y + }; + + // when + alignElements.trigger(elements, 'middle'); + + // then + expect(child.x).to.equal(initialRelativePosition.x + child.parent.x); + expect(child.y).to.equal(initialRelativePosition.y + child.parent.y); + }) + ); + }); + }); diff --git a/test/spec/features/distribute-elements/BpmnDistributeElementsSpec.js b/test/spec/features/distribute-elements/BpmnDistributeElementsSpec.js index d09a332dd3..8e5509ec46 100644 --- a/test/spec/features/distribute-elements/BpmnDistributeElementsSpec.js +++ b/test/spec/features/distribute-elements/BpmnDistributeElementsSpec.js @@ -1,3 +1,5 @@ +import { forEach } from 'min-dash'; + import { bootstrapModeler, inject @@ -7,6 +9,8 @@ import bpmnDistributeElements from 'lib/features/distribute-elements'; import modelingModule from 'lib/features/modeling'; import coreModule from 'lib/core'; +import { is } from 'lib/util/ModelUtil'; + function last(arr) { return arr[arr.length - 1]; } @@ -27,7 +31,7 @@ describe('features/distribute-elements', function() { beforeEach(inject(function(elementRegistry, canvas) { elements = elementRegistry.filter(function(element) { - return element.parent; + return element.parent && !is(element, 'bpmn:Participant'); }); })); @@ -79,34 +83,96 @@ describe('features/distribute-elements', function() { describe('filtering elements', function() { - var xml = require('../../../fixtures/bpmn/distribute-elements-filtering.bpmn'); + describe('process', function() { - beforeEach(bootstrapModeler(xml, { modules: testModules })); + var xml = require('../../../fixtures/bpmn/distribute-elements-filtering.bpmn'), + elements; - var elements; + beforeEach(bootstrapModeler(xml, { modules: testModules })); - beforeEach(inject(function(elementRegistry, canvas) { - elements = elementRegistry.filter(function(element) { - return element.parent; - }); - })); + beforeEach(inject(function(elementRegistry) { + elements = elementRegistry.filter(function(element) { + return element.parent; + }); + })); - it('should not distribute boundary events', inject(function(distributeElements, elementRegistry) { - // given - var boundaryEvent = elementRegistry.get('BoundaryEvent_1'); + it('should not distribute boundary events', inject(function(distributeElements, elementRegistry) { - // when - var rangeGroups = distributeElements.trigger(elements, 'horizontal'); + // given + var boundaryEvent = elementRegistry.get('BoundaryEvent_1'); - // then - expect(rangeGroups).to.have.length(3); + // when + var rangeGroups = distributeElements.trigger(elements, 'horizontal'); - expect(rangeGroups[1].elements).not.to.include(boundaryEvent); + // then + expect(rangeGroups).to.have.length(3); - })); + forEach(rangeGroups, function(rangeGroup) { + expect(rangeGroup.elements).not.to.include(boundaryEvent); + }); + })); + + + it('should not distribute sub process children', inject( + function(distributeElements, elementRegistry) { + + // given + var childElement = elementRegistry.get('SubProcessChild'); + + // when + var rangeGroups = distributeElements.trigger(elements, 'horizontal'); + + // then + expect(rangeGroups).to.have.length(3); + + forEach(rangeGroups, function(rangeGroup) { + expect(rangeGroup.elements).not.to.include(childElement); + }); + }) + ); + }); + + + describe('collaboration', function() { + + var xml = require('../../../fixtures/bpmn/distribute-elements-filtering.collaboration.bpmn'), + elements; + + beforeEach(bootstrapModeler(xml, { modules: testModules })); + + + beforeEach(inject(function(elementRegistry) { + elements = elementRegistry.filter(function(element) { + return element.parent; + }); + })); + + + it('should distribute participants', inject(function(distributeElements, elementRegistry) { + + // given + var participants = elementRegistry.filter(function(element) { + return is(element, 'bpmn:Participant'); + }); + + // when + var rangeGroups = distributeElements.trigger(elements, 'vertical'); + + // then + expect(rangeGroups).to.have.length(3); + + var distributedElements = []; + + forEach(rangeGroups, function(rangeGroup) { + distributedElements = distributedElements.concat(rangeGroup.elements); + }); + expect(distributedElements).to.have.length(3); + expect(distributedElements).to.have.members(participants); + })); + }); }); }); diff --git a/test/spec/features/distribute-elements/DistributeElementsMenuProviderSpec.js b/test/spec/features/distribute-elements/DistributeElementsMenuProviderSpec.js new file mode 100644 index 0000000000..421d908570 --- /dev/null +++ b/test/spec/features/distribute-elements/DistributeElementsMenuProviderSpec.js @@ -0,0 +1,95 @@ +import { + bootstrapModeler, + getBpmnJS, + inject +} from 'test/TestHelper'; + +import { + query as domQuery +} from 'min-dom'; + +import { + forEach +} from 'min-dash'; + +import distributeElementsModule from 'lib/features/distribute-elements'; +import modelingModule from 'lib/features/modeling'; +import coreModule from 'lib/core'; + + +describe('features/distribute-elements - popup menu', function() { + + var testModules = [ distributeElementsModule, modelingModule, coreModule ]; + + var basicXML = require('../../../fixtures/bpmn/distribute-elements.bpmn'); + + beforeEach(bootstrapModeler(basicXML, { modules: testModules })); + + + it('should provide distribution buttons', inject(function(elementRegistry, popupMenu) { + + // given + var elements = [ + elementRegistry.get('ExclusiveGateway_10cec0a'), + elementRegistry.get('Task_08pns8h'), + elementRegistry.get('Task_0511uak'), + elementRegistry.get('EndEvent_0c9irey') + ]; + + // when + popupMenu.open(elements, 'align-elements', { + x: 0, + y: 0 + }); + + // then + forEach([ + 'horizontal', + 'vertical' + ], function(distribution) { + expect(getEntry('distribute-elements-' + distribution)).to.exist; + }); + })); + + forEach([ + 'horizontal', + 'vertical' + ], function(distribution) { + it('should close popup menu when button is clicked', inject( + function(elementRegistry, popupMenu) { + + // given + var elements = [ + elementRegistry.get('ExclusiveGateway_10cec0a'), + elementRegistry.get('Task_08pns8h'), + elementRegistry.get('Task_0511uak'), + elementRegistry.get('EndEvent_0c9irey') + ]; + popupMenu.open(elements, 'align-elements', { + x: 0, + y: 0 + }); + var entry = getEntry('distribute-elements-' + distribution); + + // when + entry.click(); + + // then + expect(popupMenu.isOpen()).to.be.false; + }) + ); + }); + +}); + + +// helper ////////////////////////////////////////////////////////////////////// +function getEntry(actionName) { + return padEntry(getBpmnJS().invoke(function(popupMenu) { + return popupMenu._current.container; + }), actionName); +} + +function padEntry(element, name) { + return domQuery('[data-id="' + name + '"]', element); +}