├── .gitignore
├── docs
└── screenshot.png
├── renovate.json
├── .github
└── workflows
│ └── CI.yml
├── app
├── index.js
├── custom-modeler
│ ├── custom
│ │ ├── CustomOrderingProvider.js
│ │ ├── index.js
│ │ ├── CustomContextPadProvider.js
│ │ ├── CustomRules.js
│ │ ├── CustomPalette.js
│ │ ├── CustomElementFactory.js
│ │ ├── CustomUpdater.js
│ │ └── CustomRenderer.js
│ └── index.js
├── index.html
└── custom-elements.json
├── eslint.config.mjs
├── test
├── TestHelper.js
└── spec
│ ├── ModelingSpec.js
│ ├── CustomModelerSpec.js
│ ├── diagram.bpmn
│ ├── CustomModelingSpec.js
│ └── Modeling.collaboration.bpmn
├── webpack.config.js
├── karma.conf.js
├── package.json
├── README.md
└── resources
└── pizza-collaboration.bpmn
/.gitignore:
--------------------------------------------------------------------------------
1 | .DS_Store
2 | node_modules
3 | public
--------------------------------------------------------------------------------
/docs/screenshot.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/bpmn-io/bpmn-js-example-custom-shapes/HEAD/docs/screenshot.png
--------------------------------------------------------------------------------
/renovate.json:
--------------------------------------------------------------------------------
1 | {
2 | "$schema": "https://docs.renovatebot.com/renovate-schema.json",
3 | "extends": [
4 | "github>bpmn-io/renovate-config:recommended"
5 | ]
6 | }
7 |
--------------------------------------------------------------------------------
/.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@v6
15 | - name: Use Node.js
16 | uses: actions/setup-node@v6
17 | with:
18 | node-version: 24
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
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 | ];
--------------------------------------------------------------------------------
/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/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 | };
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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/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 | ];
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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