├── .github └── workflows │ └── CI.yml ├── .gitignore ├── README.md ├── app ├── custom-elements.json ├── custom-modeler │ ├── custom │ │ ├── CustomContextPadProvider.js │ │ ├── CustomElementFactory.js │ │ ├── CustomOrderingProvider.js │ │ ├── CustomPalette.js │ │ ├── CustomRenderer.js │ │ ├── CustomRules.js │ │ ├── CustomUpdater.js │ │ └── index.js │ └── index.js ├── index.html └── index.js ├── docs └── screenshot.png ├── eslint.config.mjs ├── karma.conf.js ├── package-lock.json ├── package.json ├── renovate.json ├── resources └── pizza-collaboration.bpmn ├── test ├── TestHelper.js └── spec │ ├── CustomModelerSpec.js │ ├── CustomModelingSpec.js │ ├── Modeling.collaboration.bpmn │ ├── ModelingSpec.js │ └── diagram.bpmn └── webpack.config.js /.github/workflows/CI.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | on: [ push, pull_request ] 3 | jobs: 4 | Build: 5 | 6 | strategy: 7 | matrix: 8 | os: [ ubuntu-latest ] 9 | 10 | runs-on: ${{ matrix.os }} 11 | 12 | steps: 13 | - name: Checkout 14 | uses: actions/checkout@v4 15 | - name: Use Node.js 16 | uses: actions/setup-node@v4 17 | with: 18 | node-version: 20 19 | cache: 'npm' 20 | - name: Install dependencies 21 | run: npm ci 22 | - name: Setup project 23 | uses: bpmn-io/actions/setup@latest 24 | - name: Build 25 | run: npm run all -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | node_modules 3 | public -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | > :warning: Should custom elements be serialized within the BPMN 2.0 diagram? If that is the case, this example is not what you are looking for. Checkout our [:notebook: custom elements guide](https://github.com/bpmn-io/bpmn-js-examples/tree/master/custom-elements) to learn how to build custom elements in a BPMN 2.0 compatible way. 2 | 3 | 4 | # bpmn-js example: Custom Shapes 5 | 6 | [![CI](https://github.com/bpmn-io/bpmn-js-example-custom-shapes/actions/workflows/CI.yml/badge.svg)](https://github.com/bpmn-io/bpmn-js-example-custom-shapes/actions/workflows/CI.yml) 7 | 8 | This advanced example shows how to extend [bpmn-js](https://github.com/bpmn-io/bpmn-js) with new shapes and connections that are __not part of the BPMN 2.0 diagram / incompatible with the BPMN 2.0 standard__. Consult our [:notebook: custom elements guide](https://github.com/bpmn-io/bpmn-js-examples/tree/master/custom-elements) to learn how to extend the toolkit in a BPMN 2.0 compliant way. 9 | 10 | ## About 11 | 12 | This example extends [bpmn-js](https://github.com/bpmn-io/bpmn-js), creating a custom BPMN modeler that can display and add custom shapes and connections to BPMN 2.0 diagrams. 13 | 14 | The renderer ships with custom rules that define which modeling operations are possible on custom shapes and connections. 15 | It can import custom shapes and connections from a [JSON](http://json.org/) descriptor and updates their properties during modeling. 16 | 17 | ![demo application screenshot](docs/screenshot.png "bpmn-js custom elements example") 18 | 19 | 20 | ## Usage Summary 21 | 22 | The example provides a [custom modeler](app/custom-modeler/index.js). After instantiation, the modeler allows you to add and get custom shapes and connections. 23 | 24 | ```javascript 25 | // add custom elements 26 | var customElements = [ 27 | { 28 | type: "custom:triangle", 29 | id: "CustomTriangle_1", 30 | x: 300, 31 | y: 300 32 | }, 33 | { 34 | type: "custom:connection", 35 | id: "CustomConnection_1", 36 | source: "CustomTriangle_1", 37 | target: "Task_1", 38 | waypoints: [ 39 | // ... 40 | ] 41 | } 42 | ]; 43 | 44 | customModeler.addCustomElements(customElements); 45 | 46 | 47 | // get them after modeling 48 | customModeler.getCustomElements(); // all currently existing custom elements 49 | ``` 50 | 51 | The modeler ships with a [module](app/custom-modeler/custom/index.js) that provides the following [bpmn-js](https://github.com/bpmn-io/bpmn-js) extensions: 52 | 53 | * [`CustomContextPadProvider`](app/custom-modeler/custom/CustomContextPadProvider.js): A custom context pad that allows you to connect custom elements to BPMN elements 54 | * [`CustomElementFactory`](app/custom-modeler/custom/CustomElementFactory.js): A factory that knows about how to create BPMN and custom shapes 55 | * [`CustomOrderingProvider`](app/custom-modeler/custom/CustomOrderingProvider.js): A provider that ensures custom connections are always rendered on top 56 | * [`CustomPalette`](app/custom-modeler/custom/CustomPalette.js): A custom palette that allows you to create custom elements 57 | * [`CustomRenderer`](app/custom-modeler/custom/CustomRenderer.js): A renderer that knows how to draw custom elements 58 | * [`CustomRules`](app/custom-modeler/custom/CustomRules.js): A rule provider that defines the allowed interaction with custom elements 59 | * [`CustomUpdater`](app/custom-modeler/custom/CustomUpdater.js): An updater that updates business data while the user interacts with the diagram 60 | 61 | 62 | ## Build and Run 63 | 64 | ``` 65 | # install dependencies 66 | npm install 67 | 68 | # spin up development mode 69 | npm run dev 70 | 71 | # execute tests 72 | npm test 73 | ``` 74 | 75 | 76 | ## License 77 | 78 | MIT 79 | -------------------------------------------------------------------------------- /app/custom-elements.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "type":"custom:circle", 4 | "id":"CustomCircle_1", 5 | "x":806, 6 | "y":210 7 | }, 8 | { 9 | "type":"custom:triangle", 10 | "id":"CustomTriangle_1", 11 | "x":300, 12 | "y":300 13 | }, 14 | { 15 | "type":"custom:connection", 16 | "id":"CustomConnection_2", 17 | "waypoints":[ 18 | { 19 | "original":{ 20 | "x":320, 21 | "y":320 22 | }, 23 | "x":330, 24 | "y":320 25 | }, 26 | { 27 | "x":469, 28 | "y":320 29 | }, 30 | { 31 | "x":469, 32 | "y":225 33 | }, 34 | { 35 | "original":{ 36 | "x":559, 37 | "y":200 38 | }, 39 | "x":517, 40 | "y":212 41 | } 42 | ], 43 | "source":"CustomTriangle_1", 44 | "target":"Task_2" 45 | }, 46 | { 47 | "type":"custom:connection", 48 | "id":"CustomConnection_1", 49 | "source":"CustomTriangle_1", 50 | "target":"Task_1", 51 | "waypoints":[ 52 | { 53 | "original":{ 54 | "x":319, 55 | "y":302 56 | }, 57 | "x":319, 58 | "y":302 59 | }, 60 | { 61 | "x":319, 62 | "y":200 63 | }, 64 | { 65 | "x":309, 66 | "y":200 67 | }, 68 | { 69 | "original":{ 70 | "x":309, 71 | "y":145 72 | }, 73 | "x":309, 74 | "y":145 75 | } 76 | ] 77 | }, 78 | { 79 | "type":"custom:connection", 80 | "waypoints":[ 81 | { 82 | "original":{ 83 | "x":876, 84 | "y":280 85 | }, 86 | "x":946, 87 | "y":280 88 | }, 89 | { 90 | "x":1028, 91 | "y":280 92 | }, 93 | { 94 | "x":1028, 95 | "y":111 96 | }, 97 | { 98 | "original":{ 99 | "x":876, 100 | "y":111 101 | }, 102 | "x":917, 103 | "y":111 104 | } 105 | ], 106 | "source":"CustomCircle_1", 107 | "target":"Task_3" 108 | } 109 | ] 110 | -------------------------------------------------------------------------------- /app/custom-modeler/custom/CustomContextPadProvider.js: -------------------------------------------------------------------------------- 1 | import inherits from 'inherits-browser'; 2 | 3 | import ContextPadProvider from 'bpmn-js/lib/features/context-pad/ContextPadProvider'; 4 | 5 | import { 6 | isAny 7 | } from 'bpmn-js/lib/features/modeling/util/ModelingUtil'; 8 | 9 | import { 10 | assign, 11 | bind 12 | } from 'min-dash'; 13 | 14 | 15 | export default function CustomContextPadProvider(injector, connect, translate) { 16 | 17 | injector.invoke(ContextPadProvider, this); 18 | 19 | var cached = bind(this.getContextPadEntries, this); 20 | 21 | this.getContextPadEntries = function(element) { 22 | var actions = cached(element); 23 | 24 | var businessObject = element.businessObject; 25 | 26 | function startConnect(event, element, autoActivate) { 27 | connect.start(event, element, autoActivate); 28 | } 29 | 30 | if (isAny(businessObject, [ 'custom:triangle', 'custom:circle' ])) { 31 | assign(actions, { 32 | 'connect': { 33 | group: 'connect', 34 | className: 'bpmn-icon-connection-multi', 35 | title: translate('Connect using custom connection'), 36 | action: { 37 | click: startConnect, 38 | dragstart: startConnect 39 | } 40 | } 41 | }); 42 | } 43 | 44 | return actions; 45 | }; 46 | } 47 | 48 | inherits(CustomContextPadProvider, ContextPadProvider); 49 | 50 | CustomContextPadProvider.$inject = [ 51 | 'injector', 52 | 'connect', 53 | 'translate' 54 | ]; -------------------------------------------------------------------------------- /app/custom-modeler/custom/CustomElementFactory.js: -------------------------------------------------------------------------------- 1 | import { 2 | assign 3 | } from 'min-dash'; 4 | 5 | import inherits from 'inherits-browser'; 6 | 7 | import BpmnElementFactory from 'bpmn-js/lib/features/modeling/ElementFactory'; 8 | import { 9 | DEFAULT_LABEL_SIZE 10 | } from 'bpmn-js/lib/util/LabelUtil'; 11 | 12 | 13 | /** 14 | * A custom factory that knows how to create BPMN _and_ custom elements. 15 | */ 16 | export default function CustomElementFactory(bpmnFactory, moddle) { 17 | BpmnElementFactory.call(this, bpmnFactory, moddle); 18 | 19 | var self = this; 20 | 21 | /** 22 | * Create a diagram-js element with the given type (any of shape, connection, label). 23 | * 24 | * @param {String} elementType 25 | * @param {Object} attrs 26 | * 27 | * @return {djs.model.Base} 28 | */ 29 | this.create = function(elementType, attrs) { 30 | var type = attrs.type; 31 | 32 | if (elementType === 'label') { 33 | return self._baseCreate(elementType, assign({ type: 'label' }, DEFAULT_LABEL_SIZE, attrs)); 34 | } 35 | 36 | // add type to businessObject if custom 37 | if (/^custom:/.test(type)) { 38 | if (!attrs.businessObject) { 39 | attrs.businessObject = { 40 | type: type 41 | }; 42 | 43 | if (attrs.id) { 44 | assign(attrs.businessObject, { 45 | id: attrs.id 46 | }); 47 | } 48 | } 49 | 50 | // add width and height if shape 51 | if (!/:connection$/.test(type)) { 52 | assign(attrs, self._getCustomElementSize(type)); 53 | } 54 | 55 | 56 | // we mimic the ModdleElement API to allow interoperability with 57 | // other components, i.e. the Modeler and Properties Panel 58 | 59 | if (!('$model' in attrs.businessObject)) { 60 | Object.defineProperty(attrs.businessObject, '$model', { 61 | value: moddle 62 | }); 63 | } 64 | 65 | if (!('$instanceOf' in attrs.businessObject)) { 66 | 67 | // ensures we can use ModelUtil#is for type checks 68 | Object.defineProperty(attrs.businessObject, '$instanceOf', { 69 | value: function(type) { 70 | return this.type === type; 71 | } 72 | }); 73 | } 74 | 75 | if (!('get' in attrs.businessObject)) { 76 | Object.defineProperty(attrs.businessObject, 'get', { 77 | value: function(key) { 78 | return this[key]; 79 | } 80 | }); 81 | } 82 | 83 | if (!('set' in attrs.businessObject)) { 84 | Object.defineProperty(attrs.businessObject, 'set', { 85 | value: function(key, value) { 86 | return this[key] = value; 87 | } 88 | }); 89 | } 90 | 91 | // END minic ModdleElement API 92 | 93 | return self._baseCreate(elementType, attrs); 94 | } 95 | 96 | return this.createElement(elementType, attrs); 97 | }; 98 | } 99 | 100 | inherits(CustomElementFactory, BpmnElementFactory); 101 | 102 | CustomElementFactory.$inject = [ 103 | 'bpmnFactory', 104 | 'moddle' 105 | ]; 106 | 107 | 108 | /** 109 | * Returns the default size of custom shapes. 110 | * 111 | * The following example shows an interface on how 112 | * to setup the custom shapes's dimensions. 113 | * 114 | * @example 115 | * 116 | * var shapes = { 117 | * triangle: { width: 40, height: 40 }, 118 | * rectangle: { width: 100, height: 20 } 119 | * }; 120 | * 121 | * return shapes[type]; 122 | * 123 | * 124 | * @param {String} type 125 | * 126 | * @return {Dimensions} a {width, height} object representing the size of the element 127 | */ 128 | CustomElementFactory.prototype._getCustomElementSize = function(type) { 129 | var shapes = { 130 | __default: { width: 100, height: 80 }, 131 | 'custom:triangle': { width: 40, height: 40 }, 132 | 'custom:circle': { width: 140, height: 140 } 133 | }; 134 | 135 | return shapes[type] || shapes.__default; 136 | }; 137 | -------------------------------------------------------------------------------- /app/custom-modeler/custom/CustomOrderingProvider.js: -------------------------------------------------------------------------------- 1 | import inherits from 'inherits-browser'; 2 | 3 | import OrderingProvider from 'diagram-js/lib/features/ordering/OrderingProvider'; 4 | 5 | 6 | /** 7 | * a simple ordering provider that ensures that custom 8 | * connections are always rendered on top. 9 | */ 10 | export default function CustomOrderingProvider(eventBus, canvas) { 11 | 12 | OrderingProvider.call(this, eventBus); 13 | 14 | this.getOrdering = function(element, newParent) { 15 | 16 | if (element.type === 'custom:connection') { 17 | 18 | // always move to end of root element 19 | // to display always on top 20 | return { 21 | parent: canvas.getRootElement(), 22 | index: -1 23 | }; 24 | } 25 | }; 26 | } 27 | 28 | CustomOrderingProvider.$inject = [ 'eventBus', 'canvas' ]; 29 | 30 | inherits(CustomOrderingProvider, OrderingProvider); -------------------------------------------------------------------------------- /app/custom-modeler/custom/CustomPalette.js: -------------------------------------------------------------------------------- 1 | import { 2 | assign 3 | } from 'min-dash'; 4 | 5 | 6 | /** 7 | * A palette that allows you to create BPMN _and_ custom elements. 8 | */ 9 | export default function PaletteProvider(palette, create, elementFactory, spaceTool, lassoTool) { 10 | 11 | this._create = create; 12 | this._elementFactory = elementFactory; 13 | this._spaceTool = spaceTool; 14 | this._lassoTool = lassoTool; 15 | 16 | palette.registerProvider(this); 17 | } 18 | 19 | PaletteProvider.$inject = [ 20 | 'palette', 21 | 'create', 22 | 'elementFactory', 23 | 'spaceTool', 24 | 'lassoTool' 25 | ]; 26 | 27 | 28 | PaletteProvider.prototype.getPaletteEntries = function(element) { 29 | 30 | var actions = {}, 31 | create = this._create, 32 | elementFactory = this._elementFactory, 33 | spaceTool = this._spaceTool, 34 | lassoTool = this._lassoTool; 35 | 36 | 37 | function createAction(type, group, className, title, options) { 38 | 39 | function createListener(event) { 40 | var shape = elementFactory.createShape(assign({ type: type }, options)); 41 | 42 | if (options) { 43 | shape.businessObject.di.isExpanded = options.isExpanded; 44 | } 45 | 46 | create.start(event, shape); 47 | } 48 | 49 | var shortType = type.replace(/^bpmn:/, ''); 50 | 51 | return { 52 | group: group, 53 | className: className, 54 | title: title || 'Create ' + shortType, 55 | action: { 56 | dragstart: createListener, 57 | click: createListener 58 | } 59 | }; 60 | } 61 | 62 | function createParticipant(event, collapsed) { 63 | create.start(event, elementFactory.createParticipantShape(collapsed)); 64 | } 65 | 66 | assign(actions, { 67 | 'custom-triangle': createAction( 68 | 'custom:triangle', 'custom', 'icon-custom-triangle' 69 | ), 70 | 'custom-circle': createAction( 71 | 'custom:circle', 'custom', 'icon-custom-circle' 72 | ), 73 | 'custom-separator': { 74 | group: 'custom', 75 | separator: true 76 | }, 77 | 'lasso-tool': { 78 | group: 'tools', 79 | className: 'bpmn-icon-lasso-tool', 80 | title: 'Activate the lasso tool', 81 | action: { 82 | click: function(event) { 83 | lassoTool.activateSelection(event); 84 | } 85 | } 86 | }, 87 | 'space-tool': { 88 | group: 'tools', 89 | className: 'bpmn-icon-space-tool', 90 | title: 'Activate the create/remove space tool', 91 | action: { 92 | click: function(event) { 93 | spaceTool.activateSelection(event); 94 | } 95 | } 96 | }, 97 | 'tool-separator': { 98 | group: 'tools', 99 | separator: true 100 | }, 101 | 'create.start-event': createAction( 102 | 'bpmn:StartEvent', 'event', 'bpmn-icon-start-event-none' 103 | ), 104 | 'create.intermediate-event': createAction( 105 | 'bpmn:IntermediateThrowEvent', 'event', 'bpmn-icon-intermediate-event-none' 106 | ), 107 | 'create.end-event': createAction( 108 | 'bpmn:EndEvent', 'event', 'bpmn-icon-end-event-none' 109 | ), 110 | 'create.exclusive-gateway': createAction( 111 | 'bpmn:ExclusiveGateway', 'gateway', 'bpmn-icon-gateway-xor' 112 | ), 113 | 'create.task': createAction( 114 | 'bpmn:Task', 'activity', 'bpmn-icon-task' 115 | ), 116 | 'create.subprocess-expanded': createAction( 117 | 'bpmn:SubProcess', 'activity', 'bpmn-icon-subprocess-expanded', 'Create expanded SubProcess', 118 | { isExpanded: true } 119 | ), 120 | 'create.participant-expanded': { 121 | group: 'collaboration', 122 | className: 'bpmn-icon-participant', 123 | title: 'Create Pool/Participant', 124 | action: { 125 | dragstart: createParticipant, 126 | click: createParticipant 127 | } 128 | } 129 | }); 130 | 131 | return actions; 132 | }; 133 | -------------------------------------------------------------------------------- /app/custom-modeler/custom/CustomRenderer.js: -------------------------------------------------------------------------------- 1 | import inherits from 'inherits-browser'; 2 | 3 | import BaseRenderer from 'diagram-js/lib/draw/BaseRenderer'; 4 | 5 | import { 6 | componentsToPath, 7 | createLine 8 | } from 'diagram-js/lib/util/RenderUtil'; 9 | 10 | import { 11 | append as svgAppend, 12 | attr as svgAttr, 13 | create as svgCreate 14 | } from 'tiny-svg'; 15 | 16 | var COLOR_GREEN = '#52B415', 17 | COLOR_RED = '#cc0000', 18 | COLOR_YELLOW = '#ffc800'; 19 | 20 | /** 21 | * A renderer that knows how to render custom elements. 22 | */ 23 | export default function CustomRenderer(eventBus, styles) { 24 | 25 | BaseRenderer.call(this, eventBus, 2000); 26 | 27 | var computeStyle = styles.computeStyle; 28 | 29 | this.drawTriangle = function(p, side) { 30 | var halfSide = side / 2, 31 | points, 32 | attrs; 33 | 34 | points = [ halfSide, 0, side, side, 0, side ]; 35 | 36 | attrs = computeStyle(attrs, { 37 | stroke: COLOR_GREEN, 38 | strokeWidth: 2, 39 | fill: COLOR_GREEN 40 | }); 41 | 42 | var polygon = svgCreate('polygon'); 43 | 44 | svgAttr(polygon, { 45 | points: points 46 | }); 47 | 48 | svgAttr(polygon, attrs); 49 | 50 | svgAppend(p, polygon); 51 | 52 | return polygon; 53 | }; 54 | 55 | this.getTrianglePath = function(element) { 56 | var x = element.x, 57 | y = element.y, 58 | width = element.width, 59 | height = element.height; 60 | 61 | var trianglePath = [ 62 | [ 'M', x + width / 2, y ], 63 | [ 'l', width / 2, height ], 64 | [ 'l', -width, 0 ], 65 | [ 'z' ] 66 | ]; 67 | 68 | return componentsToPath(trianglePath); 69 | }; 70 | 71 | this.drawCircle = function(p, width, height) { 72 | var cx = width / 2, 73 | cy = height / 2; 74 | 75 | var attrs = computeStyle(attrs, { 76 | stroke: COLOR_YELLOW, 77 | strokeWidth: 4, 78 | fill: COLOR_YELLOW 79 | }); 80 | 81 | var circle = svgCreate('circle'); 82 | 83 | svgAttr(circle, { 84 | cx: cx, 85 | cy: cy, 86 | r: Math.round((width + height) / 4) 87 | }); 88 | 89 | svgAttr(circle, attrs); 90 | 91 | svgAppend(p, circle); 92 | 93 | return circle; 94 | }; 95 | 96 | this.getCirclePath = function(shape) { 97 | var cx = shape.x + shape.width / 2, 98 | cy = shape.y + shape.height / 2, 99 | radius = shape.width / 2; 100 | 101 | var circlePath = [ 102 | [ 'M', cx, cy ], 103 | [ 'm', 0, -radius ], 104 | [ 'a', radius, radius, 0, 1, 1, 0, 2 * radius ], 105 | [ 'a', radius, radius, 0, 1, 1, 0, -2 * radius ], 106 | [ 'z' ] 107 | ]; 108 | 109 | return componentsToPath(circlePath); 110 | }; 111 | 112 | this.drawCustomConnection = function(p, element) { 113 | var attrs = computeStyle(attrs, { 114 | stroke: COLOR_RED, 115 | strokeWidth: 2 116 | }); 117 | 118 | return svgAppend(p, createLine(element.waypoints, attrs)); 119 | }; 120 | 121 | this.getCustomConnectionPath = function(connection) { 122 | var waypoints = connection.waypoints.map(function(p) { 123 | return p.original || p; 124 | }); 125 | 126 | var connectionPath = [ 127 | [ 'M', waypoints[0].x, waypoints[0].y ] 128 | ]; 129 | 130 | waypoints.forEach(function(waypoint, index) { 131 | if (index !== 0) { 132 | connectionPath.push([ 'L', waypoint.x, waypoint.y ]); 133 | } 134 | }); 135 | 136 | return componentsToPath(connectionPath); 137 | }; 138 | } 139 | 140 | inherits(CustomRenderer, BaseRenderer); 141 | 142 | CustomRenderer.$inject = [ 'eventBus', 'styles' ]; 143 | 144 | 145 | CustomRenderer.prototype.canRender = function(element) { 146 | return /^custom:/.test(element.type); 147 | }; 148 | 149 | CustomRenderer.prototype.drawShape = function(p, element) { 150 | var type = element.type; 151 | 152 | if (type === 'custom:triangle') { 153 | return this.drawTriangle(p, element.width); 154 | } 155 | 156 | if (type === 'custom:circle') { 157 | return this.drawCircle(p, element.width, element.height); 158 | } 159 | }; 160 | 161 | CustomRenderer.prototype.getShapePath = function(shape) { 162 | var type = shape.type; 163 | 164 | if (type === 'custom:triangle') { 165 | return this.getTrianglePath(shape); 166 | } 167 | 168 | if (type === 'custom:circle') { 169 | return this.getCirclePath(shape); 170 | } 171 | }; 172 | 173 | CustomRenderer.prototype.drawConnection = function(p, element) { 174 | 175 | var type = element.type; 176 | 177 | if (type === 'custom:connection') { 178 | return this.drawCustomConnection(p, element); 179 | } 180 | }; 181 | 182 | 183 | CustomRenderer.prototype.getConnectionPath = function(connection) { 184 | 185 | var type = connection.type; 186 | 187 | if (type === 'custom:connection') { 188 | return this.getCustomConnectionPath(connection); 189 | } 190 | }; 191 | -------------------------------------------------------------------------------- /app/custom-modeler/custom/CustomRules.js: -------------------------------------------------------------------------------- 1 | import { 2 | reduce 3 | } from 'min-dash'; 4 | 5 | import inherits from 'inherits-browser'; 6 | 7 | import { 8 | is 9 | } from 'bpmn-js/lib/util/ModelUtil'; 10 | 11 | import RuleProvider from 'diagram-js/lib/features/rules/RuleProvider'; 12 | 13 | var HIGH_PRIORITY = 1500; 14 | 15 | 16 | function isCustom(element) { 17 | return element && /^custom:/.test(element.type); 18 | } 19 | 20 | /** 21 | * Specific rules for custom elements 22 | */ 23 | export default function CustomRules(eventBus) { 24 | RuleProvider.call(this, eventBus); 25 | } 26 | 27 | inherits(CustomRules, RuleProvider); 28 | 29 | CustomRules.$inject = [ 'eventBus' ]; 30 | 31 | 32 | CustomRules.prototype.init = function() { 33 | 34 | /** 35 | * Can shape be created on target container? 36 | */ 37 | function canCreate(shape, target) { 38 | 39 | // only judge about custom elements 40 | if (!isCustom(shape)) { 41 | return; 42 | } 43 | 44 | // allow creation on processes 45 | return is(target, 'bpmn:Process') || is(target, 'bpmn:Participant') || is(target, 'bpmn:Collaboration'); 46 | } 47 | 48 | /** 49 | * Can source and target be connected? 50 | */ 51 | function canConnect(source, target) { 52 | 53 | // only judge about custom elements 54 | if (!isCustom(source) && !isCustom(target)) { 55 | return; 56 | } 57 | 58 | // allow connection between custom shape and task 59 | if (isCustom(source)) { 60 | if (is(target, 'bpmn:Task')) { 61 | return { type: 'custom:connection' }; 62 | } else { 63 | return false; 64 | } 65 | } else if (isCustom(target)) { 66 | if (is(source, 'bpmn:Task')) { 67 | return { type: 'custom:connection' }; 68 | } else { 69 | return false; 70 | } 71 | } 72 | } 73 | 74 | this.addRule('elements.move', HIGH_PRIORITY, function(context) { 75 | 76 | var target = context.target, 77 | shapes = context.shapes; 78 | 79 | var type; 80 | 81 | // do not allow mixed movements of custom / BPMN shapes 82 | // if any shape cannot be moved, the group cannot be moved, too 83 | var allowed = reduce(shapes, function(result, s) { 84 | if (type === undefined) { 85 | type = isCustom(s); 86 | } 87 | 88 | if (type !== isCustom(s) || result === false) { 89 | return false; 90 | } 91 | 92 | return canCreate(s, target); 93 | }, undefined); 94 | 95 | // reject, if we have at least one 96 | // custom element that cannot be moved 97 | return allowed; 98 | }); 99 | 100 | this.addRule('shape.create', HIGH_PRIORITY, function(context) { 101 | var target = context.target, 102 | shape = context.shape; 103 | 104 | return canCreate(shape, target); 105 | }); 106 | 107 | this.addRule('shape.resize', HIGH_PRIORITY, function(context) { 108 | var shape = context.shape; 109 | 110 | if (isCustom(shape)) { 111 | 112 | // cannot resize custom elements 113 | return false; 114 | } 115 | }); 116 | 117 | this.addRule('connection.create', HIGH_PRIORITY, function(context) { 118 | var source = context.source, 119 | target = context.target; 120 | 121 | return canConnect(source, target); 122 | }); 123 | 124 | this.addRule('connection.reconnectStart', HIGH_PRIORITY, function(context) { 125 | var connection = context.connection, 126 | source = context.hover || context.source, 127 | target = connection.target; 128 | 129 | return canConnect(source, target, connection); 130 | }); 131 | 132 | this.addRule('connection.reconnectEnd', HIGH_PRIORITY, function(context) { 133 | var connection = context.connection, 134 | source = connection.source, 135 | target = context.hover || context.target; 136 | 137 | return canConnect(source, target, connection); 138 | }); 139 | 140 | }; 141 | -------------------------------------------------------------------------------- /app/custom-modeler/custom/CustomUpdater.js: -------------------------------------------------------------------------------- 1 | import inherits from 'inherits-browser'; 2 | 3 | import { 4 | pick, 5 | assign 6 | } from 'min-dash'; 7 | 8 | import CommandInterceptor from 'diagram-js/lib/command/CommandInterceptor'; 9 | 10 | import { 11 | add as collectionAdd, 12 | remove as collectionRemove 13 | } from 'diagram-js/lib/util/Collections'; 14 | 15 | 16 | /** 17 | * A handler responsible for updating the custom element's businessObject 18 | * once changes on the diagram happen. 19 | */ 20 | export default function CustomUpdater(eventBus, modeling, bpmnjs) { 21 | 22 | CommandInterceptor.call(this, eventBus); 23 | 24 | function updateCustomElement(e) { 25 | var context = e.context, 26 | shape = context.shape, 27 | businessObject = shape.businessObject; 28 | 29 | if (!isCustom(shape)) { 30 | return; 31 | } 32 | 33 | var parent = shape.parent; 34 | 35 | var customElements = bpmnjs._customElements; 36 | 37 | // make sure element is added / removed from bpmnjs.customElements 38 | if (!parent) { 39 | collectionRemove(customElements, businessObject); 40 | } else { 41 | collectionAdd(customElements, businessObject); 42 | } 43 | 44 | // save custom element position 45 | assign(businessObject, pick(shape, [ 'x', 'y' ])); 46 | } 47 | 48 | function updateCustomConnection(e) { 49 | 50 | var context = e.context, 51 | connection = context.connection, 52 | source = connection.source, 53 | target = connection.target, 54 | businessObject = connection.businessObject; 55 | 56 | var parent = connection.parent; 57 | 58 | var customElements = bpmnjs._customElements; 59 | 60 | // make sure element is added / removed from bpmnjs.customElements 61 | if (!parent) { 62 | collectionRemove(customElements, businessObject); 63 | } else { 64 | collectionAdd(customElements, businessObject); 65 | } 66 | 67 | // update waypoints 68 | assign(businessObject, { 69 | waypoints: copyWaypoints(connection) 70 | }); 71 | 72 | if (source && target) { 73 | assign(businessObject, { 74 | source: source.id, 75 | target: target.id 76 | }); 77 | } 78 | 79 | } 80 | 81 | this.executed([ 82 | 'shape.create', 83 | 'shape.move', 84 | 'shape.delete' 85 | ], ifCustomElement(updateCustomElement)); 86 | 87 | this.reverted([ 88 | 'shape.create', 89 | 'shape.move', 90 | 'shape.delete' 91 | ], ifCustomElement(updateCustomElement)); 92 | 93 | this.executed([ 94 | 'connection.create', 95 | 'connection.reconnectStart', 96 | 'connection.reconnectEnd', 97 | 'connection.updateWaypoints', 98 | 'connection.delete', 99 | 'connection.layout', 100 | 'connection.move' 101 | ], ifCustomElement(updateCustomConnection)); 102 | 103 | this.reverted([ 104 | 'connection.create', 105 | 'connection.reconnectStart', 106 | 'connection.reconnectEnd', 107 | 'connection.updateWaypoints', 108 | 'connection.delete', 109 | 'connection.layout', 110 | 'connection.move' 111 | ], ifCustomElement(updateCustomConnection)); 112 | 113 | 114 | /** 115 | * When morphing a Process into a Collaboration or vice-versa, 116 | * make sure that the existing custom elements get their parents updated. 117 | */ 118 | function updateCustomElementsRoot(event) { 119 | var context = event.context, 120 | oldRoot = context.oldRoot, 121 | newRoot = context.newRoot, 122 | children = oldRoot.children; 123 | 124 | var customChildren = children.filter(isCustom); 125 | 126 | if (customChildren.length) { 127 | modeling.moveElements(customChildren, { x: 0, y: 0 }, newRoot); 128 | } 129 | } 130 | 131 | this.postExecute('canvas.updateRoot', updateCustomElementsRoot); 132 | } 133 | 134 | inherits(CustomUpdater, CommandInterceptor); 135 | 136 | CustomUpdater.$inject = [ 'eventBus', 'modeling', 'bpmnjs' ]; 137 | 138 | 139 | // helpers /////////////////////////////////// 140 | 141 | function copyWaypoints(connection) { 142 | return connection.waypoints.map(function(p) { 143 | return { x: p.x, y: p.y }; 144 | }); 145 | } 146 | 147 | function isCustom(element) { 148 | return element && /custom:/.test(element.type); 149 | } 150 | 151 | function ifCustomElement(fn) { 152 | return function(event) { 153 | var context = event.context, 154 | element = context.shape || context.connection; 155 | 156 | if (isCustom(element)) { 157 | fn(event); 158 | } 159 | }; 160 | } -------------------------------------------------------------------------------- /app/custom-modeler/custom/index.js: -------------------------------------------------------------------------------- 1 | import CustomContextPadProvider from './CustomContextPadProvider'; 2 | import CustomElementFactory from './CustomElementFactory'; 3 | import CustomOrderingProvider from './CustomOrderingProvider'; 4 | import CustomPalette from './CustomPalette'; 5 | import CustomRenderer from './CustomRenderer'; 6 | import CustomRules from './CustomRules'; 7 | import CustomUpdater from './CustomUpdater'; 8 | 9 | export default { 10 | __init__: [ 11 | 'contextPadProvider', 12 | 'customOrderingProvider', 13 | 'customRenderer', 14 | 'customRules', 15 | 'customUpdater', 16 | 'paletteProvider' 17 | ], 18 | contextPadProvider: [ 'type', CustomContextPadProvider ], 19 | customOrderingProvider: [ 'type', CustomOrderingProvider ], 20 | customRenderer: [ 'type', CustomRenderer ], 21 | customRules: [ 'type', CustomRules ], 22 | customUpdater: [ 'type', CustomUpdater ], 23 | elementFactory: [ 'type', CustomElementFactory ], 24 | paletteProvider: [ 'type', CustomPalette ] 25 | }; 26 | -------------------------------------------------------------------------------- /app/custom-modeler/index.js: -------------------------------------------------------------------------------- 1 | import Modeler from 'bpmn-js/lib/Modeler'; 2 | 3 | import { 4 | assign, 5 | isArray 6 | } from 'min-dash'; 7 | 8 | import inherits from 'inherits-browser'; 9 | 10 | import CustomModule from './custom'; 11 | 12 | 13 | export default function CustomModeler(options) { 14 | Modeler.call(this, options); 15 | 16 | this._customElements = []; 17 | } 18 | 19 | inherits(CustomModeler, Modeler); 20 | 21 | CustomModeler.prototype._modules = [].concat( 22 | CustomModeler.prototype._modules, 23 | [ 24 | CustomModule 25 | ] 26 | ); 27 | 28 | /** 29 | * Add a single custom element to the underlying diagram 30 | * 31 | * @param {Object} customElement 32 | */ 33 | CustomModeler.prototype._addCustomShape = function(customElement) { 34 | 35 | this._customElements.push(customElement); 36 | 37 | var canvas = this.get('canvas'), 38 | elementFactory = this.get('elementFactory'); 39 | 40 | var customAttrs = assign({ businessObject: customElement }, customElement); 41 | 42 | var customShape = elementFactory.create('shape', customAttrs); 43 | 44 | return canvas.addShape(customShape); 45 | 46 | }; 47 | 48 | CustomModeler.prototype._addCustomConnection = function(customElement) { 49 | 50 | this._customElements.push(customElement); 51 | 52 | var canvas = this.get('canvas'), 53 | elementFactory = this.get('elementFactory'), 54 | elementRegistry = this.get('elementRegistry'); 55 | 56 | var customAttrs = assign({ businessObject: customElement }, customElement); 57 | 58 | var connection = elementFactory.create('connection', assign(customAttrs, { 59 | source: elementRegistry.get(customElement.source), 60 | target: elementRegistry.get(customElement.target) 61 | }), 62 | elementRegistry.get(customElement.source).parent); 63 | 64 | return canvas.addConnection(connection); 65 | 66 | }; 67 | 68 | /** 69 | * Add a number of custom elements and connections to the underlying diagram. 70 | * 71 | * @param {Array} customElements 72 | */ 73 | CustomModeler.prototype.addCustomElements = function(customElements) { 74 | 75 | if (!isArray(customElements)) { 76 | throw new Error('argument must be an array'); 77 | } 78 | 79 | var shapes = [], 80 | connections = []; 81 | 82 | customElements.forEach(function(customElement) { 83 | if (isCustomConnection(customElement)) { 84 | connections.push(customElement); 85 | } else { 86 | shapes.push(customElement); 87 | } 88 | }); 89 | 90 | // add shapes before connections so that connections 91 | // can already rely on the shapes being part of the diagram 92 | shapes.forEach(this._addCustomShape, this); 93 | 94 | connections.forEach(this._addCustomConnection, this); 95 | }; 96 | 97 | /** 98 | * Get custom elements with their current status. 99 | * 100 | * @return {Array} custom elements on the diagram 101 | */ 102 | CustomModeler.prototype.getCustomElements = function() { 103 | return this._customElements; 104 | }; 105 | 106 | 107 | function isCustomConnection(element) { 108 | return element.type === 'custom:connection'; 109 | } 110 | -------------------------------------------------------------------------------- /app/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 27 | 28 | 32 | 33 | custom elements example - bpmn-js-examples 34 | 35 | 36 |
37 | 38 | 39 | 40 | 41 | -------------------------------------------------------------------------------- /app/index.js: -------------------------------------------------------------------------------- 1 | /* eslint-env browser */ 2 | 3 | import pizzaDiagram from '../resources/pizza-collaboration.bpmn'; 4 | 5 | import customElements from './custom-elements.json'; 6 | 7 | import CustomModeler from './custom-modeler'; 8 | 9 | var modeler = new CustomModeler({ 10 | container: '#canvas' 11 | }); 12 | 13 | modeler.importXML(pizzaDiagram).then(() => { 14 | modeler.get('canvas').zoom('fit-viewport'); 15 | 16 | modeler.addCustomElements(customElements); 17 | }).catch(err => { 18 | console.error('something went wrong:', err); 19 | }); 20 | 21 | 22 | // expose bpmnjs to window for debugging purposes 23 | window.bpmnjs = modeler; 24 | -------------------------------------------------------------------------------- /docs/screenshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bpmn-io/bpmn-js-example-custom-shapes/9b6731226a9fc7720f473412529a2f911818bf6c/docs/screenshot.png -------------------------------------------------------------------------------- /eslint.config.mjs: -------------------------------------------------------------------------------- 1 | import bpmnIoPlugin from 'eslint-plugin-bpmn-io'; 2 | 3 | export default [ 4 | { 5 | ignores: [ 'public' ], 6 | }, 7 | ...bpmnIoPlugin.configs.browser, 8 | ...bpmnIoPlugin.configs.node.map(config => { 9 | return { 10 | ...config, 11 | files: [ 12 | '**/*.config.js', 13 | '**/*.conf.js', 14 | 'test/**/*.js', 15 | ] 16 | }; 17 | }), 18 | ...bpmnIoPlugin.configs.mocha.map(config => { 19 | return { 20 | ...config, 21 | files: [ 22 | 'test/**/*.js', 23 | ] 24 | }; 25 | }), 26 | { 27 | files: [ '**/*.js', '**/*.mjs' ], 28 | } 29 | ]; -------------------------------------------------------------------------------- /karma.conf.js: -------------------------------------------------------------------------------- 1 | /* eslint-env node */ 2 | 3 | // configures browsers to run test against 4 | // any of [ 'ChromeHeadless', 'Chrome', 'Firefox', 'Safari' ] 5 | var browsers = (process.env.TEST_BROWSERS || 'ChromeHeadless').split(','); 6 | 7 | // use puppeteer provided Chrome for testing 8 | process.env.CHROME_BIN = require('puppeteer').executablePath(); 9 | 10 | 11 | module.exports = function(karma) { 12 | karma.set({ 13 | 14 | frameworks: [ 15 | 'mocha', 16 | 'chai', 17 | 'webpack' 18 | ], 19 | 20 | files: [ 21 | 'test/spec/**/*Spec.js' 22 | ], 23 | 24 | preprocessors: { 25 | 'test/spec/**/*Spec.js': [ 'webpack' ] 26 | }, 27 | 28 | reporters: [ 'progress' ], 29 | 30 | browsers, 31 | 32 | browserNoActivityTimeout: 30000, 33 | 34 | singleRun: true, 35 | autoWatch: false, 36 | 37 | webpack: { 38 | mode: 'development', 39 | module: { 40 | rules: [ 41 | { 42 | test: require.resolve('./test/TestHelper.js'), 43 | sideEffects: true 44 | }, 45 | { 46 | test: /\.css|\.bpmn$/, 47 | type: 'asset/source' 48 | } 49 | ] 50 | }, 51 | resolve: { 52 | mainFields: [ 53 | 'dev:module', 54 | 'module', 55 | 'main' 56 | ] 57 | }, 58 | devtool: 'eval-source-map' 59 | } 60 | }); 61 | }; 62 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "custom-elements-example", 3 | "version": "0.0.0", 4 | "description": "An example on how to create a custom elements with bpmn-js", 5 | "main": "app/app.js", 6 | "repository": { 7 | "type": "git", 8 | "url": "https://github.com/bpmn-io/bpmn-js-examples" 9 | }, 10 | "scripts": { 11 | "all": "run-s lint test build", 12 | "lint": "eslint .", 13 | "auto-test": "npm test -- --auto-watch --no-single-run", 14 | "test": "karma start", 15 | "build": "webpack", 16 | "start": "run-s build serve", 17 | "dev": "run-p \"build -- --watch\" serve", 18 | "serve": "sirv public --dev" 19 | }, 20 | "keywords": [ 21 | "bpmnjs-example" 22 | ], 23 | "author": { 24 | "name": "Ricardo Matias", 25 | "url": "https://github.com/ricardomatias" 26 | }, 27 | "contributors": [ 28 | { 29 | "name": "bpmn.io contributors", 30 | "url": "https://github.com/bpmn-io" 31 | } 32 | ], 33 | "license": "MIT", 34 | "devDependencies": { 35 | "chai": "^4.3.10", 36 | "copy-webpack-plugin": "^13.0.0", 37 | "eslint": "^9.12.0", 38 | "eslint-plugin-bpmn-io": "^2.0.2", 39 | "karma": "^6.4.2", 40 | "karma-chai": "^0.1.0", 41 | "karma-chrome-launcher": "^3.2.0", 42 | "karma-firefox-launcher": "^2.1.2", 43 | "karma-mocha": "^2.0.1", 44 | "karma-webpack": "^5.0.0", 45 | "mocha": "^10.2.0", 46 | "mocha-test-container-support": "^0.2.0", 47 | "npm-run-all2": "^8.0.0", 48 | "puppeteer": "^24.0.0", 49 | "sirv-cli": "^3.0.0", 50 | "webpack": "^5.89.0", 51 | "webpack-cli": "^6.0.0" 52 | }, 53 | "dependencies": { 54 | "bpmn-js": "^18.0.0", 55 | "diagram-js": "^15.1.0", 56 | "inherits-browser": "^0.1.0", 57 | "min-dash": "^4.1.1", 58 | "tiny-svg": "^4.0.0" 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /renovate.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://docs.renovatebot.com/renovate-schema.json", 3 | "extends": [ 4 | "github>bpmn-io/renovate-config:recommended" 5 | ] 6 | } 7 | -------------------------------------------------------------------------------- /resources/pizza-collaboration.bpmn: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | OrderReceivedEvent 9 | _6-652 10 | _6-674 11 | CalmCustomerTask 12 | 13 | 14 | _6-463 15 | 16 | 17 | _6-514 18 | _6-565 19 | _6-616 20 | 21 | 22 | 23 | _6-630 24 | 25 | 26 | 27 | _6-630 28 | _6-691 29 | _6-693 30 | 31 | 32 | _6-691 33 | _6-746 34 | _6-748 35 | 36 | 37 | 38 | _6-748 39 | _6-746 40 | 41 | 42 | _6-693 43 | _6-632 44 | 45 | 46 | _6-632 47 | _6-634 48 | 49 | 50 | _6-634 51 | _6-636 52 | 53 | 54 | _6-636 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | _6-125 70 | 71 | 72 | _6-125 73 | _6-178 74 | 75 | 76 | _6-178 77 | _6-420 78 | 79 | 80 | _6-420 81 | _6-430 82 | _6-422 83 | _6-424 84 | 85 | 86 | _6-422 87 | _6-428 88 | 89 | 90 | 91 | _6-424 92 | _6-426 93 | 94 | 95 | 96 | 97 | 98 | _6-426 99 | _6-430 100 | 101 | 102 | _6-428 103 | _6-434 104 | 105 | 106 | _6-434 107 | _6-436 108 | 109 | 110 | _6-436 111 | 112 | 113 | 114 | 115 | 116 | 117 | 118 | 119 | 120 | 121 | 122 | 123 | 124 | 125 | 126 | 127 | 128 | 129 | 130 | 131 | 132 | 133 | 134 | 135 | 136 | 137 | 138 | 139 | 140 | 141 | 142 | 143 | 144 | 145 | 146 | 147 | 148 | 149 | 150 | 151 | 152 | 153 | 154 | 155 | 156 | 157 | 158 | 159 | 160 | 161 | 162 | 163 | 164 | 165 | 166 | 167 | 168 | 169 | 170 | 171 | 172 | 173 | 174 | 175 | 176 | 177 | 178 | 179 | 180 | 181 | 182 | 183 | 184 | 185 | 186 | 187 | 188 | 189 | 190 | 191 | 192 | 193 | 194 | 195 | 196 | 197 | 198 | 199 | 200 | 201 | 202 | 203 | 204 | 205 | 206 | 207 | 208 | 209 | 210 | 211 | 212 | 213 | 214 | 215 | 216 | 217 | 218 | 219 | 220 | 221 | 222 | 223 | 224 | 225 | 226 | 227 | 228 | 229 | 230 | 231 | 232 | 233 | 234 | 235 | 236 | 237 | 238 | 239 | 240 | 241 | 242 | 243 | 244 | 245 | 246 | 247 | 248 | 249 | 250 | 251 | 252 | 253 | 254 | 255 | 256 | 257 | 258 | 259 | 260 | 261 | 262 | 263 | 264 | 265 | 266 | 267 | 268 | 269 | 270 | 271 | 272 | 273 | 274 | 275 | 276 | 277 | 278 | 279 | 280 | 281 | 282 | 283 | 284 | 285 | 286 | 287 | 288 | 289 | 290 | 291 | 292 | 293 | 294 | 295 | 296 | 297 | 298 | 299 | 300 | 301 | 302 | 303 | 304 | 305 | 306 | 307 | 308 | 309 | 310 | 311 | 312 | 313 | 314 | 315 | 316 | 317 | 318 | 319 | 320 | 321 | 322 | 323 | 324 | 325 | 326 | 327 | 328 | 329 | 330 | 331 | 332 | 333 | 334 | 335 | 336 | 337 | 338 | 339 | 340 | 341 | 342 | 343 | 344 | 345 | 346 | 347 | 348 | 349 | 350 | 351 | 352 | 353 | 354 | 355 | 356 | 357 | 358 | 359 | 360 | 361 | 362 | 363 | 364 | 365 | 366 | 367 | 368 | 369 | 370 | 371 | 372 | 373 | 374 | 375 | 376 | 377 | 378 | 379 | 380 | 381 | 382 | 383 | 384 | -------------------------------------------------------------------------------- /test/TestHelper.js: -------------------------------------------------------------------------------- 1 | export * from 'bpmn-js/test/helper'; 2 | 3 | import { 4 | insertCSS 5 | } from 'bpmn-js/test/helper'; 6 | 7 | insertCSS('diagram-js.css', require('bpmn-js/dist/assets/diagram-js.css')); 8 | insertCSS('bpmn-embedded.css', require('bpmn-js/dist/assets/bpmn-font/css/bpmn-embedded.css')); 9 | 10 | insertCSS('diagram-js-testing.css', 11 | '.test-container .result { height: 500px; }' + '.test-container > div' 12 | ); 13 | 14 | insertCSS('custom-modeler-testing.css', 15 | '.icon-custom-triangle {' 16 | + 'background: url(\'data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20fill%3D%22%233CAA82%22%20width%3D%22270%22%20height%3D%22240%22%3E%3Cpath%20d%3D%22M8%2C40%20l%2015%2C-27%20l%2015%2C27%20z%22%2F%3E%3C%2Fsvg%3E\');' 17 | + '}' 18 | + '.icon-custom-circle {' 19 | + 'background: url(\'data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20stroke-width%3D%228%22%20stroke%3D%22%2348a%22%20fill%3D%22none%22%20viewBox%3D%220%200%20120%20120%22%3E%3Ccircle%20cx%3D%2260%22%20cy%3D%2260%22%20r%3D%2240%22%2F%3E%3C%2Fsvg%3E\');' 20 | + '}' 21 | ); 22 | -------------------------------------------------------------------------------- /test/spec/CustomModelerSpec.js: -------------------------------------------------------------------------------- 1 | import '../TestHelper'; 2 | 3 | import TestContainer from 'mocha-test-container-support'; 4 | 5 | import CustomModeler from '../../app/custom-modeler'; 6 | 7 | import { 8 | is 9 | } from 'bpmn-js/lib/util/ModelUtil'; 10 | 11 | import diagramXML from './diagram.bpmn'; 12 | 13 | 14 | describe('custom modeler', function() { 15 | 16 | var container; 17 | 18 | beforeEach(function() { 19 | container = TestContainer.get(this); 20 | }); 21 | 22 | 23 | describe('custom elements', function() { 24 | 25 | var modeler; 26 | 27 | // spin up modeler with custom element before each test 28 | beforeEach(function() { 29 | modeler = new CustomModeler({ container: container }); 30 | 31 | return modeler.importXML(diagramXML); 32 | }); 33 | 34 | 35 | it('should import custom element', function() { 36 | 37 | // given 38 | var elementRegistry = modeler.get('elementRegistry'), 39 | customElements = modeler.getCustomElements(); 40 | 41 | // when 42 | var customElement = { 43 | type: 'custom:triangle', 44 | id: 'CustomTriangle_1', 45 | x: 300, 46 | y: 200 47 | }; 48 | 49 | modeler.addCustomElements([ customElement ]); 50 | var customTriangle = elementRegistry.get('CustomTriangle_1'); 51 | 52 | // then 53 | expect(is(customTriangle, 'custom:triangle')).to.be.true; 54 | 55 | expect(customTriangle).to.exist; 56 | expect(customElements).to.contain(customElement); 57 | 58 | }); 59 | 60 | }); 61 | 62 | 63 | describe('custom connections', function() { 64 | 65 | var modeler; 66 | 67 | // spin up modeler with custom element before each test 68 | beforeEach(function() { 69 | modeler = new CustomModeler({ container: container }); 70 | 71 | return modeler.importXML(diagramXML).then(() => { 72 | modeler.addCustomElements([ { 73 | type: 'custom:triangle', 74 | id: 'CustomTriangle_1', 75 | x: 300, 76 | y: 200 77 | } ]); 78 | }); 79 | 80 | }); 81 | 82 | 83 | it('should import custom connection', function() { 84 | 85 | // given 86 | var elementRegistry = modeler.get('elementRegistry'); 87 | var customElements = modeler.getCustomElements(); 88 | 89 | // when 90 | var customElement = { 91 | type: 'custom:connection', 92 | id: 'CustomConnection_1', 93 | source: 'CustomTriangle_1', 94 | target: 'Task_1', 95 | waypoints: [ 96 | { x: 100, y: 100 }, 97 | { x: 200, y: 300 } 98 | ] 99 | }; 100 | 101 | modeler.addCustomElements([ customElement ]); 102 | var customConnection = elementRegistry.get('CustomConnection_1'); 103 | 104 | // then 105 | expect(customConnection).to.exist; 106 | expect(customElements).to.contain(customElement); 107 | 108 | }); 109 | 110 | }); 111 | 112 | }); 113 | -------------------------------------------------------------------------------- /test/spec/CustomModelingSpec.js: -------------------------------------------------------------------------------- 1 | import { 2 | bootstrapBpmnJS, 3 | inject 4 | } from '../TestHelper'; 5 | 6 | import { 7 | assign 8 | } from 'min-dash'; 9 | 10 | import CustomModeler from '../../app/custom-modeler'; 11 | 12 | import diagramXML from './diagram.bpmn'; 13 | 14 | 15 | describe('custom modeling', function() { 16 | 17 | beforeEach(bootstrapBpmnJS(CustomModeler, diagramXML)); 18 | 19 | 20 | describe('custom elements', function() { 21 | 22 | beforeEach(inject(function(bpmnjs) { 23 | 24 | var customShape = { 25 | type: 'custom:triangle', 26 | id: 'CustomTriangle_1', 27 | x: 300, 28 | y: 300 29 | }; 30 | 31 | bpmnjs.addCustomElements([ customShape ]); 32 | })); 33 | 34 | 35 | it('should export custom element', inject( 36 | function(bpmnjs, elementRegistry, modeling) { 37 | 38 | // given 39 | var customElement = { 40 | type: 'custom:circle', 41 | id: 'CustomCircle_1', 42 | x: 200, 43 | y: 400 44 | }; 45 | 46 | var position = { x: customElement.x, y: customElement.y }, 47 | target = elementRegistry.get('Process_1'); 48 | 49 | modeling.createShape( 50 | assign({ businessObject: customElement }, customElement), 51 | position, 52 | target 53 | ); 54 | 55 | // when 56 | var customElements = bpmnjs.getCustomElements(); 57 | 58 | // then 59 | expect(customElements).to.contain(customElement); 60 | } 61 | )); 62 | 63 | 64 | it('should not resize custom shape', inject(function(elementRegistry, rules) { 65 | 66 | // given 67 | var customElement = elementRegistry.get('CustomTriangle_1'); 68 | 69 | // when 70 | var allowed = rules.allowed('resize', { shape: customElement }); 71 | 72 | // then 73 | expect(allowed).to.be.false; 74 | })); 75 | 76 | 77 | it('should update custom element', inject(function(elementRegistry, modeling) { 78 | 79 | // given 80 | var customElement = elementRegistry.get('CustomTriangle_1'); 81 | 82 | // when 83 | modeling.moveShape(customElement, { x: 200, y: 50 }, customElement.parent); 84 | 85 | // then 86 | expect(customElement.businessObject.x).to.equal(500); 87 | expect(customElement.businessObject.y).to.equal(350); 88 | })); 89 | 90 | 91 | it('should remove deleted shape from _customElements', inject( 92 | function(bpmnjs, elementRegistry, modeling) { 93 | 94 | // given 95 | var customShape = elementRegistry.get('CustomTriangle_1'), 96 | customElements = bpmnjs.getCustomElements(); 97 | 98 | // when 99 | modeling.removeShape(customShape); 100 | 101 | // then 102 | expect(customElements.length).to.equal(0); 103 | } 104 | )); 105 | 106 | }); 107 | 108 | 109 | describe('custom connections', function() { 110 | 111 | beforeEach(inject(function(bpmnjs) { 112 | 113 | var customShape = { 114 | type: 'custom:triangle', 115 | id: 'CustomTriangle_1', 116 | x: 400, 117 | y: 300 118 | }; 119 | 120 | bpmnjs.addCustomElements([ customShape ]); 121 | })); 122 | 123 | 124 | it('should export custom connection', inject( 125 | function(bpmnjs, elementRegistry, modeling) { 126 | 127 | // given 128 | var customShape = elementRegistry.get('CustomTriangle_1'), 129 | taskShape = elementRegistry.get('Task_1'); 130 | 131 | modeling.connect(customShape, taskShape, { 132 | type: 'custom:connection', 133 | id: 'CustomConnection_1' 134 | }); 135 | 136 | // when 137 | var customElements = bpmnjs.getCustomElements(); 138 | 139 | // then 140 | var ids = customElements.map(function(element) { 141 | return element.id; 142 | }); 143 | 144 | expect(ids).to.include('CustomConnection_1'); 145 | } 146 | )); 147 | 148 | 149 | it('should connect custom shape to task', inject( 150 | function(bpmnjs, elementRegistry, modeling, rules) { 151 | 152 | // given 153 | var customShape = elementRegistry.get('CustomTriangle_1'), 154 | taskShape = elementRegistry.get('Task_1'); 155 | 156 | // when 157 | var allowedConnection = rules.allowed('connection.create', { 158 | source: customShape, 159 | target: taskShape 160 | }); 161 | 162 | modeling.connect( 163 | customShape, 164 | taskShape, 165 | allowedConnection 166 | ); 167 | 168 | // then 169 | expect(allowedConnection.type).to.eql('custom:connection'); 170 | 171 | expect(customShape.outgoing.length).to.equal(1); 172 | expect(taskShape.outgoing.length).to.equal(1); 173 | 174 | expect(bpmnjs.getCustomElements().length).to.equal(2); 175 | } 176 | )); 177 | 178 | 179 | it('should not connect custom shape to start event', inject( 180 | function(elementRegistry, rules) { 181 | 182 | // given 183 | var customShape = elementRegistry.get('CustomTriangle_1'), 184 | startEventShape = elementRegistry.get('StartEvent_1'); 185 | 186 | // when 187 | var allowed = rules.allowed('connection.create', { 188 | source: customShape, 189 | target: startEventShape 190 | }); 191 | 192 | // then 193 | expect(allowed).to.be.false; 194 | } 195 | )); 196 | 197 | 198 | it('should reconnect start', inject(function(bpmnjs, elementRegistry, modeling) { 199 | 200 | // given 201 | var customShape = elementRegistry.get('CustomTriangle_1'), 202 | taskShape = elementRegistry.get('Task_1'); 203 | 204 | var customConnection = modeling.connect(customShape, taskShape, { 205 | type: 'custom:connection' 206 | }); 207 | 208 | bpmnjs.addCustomElements([ { 209 | type: 'custom:circle', 210 | id: 'CustomCircle_1', 211 | x: 200, 212 | y: 300 213 | } ]); 214 | 215 | var customCircle = elementRegistry.get('CustomCircle_1'); 216 | 217 | // when 218 | modeling.reconnectStart(customConnection, customCircle, { 219 | x: customCircle.x + customCircle.width / 2, 220 | y: customCircle.y + customCircle.height / 2 221 | }); 222 | 223 | // then 224 | expect(customConnection.source).to.equal(customCircle); 225 | expect(customConnection.target).to.equal(taskShape); 226 | })); 227 | 228 | 229 | it('should reconnect end', inject(function(bpmnjs, elementRegistry, modeling) { 230 | 231 | // given 232 | var customShape = elementRegistry.get('CustomTriangle_1'), 233 | taskShape1 = elementRegistry.get('Task_1'), 234 | taskShape2 = elementRegistry.get('Task_2'); 235 | 236 | var customConnection = modeling.connect(customShape, taskShape1, { 237 | type: 'custom:connection' 238 | }); 239 | 240 | // when 241 | modeling.reconnectEnd(customConnection, taskShape2, { 242 | x: taskShape2.x + taskShape2.width / 2, 243 | y: taskShape2.y + taskShape2.height / 2 244 | }); 245 | 246 | // then 247 | expect(customConnection.source).to.equal(customShape); 248 | expect(customConnection.target).to.equal(taskShape2); 249 | })); 250 | 251 | 252 | it('should update custom connection', inject(function(elementRegistry, modeling) { 253 | 254 | // given 255 | var customElement = elementRegistry.get('CustomTriangle_1'), 256 | taskShape = elementRegistry.get('Task_1'); 257 | 258 | var customConnection = modeling.connect(customElement, taskShape, { 259 | type: 'custom:connection' 260 | }); 261 | 262 | // when 263 | modeling.moveShape(customElement, { x: 200, y: 50 }, customElement.parent); 264 | 265 | // then 266 | expect(customConnection.businessObject.waypoints).to.eql([ 267 | { x: 613, y: 364 }, 268 | { x: 354, y: 157 } 269 | ]); 270 | })); 271 | 272 | 273 | it('should remove deleted connection from _customElements', inject( 274 | function(bpmnjs, elementRegistry, modeling) { 275 | 276 | // given 277 | var customShape = elementRegistry.get('CustomTriangle_1'), 278 | taskShape = elementRegistry.get('Task_1'), 279 | customElements = bpmnjs.getCustomElements(); 280 | 281 | var customConnection = modeling.connect(customShape, taskShape, { 282 | type: 'custom:connection' 283 | }); 284 | 285 | // when 286 | modeling.removeConnection(customConnection); 287 | 288 | // then 289 | expect(customElements.length).to.equal(1); 290 | } 291 | )); 292 | 293 | }); 294 | 295 | }); 296 | -------------------------------------------------------------------------------- /test/spec/Modeling.collaboration.bpmn: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | OrderReceivedEvent 9 | _6-652 10 | _6-674 11 | CalmCustomerTask 12 | 13 | 14 | _6-463 15 | 16 | 17 | _6-514 18 | _6-565 19 | _6-616 20 | 21 | 22 | 23 | _6-630 24 | 25 | 26 | 27 | _6-630 28 | _6-691 29 | _6-693 30 | 31 | 32 | _6-691 33 | _6-746 34 | _6-748 35 | 36 | 37 | 38 | _6-748 39 | _6-746 40 | 41 | 42 | _6-693 43 | _6-632 44 | 45 | 46 | _6-632 47 | _6-634 48 | 49 | 50 | _6-634 51 | _6-636 52 | 53 | 54 | _6-636 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | _6-125 70 | 71 | 72 | _6-125 73 | _6-178 74 | 75 | 76 | _6-178 77 | _6-420 78 | 79 | 80 | _6-420 81 | _6-430 82 | _6-422 83 | _6-424 84 | 85 | 86 | _6-422 87 | _6-428 88 | 89 | 90 | 91 | _6-424 92 | _6-426 93 | 94 | 95 | 96 | 97 | 98 | _6-426 99 | _6-430 100 | 101 | 102 | _6-428 103 | _6-434 104 | 105 | 106 | _6-434 107 | _6-436 108 | 109 | 110 | _6-436 111 | 112 | 113 | 114 | 115 | 116 | 117 | 118 | 119 | 120 | 121 | 122 | 123 | 124 | 125 | 126 | 127 | 128 | 129 | 130 | 131 | 132 | 133 | 134 | 135 | 136 | 137 | 138 | 139 | 140 | 141 | 142 | 143 | 144 | 145 | 146 | 147 | 148 | 149 | 150 | 151 | 152 | 153 | 154 | 155 | 156 | 157 | 158 | 159 | 160 | 161 | 162 | 163 | 164 | 165 | 166 | 167 | 168 | 169 | 170 | 171 | 172 | 173 | 174 | 175 | 176 | 177 | 178 | 179 | 180 | 181 | 182 | 183 | 184 | 185 | 186 | 187 | 188 | 189 | 190 | 191 | 192 | 193 | 194 | 195 | 196 | 197 | 198 | 199 | 200 | 201 | 202 | 203 | 204 | 205 | 206 | 207 | 208 | 209 | 210 | 211 | 212 | 213 | 214 | 215 | 216 | 217 | 218 | 219 | 220 | 221 | 222 | 223 | 224 | 225 | 226 | 227 | 228 | 229 | 230 | 231 | 232 | 233 | 234 | 235 | 236 | 237 | 238 | 239 | 240 | 241 | 242 | 243 | 244 | 245 | 246 | 247 | 248 | 249 | 250 | 251 | 252 | 253 | 254 | 255 | 256 | 257 | 258 | 259 | 260 | 261 | 262 | 263 | 264 | 265 | 266 | 267 | 268 | 269 | 270 | 271 | 272 | 273 | 274 | 275 | 276 | 277 | 278 | 279 | 280 | 281 | 282 | 283 | 284 | 285 | 286 | 287 | 288 | 289 | 290 | 291 | 292 | 293 | 294 | 295 | 296 | 297 | 298 | 299 | 300 | 301 | 302 | 303 | 304 | 305 | 306 | 307 | 308 | 309 | 310 | 311 | 312 | 313 | 314 | 315 | 316 | 317 | 318 | 319 | 320 | 321 | 322 | 323 | 324 | 325 | 326 | 327 | 328 | 329 | 330 | 331 | 332 | 333 | 334 | 335 | 336 | 337 | 338 | 339 | 340 | 341 | 342 | 343 | 344 | 345 | 346 | 347 | 348 | 349 | 350 | 351 | 352 | 353 | 354 | 355 | 356 | 357 | 358 | 359 | 360 | 361 | 362 | 363 | 364 | 365 | 366 | 367 | 368 | 369 | 370 | 371 | 372 | 373 | 374 | 375 | 376 | 377 | 378 | 379 | 380 | 381 | 382 | 383 | 384 | -------------------------------------------------------------------------------- /test/spec/ModelingSpec.js: -------------------------------------------------------------------------------- 1 | import { 2 | bootstrapBpmnJS, 3 | inject 4 | } from '../TestHelper'; 5 | 6 | import CustomModeler from '../../app/custom-modeler'; 7 | 8 | import diagramXML from './Modeling.collaboration.bpmn'; 9 | 10 | 11 | describe('modeling', function() { 12 | 13 | describe('collaboration', function() { 14 | 15 | beforeEach(bootstrapBpmnJS(CustomModeler, diagramXML)); 16 | 17 | 18 | describe('removing participants', function() { 19 | 20 | beforeEach(inject(function(bpmnjs) { 21 | 22 | var customShape = { 23 | type: 'custom:triangle', 24 | id: 'CustomTriangle_1', 25 | x: 300, 26 | y: 300 27 | }; 28 | 29 | bpmnjs.addCustomElements([ customShape ]); 30 | })); 31 | 32 | 33 | it('should update parent', inject(function(elementRegistry, canvas, modeling) { 34 | 35 | // given 36 | var customTriangle = elementRegistry.get('CustomTriangle_1'); 37 | 38 | // when 39 | modeling.removeElements([ 40 | elementRegistry.get('_6-53'), 41 | elementRegistry.get('_6-438') 42 | ]); 43 | 44 | // then 45 | expect(customTriangle.parent).to.eql(canvas.getRootElement()); 46 | })); 47 | 48 | }); 49 | 50 | }); 51 | 52 | }); 53 | -------------------------------------------------------------------------------- /test/spec/diagram.bpmn: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | SequenceFlow_1 6 | 7 | 8 | SequenceFlow_1 9 | SequenceFlow_1kp6wet 10 | 11 | 12 | 13 | SequenceFlow_1o6tfcd 14 | 15 | 16 | SequenceFlow_1kp6wet 17 | SequenceFlow_1o6tfcd 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | -------------------------------------------------------------------------------- /webpack.config.js: -------------------------------------------------------------------------------- 1 | /* eslint-env node */ 2 | 3 | const CopyPlugin = require('copy-webpack-plugin'); 4 | 5 | const path = require('path'); 6 | 7 | const basePath = '.'; 8 | 9 | const absoluteBasePath = path.resolve(path.join(__dirname, basePath)); 10 | 11 | module.exports = { 12 | mode: 'development', 13 | entry: './app/index.js', 14 | output: { 15 | path: path.resolve(__dirname, 'public'), 16 | filename: 'index.js' 17 | }, 18 | devtool: 'source-map', 19 | module: { 20 | rules: [ 21 | { 22 | test: /\.less$/i, 23 | use: [ 24 | 25 | // compiles Less to CSS 26 | 'style-loader', 27 | 'css-loader', 28 | 'less-loader', 29 | ], 30 | }, 31 | { 32 | test: /\.bpmn$/, 33 | type: 'asset/source' 34 | } 35 | ] 36 | }, 37 | resolve: { 38 | mainFields: [ 39 | 'browser', 40 | 'module', 41 | 'main' 42 | ], 43 | modules: [ 44 | 'node_modules', 45 | absoluteBasePath 46 | ] 47 | }, 48 | plugins: [ 49 | new CopyPlugin({ 50 | patterns: [ 51 | { from: 'app/index.html', to: '.' }, 52 | { from: 'node_modules/bpmn-js/dist/assets', to: 'vendor/bpmn-js/assets' } 53 | ] 54 | }) 55 | ] 56 | }; --------------------------------------------------------------------------------