├── .gitignore ├── resources └── screenshot.png ├── lib ├── index.js └── Minimap.js ├── renovate.json ├── test ├── spec │ ├── marker-renderer │ │ ├── index.js │ │ └── MarkerRenderer.js │ └── MinimapSpec.js ├── distro │ ├── distroSpec.js │ └── karma.conf.js └── TestHelper.js ├── .github └── workflows │ └── CI.yml ├── karma.conf.js ├── eslint.config.mjs ├── LICENSE ├── README.md ├── rollup.config.js ├── assets └── diagram-js-minimap.css ├── package.json └── CHANGELOG.md /.gitignore: -------------------------------------------------------------------------------- 1 | /node_modules/ 2 | dist/ -------------------------------------------------------------------------------- /resources/screenshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bpmn-io/diagram-js-minimap/HEAD/resources/screenshot.png -------------------------------------------------------------------------------- /lib/index.js: -------------------------------------------------------------------------------- 1 | import Minimap from './Minimap'; 2 | 3 | export default { 4 | __init__: [ 'minimap' ], 5 | minimap: [ 'type', Minimap ] 6 | }; 7 | -------------------------------------------------------------------------------- /renovate.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://docs.renovatebot.com/renovate-schema.json", 3 | "extends": [ 4 | "github>bpmn-io/renovate-config:recommended" 5 | ] 6 | } 7 | -------------------------------------------------------------------------------- /test/spec/marker-renderer/index.js: -------------------------------------------------------------------------------- 1 | import MarkerRenderer from './MarkerRenderer'; 2 | 3 | export default { 4 | __init__: [ 'defaultRenderer' ], 5 | defaultRenderer: [ 'type', MarkerRenderer ] 6 | }; -------------------------------------------------------------------------------- /test/distro/distroSpec.js: -------------------------------------------------------------------------------- 1 | describe('distro', function() { 2 | 3 | it('should expose CJS bundle', function() { 4 | const DiagramJSMinimap = require('../..'); 5 | 6 | expect(DiagramJSMinimap).to.exist; 7 | }); 8 | 9 | 10 | it('should expose UMD bundle', function() { 11 | const DiagramJSMinimap = require('../../dist/diagram-minimap.umd.js'); 12 | 13 | expect(DiagramJSMinimap).to.exist; 14 | }); 15 | 16 | }); 17 | -------------------------------------------------------------------------------- /test/TestHelper.js: -------------------------------------------------------------------------------- 1 | import semver from 'semver'; 2 | 3 | export * from 'diagram-js/test/helper'; 4 | 5 | /** 6 | * Execute test only if currently installed bpmn-js is of given version. 7 | * 8 | * @param {string} versionRange 9 | * @param {boolean} only 10 | */ 11 | export function withDiagramJs(versionRange, only) { 12 | if (diagramJsSatisfies(versionRange)) { 13 | return only ? it.only : it; 14 | } else { 15 | return it.skip; 16 | } 17 | } 18 | 19 | function diagramJsSatisfies(versionRange) { 20 | var bpmnJsVersion = require('diagram-js/package.json').version; 21 | 22 | return semver.satisfies(bpmnJsVersion, versionRange, { includePrerelease: true }); 23 | } 24 | 25 | -------------------------------------------------------------------------------- /test/distro/karma.conf.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | // configures browsers to run test against 4 | // any of [ 'ChromeHeadless', 'Chrome', 'Firefox' ] 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 | module.exports = function(karma) { 11 | karma.set({ 12 | 13 | frameworks: [ 14 | 'webpack', 15 | 'mocha', 16 | 'sinon-chai' 17 | ], 18 | 19 | files: [ '*Spec.js' ], 20 | 21 | preprocessors: { 22 | '*Spec.js': [ 'webpack' ] 23 | }, 24 | 25 | browsers, 26 | 27 | autoWatch: false, 28 | singleRun: true, 29 | 30 | webpack: { 31 | mode: 'development' 32 | } 33 | }); 34 | }; 35 | -------------------------------------------------------------------------------- /.github/workflows/CI.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | on: [push, pull_request] 3 | jobs: 4 | Build: 5 | strategy: 6 | matrix: 7 | os: [ubuntu-latest] 8 | node-version: [ 20 ] 9 | integration-deps: 10 | - "" # as defined in package.json 11 | - diagram-js@8.x 12 | - diagram-js@7.x 13 | 14 | runs-on: ${{ matrix.os }} 15 | 16 | steps: 17 | - name: Checkout 18 | uses: actions/checkout@v6 19 | - name: Use Node.js 20 | uses: actions/setup-node@v6 21 | with: 22 | node-version: ${{ matrix.node-version }} 23 | cache: 'npm' 24 | - name: Install dependencies 25 | run: npm ci 26 | - name: Install dependencies for integration test 27 | if: ${{ matrix.integration-deps != '' }} 28 | run: npm install ${{ matrix.integration-deps }} 29 | - name: Setup project 30 | uses: bpmn-io/actions/setup@latest 31 | - name: Build 32 | run: npm run all 33 | -------------------------------------------------------------------------------- /karma.conf.js: -------------------------------------------------------------------------------- 1 | /* eslint-env node */ 2 | 3 | 'use strict'; 4 | 5 | // configures browsers to run test against 6 | // any of [ 'ChromeHeadless', 'Chrome', 'Firefox' ] 7 | var browsers = (process.env.TEST_BROWSERS || 'ChromeHeadless').split(','); 8 | 9 | // use puppeteer provided Chrome for testing 10 | process.env.CHROME_BIN = require('puppeteer').executablePath(); 11 | 12 | module.exports = function(karma) { 13 | karma.set({ 14 | 15 | frameworks: [ 16 | 'mocha', 17 | 'sinon-chai', 18 | 'webpack' 19 | ], 20 | 21 | files: [ 22 | 'test/spec/*Spec.js' 23 | ], 24 | 25 | preprocessors: { 26 | 'test/spec/*Spec.js': [ 'webpack' ] 27 | }, 28 | 29 | browsers, 30 | 31 | autoWatch: false, 32 | singleRun: true, 33 | 34 | webpack: { 35 | mode: 'development', 36 | module: { 37 | rules: [ 38 | { 39 | test: /\.css$/, 40 | type: 'asset/source' 41 | } 42 | ] 43 | } 44 | } 45 | }); 46 | }; 47 | -------------------------------------------------------------------------------- /eslint.config.mjs: -------------------------------------------------------------------------------- 1 | import bpmnIoPlugin from 'eslint-plugin-bpmn-io'; 2 | 3 | const files = { 4 | build: [ 5 | '*.js', 6 | '*.mjs', 7 | 'test/distro/karma.conf.js' 8 | ], 9 | test: [ 10 | 'test/**/*.js' 11 | ], 12 | ignored: [ 13 | 'dist' 14 | ] 15 | }; 16 | 17 | export default [ 18 | { 19 | ignores: files.ignored 20 | }, 21 | 22 | // build 23 | ...bpmnIoPlugin.configs.node.map(config => { 24 | return { 25 | ...config, 26 | files: files.build 27 | }; 28 | }), 29 | 30 | // lib + test 31 | ...bpmnIoPlugin.configs.browser.map(config => { 32 | return { 33 | ...config, 34 | ignores: files.build 35 | }; 36 | }), 37 | 38 | // test 39 | ...bpmnIoPlugin.configs.mocha.map(config => { 40 | return { 41 | ...config, 42 | files: files.test 43 | }; 44 | }), 45 | { 46 | languageOptions: { 47 | globals: { 48 | sinon: true, 49 | require: true 50 | }, 51 | }, 52 | files: files.test 53 | } 54 | ]; 55 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2017-present camunda Services GmbH 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # diagram-js Minimap 2 | 3 | [![Build Status](https://github.com/bpmn-io/diagram-js-minimap/actions/workflows/CI.yml/badge.svg)](https://github.com/bpmn-io/diagram-js-minimap/actions/workflows/CI.yml) 4 | 5 | A minimap for diagram-js. 6 | 7 | ![Minimap](resources/screenshot.png) 8 | 9 | 10 | ## Features 11 | 12 | * See the whole diagram in the minimap 13 | * Highlight current viewport 14 | * Click/drag/scroll the minimap to navigate the diagram 15 | 16 | 17 | ## Usage 18 | 19 | Extend your diagram-js application with the minimap module. We'll use [bpmn-js](https://github.com/bpmn-io/bpmn-js) as an example: 20 | 21 | ```javascript 22 | import BpmnModeler from 'bpmn-js/lib/Modeler'; 23 | 24 | import minimapModule from 'diagram-js-minimap'; 25 | 26 | var bpmnModeler = new BpmnModeler({ 27 | additionalModules: [ 28 | minimapModule 29 | ] 30 | }); 31 | ``` 32 | 33 | For proper styling integrate the embedded style sheet: 34 | 35 | ```html 36 | 37 | ``` 38 | 39 | Please see [this example](https://github.com/bpmn-io/bpmn-js-examples/tree/master/minimap) for a more detailed instruction. 40 | 41 | 42 | ## License 43 | 44 | MIT 45 | -------------------------------------------------------------------------------- /rollup.config.js: -------------------------------------------------------------------------------- 1 | import terser from '@rollup/plugin-terser'; 2 | import resolve from '@rollup/plugin-node-resolve'; 3 | import commonjs from '@rollup/plugin-commonjs'; 4 | 5 | import pkg from './package.json'; 6 | 7 | const srcEntry = pkg.source; 8 | const umdDist = pkg[ 'umd:main' ]; 9 | const umdName = 'DiagramJSMinimap'; 10 | 11 | function pgl(plugins = []) { 12 | return plugins; 13 | } 14 | 15 | export default [ 16 | 17 | // browser-friendly UMD build 18 | { 19 | input: srcEntry, 20 | output: { 21 | file: umdDist.replace(/\.js$/, '.prod.js'), 22 | format: 'umd', 23 | name: umdName 24 | }, 25 | plugins: pgl([ 26 | resolve(), 27 | commonjs(), 28 | terser() 29 | ]) 30 | }, 31 | { 32 | input: srcEntry, 33 | output: { 34 | file: umdDist, 35 | format: 'umd', 36 | name: umdName 37 | }, 38 | plugins: pgl([ 39 | resolve(), 40 | commonjs() 41 | ]) 42 | }, 43 | { 44 | input: srcEntry, 45 | output: [ 46 | { file: pkg.main, format: 'cjs', exports: 'default' }, 47 | { file: pkg.module, format: 'es', exports: 'default' } 48 | ], 49 | external: [ 50 | 'diagram-js', 51 | /diagram-js(\/[\w-]+)+/, 52 | 'hammerjs', 53 | 'min-dash', 54 | 'min-dom', 55 | 'tiny-svg' 56 | ], 57 | plugins: pgl() 58 | } 59 | ]; 60 | -------------------------------------------------------------------------------- /assets/diagram-js-minimap.css: -------------------------------------------------------------------------------- 1 | .djs-minimap { 2 | position: absolute; 3 | top: 20px; 4 | right: 20px; 5 | overflow: hidden; 6 | background-color: rgba(255, 255, 255, 0.9); 7 | border: solid 1px #CCC; 8 | border-radius: 2px; 9 | box-sizing: border-box; 10 | user-select: none; 11 | -moz-user-select: none; 12 | -ms-user-select: none; 13 | -webkit-user-select: none; 14 | } 15 | 16 | .djs-minimap:not(.open) { 17 | overflow: hidden; 18 | } 19 | 20 | .djs-minimap .map { 21 | display: none; 22 | } 23 | 24 | .djs-minimap.open .map { 25 | display: block; 26 | } 27 | 28 | .djs-minimap .map { 29 | width: 320px; 30 | height: 180px; 31 | } 32 | 33 | .djs-minimap:not(.open) .toggle { 34 | padding: 10px; 35 | text-align: center; 36 | } 37 | 38 | .djs-minimap .toggle:before { 39 | content: attr(title); 40 | } 41 | 42 | .djs-minimap.open .toggle { 43 | position: absolute; 44 | right: 0; 45 | padding: 6px; 46 | z-index: 1; 47 | } 48 | 49 | .djs-minimap .map { 50 | cursor: crosshair; 51 | } 52 | 53 | .djs-minimap .viewport { 54 | /* fill: rgba(255, 116, 0, 0.25); */ 55 | fill: none; 56 | stroke: none; 57 | } 58 | 59 | .djs-minimap .viewport-dom { 60 | position: absolute; 61 | border: solid 2px orange; 62 | border-radius: 2px; 63 | box-sizing: border-box; 64 | cursor: move; 65 | } 66 | 67 | .djs-minimap:not(.open) .viewport-dom { 68 | display: none; 69 | } 70 | 71 | .djs-minimap.open .overlay { 72 | position: absolute; 73 | top: 0; 74 | right: 0; 75 | bottom: 0; 76 | left: 0; 77 | background: rgba(255, 255, 255, 0.2); 78 | pointer-events: none; 79 | } 80 | 81 | .djs-minimap .cursor-crosshair { 82 | cursor: crosshair; 83 | } 84 | 85 | .djs-minimap .cursor-move { 86 | cursor: move; 87 | } 88 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "diagram-js-minimap", 3 | "version": "5.2.0", 4 | "description": "A minimap for diagram-js", 5 | "main": "dist/index.js", 6 | "module": "dist/index.esm.js", 7 | "umd:main": "dist/diagram-minimap.umd.js", 8 | "source": "lib/index.js", 9 | "scripts": { 10 | "all": "run-s lint test distro", 11 | "lint": "eslint .", 12 | "dev": "npm test -- --auto-watch --no-single-run", 13 | "test": "karma start", 14 | "distro": "run-s build test:build", 15 | "build": "rollup -c --bundleConfigAsCjs", 16 | "build:watch": "run-s bundle -- -w", 17 | "test:build": "karma start test/distro/karma.conf.js", 18 | "prepublishOnly": "run-s distro" 19 | }, 20 | "repository": { 21 | "type": "git", 22 | "url": "git@github.com:bpmn-io/diagram-js-minimap.git" 23 | }, 24 | "keywords": [ 25 | "diagram-js", 26 | "minimap" 27 | ], 28 | "author": "Philipp Fromme", 29 | "license": "MIT", 30 | "devDependencies": { 31 | "@rollup/plugin-commonjs": "^29.0.0", 32 | "@rollup/plugin-node-resolve": "^16.0.0", 33 | "@rollup/plugin-terser": "^0.4.4", 34 | "chai": "^4.4.1", 35 | "diagram-js": "^15.0.0", 36 | "eslint": "^9.0.0", 37 | "eslint-plugin-bpmn-io": "^2.0.1", 38 | "inherits-browser": "^0.1.0", 39 | "karma": "^6.4.3", 40 | "karma-chrome-launcher": "^3.2.0", 41 | "karma-firefox-launcher": "^2.1.3", 42 | "karma-mocha": "^2.0.1", 43 | "karma-sinon-chai": "^2.0.2", 44 | "karma-webpack": "^5.0.1", 45 | "mocha": "^10.4.0", 46 | "mocha-test-container-support": "^0.2.0", 47 | "npm-run-all2": "^8.0.0", 48 | "puppeteer": "^24.0.0", 49 | "rollup": "^4.9.4", 50 | "sinon": "^17.0.1", 51 | "sinon-chai": "^3.7.0", 52 | "webpack": "^5.91.0" 53 | }, 54 | "dependencies": { 55 | "min-dash": "^4.2.1", 56 | "min-dom": "^4.2.1", 57 | "tiny-svg": "^3.1.2" 58 | }, 59 | "files": [ 60 | "dist", 61 | "assets" 62 | ] 63 | } 64 | -------------------------------------------------------------------------------- /test/spec/marker-renderer/MarkerRenderer.js: -------------------------------------------------------------------------------- 1 | import inherits from 'inherits-browser'; 2 | 3 | import { 4 | append as svgAppend, 5 | attr as svgAttr, 6 | create as svgCreate 7 | } from 'tiny-svg'; 8 | 9 | import { 10 | query as domQuery 11 | } from 'min-dom'; 12 | 13 | import DefaultRenderer from 'diagram-js/lib/draw/DefaultRenderer'; 14 | 15 | import { 16 | createLine 17 | } from 'diagram-js/lib/util/RenderUtil'; 18 | 19 | /** 20 | * @typedef {import('../../model').Connection} Connection 21 | */ 22 | 23 | var HIGH_PRIORITY = 3000; 24 | 25 | var CONNECTION_STYLE = { 26 | fill: 'none', 27 | stroke: 'fuchsia', 28 | strokeWidth: 5 29 | }; 30 | 31 | var MARKER_TYPES = [ 32 | 'marker-start', 33 | 'marker-mid', 34 | 'marker-end' 35 | ]; 36 | 37 | /** 38 | * A renderer that can render markers. 39 | */ 40 | export default function MarkerRenderer(canvas, eventBus, styles) { 41 | DefaultRenderer.call(this, eventBus, styles, HIGH_PRIORITY); 42 | 43 | this._canvas = canvas; 44 | } 45 | 46 | inherits(MarkerRenderer, DefaultRenderer); 47 | 48 | MarkerRenderer.$inject = [ 49 | 'canvas', 50 | 'eventBus', 51 | 'styles' 52 | ]; 53 | 54 | MarkerRenderer.prototype.canRender = function() { 55 | return true; 56 | }; 57 | 58 | MarkerRenderer.prototype.drawConnection = function(parentGfx, connection) { 59 | var line = createLine(connection.waypoints, CONNECTION_STYLE); 60 | 61 | svgAppend(parentGfx, line); 62 | 63 | var self = this; 64 | 65 | MARKER_TYPES.forEach(function(markerType) { 66 | if (hasMarker(connection, markerType)) { 67 | self.addMarker(parentGfx, line, markerType, connection.id); 68 | } 69 | }); 70 | 71 | return line; 72 | }; 73 | 74 | MarkerRenderer.prototype.addMarker = function(parentGfx, gfx, markerType, id) { 75 | var defs, marker; 76 | parentGfx = parentGfx || this._canvas._svg; 77 | 78 | marker = svgCreate('marker'); 79 | 80 | marker.id = markerType + '-' + id; 81 | 82 | svgAttr(marker, { 83 | refX: 5, 84 | refY: 5, 85 | viewBox: '0 0 10 10' 86 | }); 87 | 88 | var circle = svgCreate('circle'); 89 | 90 | svgAttr(circle, { 91 | cx: 5, 92 | cy: 5, 93 | fill: 'fuchsia', 94 | r: 5 95 | }); 96 | 97 | svgAppend(marker, circle); 98 | 99 | defs = domQuery(':scope > defs', parentGfx); 100 | 101 | if (!defs) { 102 | defs = svgCreate('defs'); 103 | 104 | svgAppend(parentGfx, defs); 105 | } 106 | 107 | svgAppend(defs, marker); 108 | 109 | var reference = idToReference(marker.id); 110 | 111 | svgAttr(gfx, markerType, reference); 112 | }; 113 | 114 | // helpers ////////// 115 | 116 | /** 117 | * Get functional IRI reference for given ID of fragment within current document. 118 | * 119 | * @param {string} id 120 | * 121 | * @return {string} 122 | */ 123 | function idToReference(id) { 124 | return 'url(#' + id + ')'; 125 | } 126 | 127 | /** 128 | * Check wether given connection has marker of given type. 129 | * 130 | * @param {Connection} connection 131 | * @param {string} markerType 132 | * 133 | * @return {boolean} 134 | */ 135 | function hasMarker(connection, markerType) { 136 | return connection.marker && connection.marker[ markerType.split('-').pop() ]; 137 | } -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | All notable changes to [diagram-js-minimap](https://github.com/bpmn-io/diagram-js-minimap) are documented here. We use [semantic versioning](http://semver.org/) for releases. 4 | 5 | ## Unreleased 6 | 7 | ___Note:__ Yet to be released changes appear here._ 8 | 9 | ## 5.2.0 10 | 11 | * `FEAT`: support `diagram-js@15.1.0` 12 | 13 | ## 5.1.0 14 | 15 | * `FEAT`: support multiple diagram-js-instances on the same page 16 | * `FIX`: do not copy elements with IDs 17 | * `CHORE`: prefix svg graphic IDs 18 | 19 | ## 5.0.0 20 | 21 | * `CHORE`: drop hammerjs dependency ([#73](https://github.com/bpmn-io/diagram-js-minimap/issues/73)) 22 | 23 | ### Breaking Change 24 | 25 | * Following [`diagram-js@14`](https://github.com/bpmn-io/diagram-js/blob/develop/CHANGELOG.md#1400) this release drops hammerjs and broken touch support. 26 | 27 | ## 4.1.0 28 | 29 | * `CHORE`: bump dependencies 30 | * `CHORE`: bump rollup 31 | * `CHORE`: use diagram-js facilities for CSS escaping 32 | 33 | ## 4.0.1 34 | 35 | * `DEPS`: make `hammerjs` regular dependency 36 | 37 | ## 4.0.0 38 | 39 | * `FEAT`: minimap works with touch ([#54](https://github.com/bpmn-io/diagram-js-minimap/pull/54)) 40 | * `DEPS`: add `hammerjs` peer dependency 41 | 42 | ## 3.0.0 43 | 44 | * `DEPS`: update to diagram-js@9 45 | 46 | ## 2.1.1 47 | 48 | * `FIX`: ensure backwards compatibility with `diagram-js@7` 49 | 50 | ## 2.1.0 51 | 52 | * `FEAT`: support multi-plane diagrams ([#46](https://github.com/bpmn-io/diagram-js-minimap/pull/46)), 53 | * `DEPS`: bump `diagram-js` to 8.1.1 54 | 55 | ## 2.0.4 56 | 57 | * `FIX`: translate toggle button content ([#43](https://github.com/bpmn-io/diagram-js-minimap/issues/43)) 58 | 59 | ## 2.0.3 60 | 61 | Re-released `v2.0.2`. 62 | 63 | ## 2.0.2 64 | 65 | * `FIX`: do not log graphics not found ([#38](https://github.com/bpmn-io/diagram-js-minimap/issues/38)) 66 | 67 | ## 2.0.1 68 | 69 | * `FIX`: prevent minimap from crashing on mouse move ([#36](https://github.com/bpmn-io/diagram-js-minimap/issues/36)) 70 | 71 | ## 2.0.0 72 | 73 | * `CHORE`: provide pre-packaged distribution 74 | * `CHORE`: bump to `diagram-js@4` 75 | * `FIX`: only update viewbox on valid bounds 76 | 77 | ## 1.3.0 78 | 79 | * `CHORE`: bump to `diagram-js@3` 80 | 81 | ## 1.2.2 82 | 83 | __Republish with updated changelog.__ 84 | 85 | ## 1.2.0 86 | 87 | * `FEAT`: zoom on CTRL key only ([`a1848cf8`](https://github.com/bpmn-io/diagram-js-minimap/commit/a1848cf880478a74fb799422780df10f7e6d7d8f)) 88 | * `FEAT`: center & drag on SVG mouse down ([`5585f871`](https://github.com/bpmn-io/diagram-js-minimap/commit/5585f871933f6ec39d964907d6ab1a33d176cf8f)) 89 | * `FIX`: change title attribute depending on open/closed ([`5bc0e04a`](https://github.com/bpmn-io/diagram-js-minimap/commit/5bc0e04aedefb46f867b734aa9a303db3ea6c0b7)) 90 | 91 | ## 1.1.2 92 | 93 | * `FIX`: use `svgClasses` for IE 11 compatibility ([#25](https://github.com/bpmn-io/diagram-js-minimap/issues/25)) 94 | 95 | ## 1.1.1 96 | 97 | * `FIX`: export `Minimap` as ES module 98 | 99 | ## 1.1.0 100 | 101 | * `FEAT`: align minimap to canvas (0, 0) if possible ([#17](https://github.com/bpmn-io/diagram-js-minimap/issues/17)) 102 | * `FIX`: make close handle always clickable ([#18](https://github.com/bpmn-io/diagram-js-minimap/issues/18)) 103 | * `FIX`: correct stepping when zooming out ([#19](https://github.com/bpmn-io/diagram-js-minimap/issues/19)) 104 | * `FIX`: use same zoom directions like diagram-js `ZoomScroll` 105 | 106 | ## 1.0.0 107 | 108 | ### Breaking Changes 109 | 110 | * `CHORE`: migrate to ES modules 111 | 112 | ### Other Improvements 113 | 114 | * `FEAT`: improved minimap UX ([#4](https://github.com/bpmn-io/diagram-js-minimap/issues/4)) 115 | * `FEAT`: add more intuitive open / close controls ([#5](https://github.com/bpmn-io/diagram-js-minimap/issues/5)) 116 | * `FIX`: disallow minimap zoom outside of minimap ([`153093be`](https://github.com/bpmn-io/diagram-js-minimap/commit/153093be7f9b3999d2b2653613db427aecb83687)) 117 | * `FIX`: ignore canvas.resized events if not present in DOM ([`24614f86`](https://github.com/bpmn-io/diagram-js-minimap/commit/24614f86856a7e1b75950ffbb1a96d2d11541b5c)) 118 | * `FIX`: correct wheel / click interaction ([#12](https://github.com/bpmn-io/diagram-js-minimap/issues/12)) 119 | * `FIX`: properly cleanup global event listeners ([#16](https://github.com/bpmn-io/diagram-js-minimap/issues/16)) 120 | 121 | ## ... 122 | 123 | Check `git log` for earlier history. -------------------------------------------------------------------------------- /test/spec/MinimapSpec.js: -------------------------------------------------------------------------------- 1 | import { 2 | attr as svgAttr, 3 | remove as domRemove, 4 | query as domQuery, 5 | queryAll as domQueryAll 6 | } from 'min-dom'; 7 | 8 | import Diagram from 'diagram-js'; 9 | 10 | import { 11 | bootstrapDiagram, 12 | getDiagramJS, 13 | inject, 14 | insertCSS, 15 | withDiagramJs 16 | } from '../TestHelper'; 17 | 18 | import minimapModule from '../../lib'; 19 | import customRendererModule from './marker-renderer'; 20 | 21 | import modelingModule from 'diagram-js/lib/features/modeling'; 22 | import moveCanvasModule from 'diagram-js/lib/navigation/movecanvas'; 23 | import moveModule from 'diagram-js/lib/features/move'; 24 | import zoomScrollModule from 'diagram-js/lib/navigation/zoomscroll'; 25 | 26 | import minimapCSS from '../../assets/diagram-js-minimap.css'; 27 | 28 | insertCSS('diagram-js-minimap.css', minimapCSS); 29 | 30 | var viewerModules = [ 31 | minimapModule, 32 | moveCanvasModule, 33 | zoomScrollModule, 34 | customRendererModule 35 | ]; 36 | 37 | var modelerModules = viewerModules.concat([ 38 | modelingModule, 39 | moveModule 40 | ]); 41 | 42 | 43 | describe('minimap', function() { 44 | 45 | this.timeout(20000); 46 | 47 | 48 | describe('viewer', function() { 49 | 50 | beforeEach(bootstrapDiagram({ 51 | modules: viewerModules, 52 | minimap: { 53 | open: true 54 | } 55 | })); 56 | 57 | 58 | it('should show', inject(function(canvas, elementFactory) { 59 | 60 | // when 61 | var shapeA = elementFactory.createShape({ 62 | id: 'A', 63 | width: 100, 64 | height: 300, 65 | x: 50, 66 | y: 150 67 | }); 68 | 69 | canvas.addShape(shapeA, canvas.getRootElement()); 70 | 71 | 72 | var shapeB = elementFactory.createShape({ 73 | id: 'B', 74 | width: 50, 75 | height: 50, 76 | x: 775, 77 | y: 1175 78 | }); 79 | 80 | canvas.addShape(shapeB, canvas.getRootElement()); 81 | 82 | 83 | var shapeC = elementFactory.createShape({ 84 | id: 'C', 85 | width: 300, 86 | height: 300, 87 | x: 650, 88 | y: -50 89 | }); 90 | 91 | canvas.addShape(shapeC, canvas.getRootElement()); 92 | 93 | // then 94 | expectMinimapShapeToExist('A'); 95 | expectMinimapShapeToExist('B'); 96 | expectMinimapShapeToExist('C'); 97 | })); 98 | 99 | 100 | it('should show single element', inject(function(canvas, elementFactory) { 101 | 102 | // when 103 | var shapeA = elementFactory.createShape({ 104 | id: 'A', 105 | width: 100, 106 | height: 300, 107 | x: 50, 108 | y: 150 109 | }); 110 | 111 | canvas.addShape(shapeA, canvas.getRootElement()); 112 | 113 | // then 114 | expectMinimapShapeToExist('A'); 115 | })); 116 | 117 | 118 | it('should remove elements with ID', inject(function(canvas, elementFactory, minimap) { 119 | 120 | // when 121 | var shapeA = elementFactory.createShape({ 122 | id: 'A', 123 | width: 100, 124 | height: 300, 125 | x: 50, 126 | y: 150 127 | }); 128 | canvas.addShape(shapeA, canvas.getRootElement()); 129 | 130 | var shapeB = elementFactory.createShape({ 131 | id: 'B', 132 | width: 50, 133 | height: 50, 134 | x: 775, 135 | y: 1175 136 | }); 137 | 138 | canvas.addShape(shapeB, canvas.getRootElement()); 139 | 140 | var connection = elementFactory.createConnection({ 141 | id: 'connection', 142 | source: shapeA, 143 | target: shapeB, 144 | waypoints: [ 145 | { x: 900, y: 900 }, 146 | { x: 1000, y: 1000 }, 147 | { x: 1100, y: 1100 } 148 | ], 149 | marker: { 150 | start: true, 151 | mid: true, 152 | end: true 153 | } 154 | }); 155 | 156 | canvas.addConnection(connection, canvas.getRootElement()); 157 | 158 | // then 159 | expectMinimapConnectionToExist('connection'); 160 | 161 | const elementsWithId = domQueryAll('marker[id]', canvas._svg); 162 | expect(elementsWithId).to.have.length(3); 163 | 164 | const minimapElementsWithId = domQueryAll('marker[id]', minimap._parent); 165 | expect(minimapElementsWithId).to.have.length(0); 166 | })); 167 | 168 | }); 169 | 170 | 171 | describe('modeler', function() { 172 | 173 | beforeEach(bootstrapDiagram({ 174 | modules: modelerModules, 175 | minimap: { 176 | open: true 177 | } 178 | })); 179 | 180 | 181 | it('should show', inject(function(canvas, modeling, elementFactory) { 182 | 183 | // when 184 | var shapeA = elementFactory.createShape({ 185 | id: 'A', 186 | width: 100, 187 | height: 300 188 | }); 189 | 190 | modeling.createShape(shapeA, { x: 100, y: 300 }, canvas.getRootElement()); 191 | 192 | 193 | var shapeB = elementFactory.createShape({ 194 | id: 'B', 195 | width: 50, 196 | height: 50 197 | }); 198 | 199 | modeling.createShape(shapeB, { x: 800, y: 1200 }, canvas.getRootElement()); 200 | 201 | 202 | var shapeC = elementFactory.createShape({ 203 | id: 'C', 204 | width: 300, 205 | height: 300 206 | }); 207 | 208 | modeling.createShape(shapeC, { x: 800, y: 100 }, canvas.getRootElement()); 209 | 210 | var shapes = generateShapes(200, { 211 | x: -200, 212 | y: -50, 213 | width: 3000, 214 | height: 1000 215 | }); 216 | 217 | // then 218 | expectMinimapShapeToExist('A'); 219 | expectMinimapShapeToExist('B'); 220 | expectMinimapShapeToExist('C'); 221 | 222 | expectMinimapShapesToExist(shapes); 223 | })); 224 | 225 | 226 | it('should update', inject(function(canvas, elementFactory, modeling) { 227 | 228 | // given 229 | var parent = elementFactory.createShape({ 230 | id: 'parent', 231 | width: 100, 232 | height: 100, 233 | x: 100, 234 | y: 100 235 | }); 236 | 237 | var child = elementFactory.createShape({ 238 | id: 'child', 239 | parent: parent, 240 | width: 50, 241 | height: 50, 242 | x: 125, 243 | y: 125 244 | }); 245 | 246 | var rootElement = canvas.getRootElement(); 247 | 248 | modeling.createElements([ parent, child ], { x: 100, y: 100 }, rootElement); 249 | 250 | expectMinimapShapeToExist('parent'); 251 | expectMinimapShapeToExist('child'); 252 | 253 | // when 254 | modeling.resizeShape(parent, { x: 50, y: 50, width: 200, height: 100 }); 255 | 256 | // then 257 | expectMinimapShapeToExist('parent'); 258 | expectMinimapShapeToExist('child'); 259 | })); 260 | 261 | }); 262 | 263 | 264 | describe('canvas.resized', function() { 265 | 266 | beforeEach(bootstrapDiagram({ 267 | modules: viewerModules, 268 | minimap: { 269 | open: true 270 | } 271 | })); 272 | 273 | 274 | it('should not update if not present in DOM', inject( 275 | function(canvas, eventBus, minimap) { 276 | 277 | // given 278 | var spy = sinon.spy(minimap, '_update'); 279 | 280 | // when 281 | domRemove(canvas.getContainer()); 282 | 283 | eventBus.fire('canvas.resized'); 284 | 285 | // then 286 | expect(spy).to.not.have.been.called; 287 | } 288 | )); 289 | }); 290 | 291 | 292 | describe('update', function() { 293 | 294 | it('should not error on viewbox changed', function() { 295 | var diagram = new Diagram({ 296 | modules: modelerModules 297 | }); 298 | 299 | var canvas = diagram.get('canvas'); 300 | 301 | canvas.viewbox({ 302 | x: 0, 303 | y: 0, 304 | width: 100, 305 | height: 100 306 | }); 307 | }); 308 | 309 | 310 | it('should not error on viewbox changed (malformed values)', function() { 311 | var diagram = new Diagram({ 312 | modules: modelerModules 313 | }); 314 | 315 | var canvas = diagram.get('canvas'); 316 | 317 | canvas.viewbox({ 318 | x: 0, 319 | y: 0, 320 | width: Infinity, 321 | height: Infinity 322 | }); 323 | }); 324 | 325 | }); 326 | 327 | 328 | describe('mousemove', function() { 329 | 330 | beforeEach(bootstrapDiagram({ 331 | modules: viewerModules, 332 | minimap: { 333 | open: true 334 | } 335 | })); 336 | 337 | it('should change viewbox on mousemove', inject(function(eventBus, minimap) { 338 | 339 | // given 340 | var svg = minimap._svg; 341 | 342 | var listener = sinon.spy(); 343 | 344 | eventBus.on('canvas.viewbox.changing', listener); 345 | 346 | // when 347 | triggerMouseEvent('mousedown', svg); 348 | triggerMouseEvent('mousemove', svg); 349 | triggerMouseEvent('mousemove', svg); 350 | 351 | // then 352 | // 1 mousedown + 2 mousemove 353 | expect(listener).to.have.been.calledThrice; 354 | 355 | })); 356 | 357 | }); 358 | 359 | 360 | describe('planes', function() { 361 | beforeEach(bootstrapDiagram({ 362 | modules: viewerModules, 363 | minimap: { 364 | open: true 365 | } 366 | })); 367 | 368 | withDiagramJs('>=8')('should only show elements of active plane', 369 | inject(function(canvas, elementFactory) { 370 | 371 | // given 372 | var rootA = elementFactory.createRoot({ 373 | id: 'rootA' 374 | }); 375 | var shapeA = elementFactory.createShape({ 376 | id: 'A', 377 | width: 100, 378 | height: 300, 379 | x: 50, 380 | y: 150 381 | }); 382 | 383 | canvas.addRootElement(rootA); 384 | canvas.addShape(shapeA, rootA); 385 | 386 | var rootB = elementFactory.createRoot({ 387 | id: 'rootB' 388 | }); 389 | var shapeB = elementFactory.createShape({ 390 | id: 'B', 391 | width: 100, 392 | height: 300, 393 | x: 50, 394 | y: 150 395 | }); 396 | 397 | canvas.addRootElement(rootB); 398 | canvas.addShape(shapeB, rootB); 399 | canvas.setRootElement(rootA); 400 | 401 | // assume 402 | expectMinimapShapeToExist('A'); 403 | expectMinimapShapeToNotExist('B'); 404 | 405 | // when 406 | canvas.setRootElement(rootB); 407 | 408 | // then 409 | expectMinimapShapeToNotExist('A'); 410 | expectMinimapShapeToExist('B'); 411 | })); 412 | 413 | 414 | withDiagramJs('^7.4')('should only show elements of active plane', 415 | inject(function(canvas, elementFactory) { 416 | 417 | // given 418 | var rootA = elementFactory.createRoot({ 419 | id: 'rootA' 420 | }); 421 | var shapeA = elementFactory.createShape({ 422 | id: 'A', 423 | width: 100, 424 | height: 300, 425 | x: 50, 426 | y: 150 427 | }); 428 | 429 | canvas.createPlane('A', rootA); 430 | canvas.addShape(shapeA, rootA); 431 | 432 | var rootB = elementFactory.createRoot({ 433 | id: 'rootB' 434 | }); 435 | var shapeB = elementFactory.createShape({ 436 | id: 'B', 437 | width: 100, 438 | height: 300, 439 | x: 50, 440 | y: 150 441 | }); 442 | 443 | canvas.createPlane('B', rootB); 444 | canvas.addShape(shapeB, rootB); 445 | canvas.setActivePlane('A'); 446 | 447 | // assume 448 | expectMinimapShapeToExist('A'); 449 | expectMinimapShapeToNotExist('B'); 450 | 451 | // when 452 | canvas.setActivePlane('B'); 453 | 454 | // then 455 | expectMinimapShapeToNotExist('A'); 456 | expectMinimapShapeToExist('B'); 457 | })); 458 | 459 | }); 460 | 461 | }); 462 | 463 | 464 | // helpers ///////////////// 465 | 466 | function generateShapes(count, viewport) { 467 | 468 | return getDiagramJS().invoke(function(canvas, elementFactory, modeling) { 469 | var rootElement = canvas.getRootElement(), 470 | shape; 471 | 472 | var shapes = []; 473 | 474 | for (var i = 0; i < count; i++) { 475 | shape = elementFactory.createShape({ 476 | id: 'shape' + i, 477 | width: random(10, 300), 478 | height: random(10, 200), 479 | }); 480 | 481 | shapes.push(modeling.createShape(shape, { 482 | x: random(viewport.x, viewport.width), 483 | y: random(viewport.y, viewport.height) 484 | }, rootElement)); 485 | } 486 | 487 | return shapes; 488 | }); 489 | } 490 | 491 | function random(start, end) { 492 | return Math.round(Math.random() * (end - start) + start); 493 | } 494 | 495 | function triggerMouseEvent(type, gfx) { 496 | var event = document.createEvent('MouseEvent'); 497 | 498 | event.initMouseEvent(type, true, true, window, 0, 0, 0, 80, 20, false, false, false, false, 0, null); 499 | 500 | return gfx.dispatchEvent(event); 501 | } 502 | 503 | function expectMinimapShapeToExist(id) { 504 | getDiagramJS().invoke(function(elementRegistry, minimap) { 505 | var element = elementRegistry.get(id); 506 | 507 | expect(element).to.exist; 508 | 509 | var minimapShape = domQuery(`g[id^="djs-minimap-${id}"]`, minimap._parent); 510 | 511 | expect(minimapShape).to.exist; 512 | 513 | var transform = svgAttr(minimapShape, 'transform'); 514 | 515 | var translate = transform.replace('translate(', '').replace(')', '').split(' '); 516 | 517 | var x = parseInt(translate[0], 10), 518 | y = parseInt(translate[1], 10); 519 | 520 | var parentX = element.parent.x || 0, 521 | parentY = element.parent.y || 0; 522 | 523 | expect(x).to.equal(element.x - parentX); 524 | expect(y).to.equal(element.y - parentY); 525 | }); 526 | } 527 | 528 | function expectMinimapConnectionToExist(id) { 529 | getDiagramJS().invoke(function(elementRegistry, minimap) { 530 | var element = elementRegistry.get(id); 531 | 532 | expect(element).to.exist; 533 | 534 | var minimapShape = domQuery(`g[id^="djs-minimap-${id}"]`, minimap._parent); 535 | 536 | expect(minimapShape).to.exist; 537 | }); 538 | } 539 | 540 | 541 | function expectMinimapShapeToNotExist(id) { 542 | getDiagramJS().invoke(function(elementRegistry, minimap) { 543 | var element = elementRegistry.get(id); 544 | 545 | expect(element).to.exist; 546 | 547 | var minimapShape = domQuery('g#' + id, minimap._parent); 548 | 549 | expect(minimapShape).to.not.exist; 550 | }); 551 | } 552 | 553 | function expectMinimapShapesToExist(shapes) { 554 | shapes.forEach(function(shape) { 555 | var id = shape.id; 556 | 557 | expectMinimapShapeToExist(id); 558 | }); 559 | } -------------------------------------------------------------------------------- /lib/Minimap.js: -------------------------------------------------------------------------------- 1 | import { 2 | attr as domAttr, 3 | classes as domClasses, 4 | event as domEvent, 5 | query as domQuery, 6 | queryAll as domQueryAll 7 | } from 'min-dom'; 8 | 9 | import { 10 | append as svgAppend, 11 | attr as svgAttr, 12 | classes as svgClasses, 13 | clear as svgClear, 14 | clone as svgClone, 15 | create as svgCreate, 16 | remove as svgRemove 17 | } from 'tiny-svg'; 18 | 19 | import { 20 | assign, 21 | every, 22 | isNumber, 23 | isObject 24 | } from 'min-dash'; 25 | 26 | import { 27 | escapeCSS as cssEscape 28 | } from 'diagram-js/lib/util/EscapeUtil'; 29 | 30 | import { getVisual } from 'diagram-js/lib/util/GraphicsUtil'; 31 | 32 | import IdGenerator from 'diagram-js/lib/util/IdGenerator'; 33 | 34 | var MINIMAP_VIEWBOX_PADDING = 50; 35 | 36 | var IDS = new IdGenerator(); 37 | 38 | var RANGE = { min: 0.2, max: 4 }, 39 | NUM_STEPS = 10; 40 | 41 | var DELTA_THRESHOLD = 0.1; 42 | 43 | var LOW_PRIORITY = 250; 44 | 45 | 46 | /** 47 | * A minimap that reflects and lets you navigate the diagram. 48 | */ 49 | export default function Minimap( 50 | config, injector, eventBus, 51 | canvas, elementRegistry) { 52 | 53 | var self = this; 54 | 55 | this._canvas = canvas; 56 | this._elementRegistry = elementRegistry; 57 | this._eventBus = eventBus; 58 | this._injector = injector; 59 | 60 | this._state = { 61 | isOpen: undefined, 62 | isDragging: false, 63 | initialDragPosition: null, 64 | offsetViewport: null, 65 | cachedViewbox: null, 66 | dragger: null, 67 | svgClientRect: null, 68 | parentClientRect: null, 69 | zoomDelta: 0 70 | }; 71 | 72 | this._minimapId = IDS.next(); 73 | 74 | this._init(); 75 | 76 | this.toggle((config && config.open) || false); 77 | 78 | function centerViewbox(point) { 79 | 80 | // getBoundingClientRect might return zero-dimensional when called for the first time 81 | if (!self._state._svgClientRect || isZeroDimensional(self._state._svgClientRect)) { 82 | self._state._svgClientRect = self._svg.getBoundingClientRect(); 83 | } 84 | 85 | var diagramPoint = mapMousePositionToDiagramPoint({ 86 | x: point.x - self._state._svgClientRect.left, 87 | y: point.y - self._state._svgClientRect.top 88 | }, self._svg, self._lastViewbox); 89 | 90 | setViewboxCenteredAroundPoint(diagramPoint, self._canvas); 91 | 92 | self._update(); 93 | } 94 | 95 | function mousedown(center) { 96 | 97 | return function onMousedown(event) { 98 | var point = getPoint(event); 99 | 100 | // getBoundingClientRect might return zero-dimensional when called for the first time 101 | if (!self._state._svgClientRect || isZeroDimensional(self._state._svgClientRect)) { 102 | self._state._svgClientRect = self._svg.getBoundingClientRect(); 103 | } 104 | 105 | if (center) { 106 | centerViewbox(point); 107 | } 108 | 109 | var diagramPoint = mapMousePositionToDiagramPoint({ 110 | x: point.x - self._state._svgClientRect.left, 111 | y: point.y - self._state._svgClientRect.top 112 | }, self._svg, self._lastViewbox); 113 | 114 | var viewbox = canvas.viewbox(); 115 | 116 | var offsetViewport = getOffsetViewport(diagramPoint, viewbox); 117 | 118 | var initialViewportDomRect = self._viewportDom.getBoundingClientRect(); 119 | 120 | // take border into account (regardless of width) 121 | var offsetViewportDom = { 122 | x: point.x - initialViewportDomRect.left + 1, 123 | y: point.y - initialViewportDomRect.top + 1 124 | }; 125 | 126 | // init dragging 127 | assign(self._state, { 128 | cachedViewbox: viewbox, 129 | initialDragPosition: { 130 | x: point.x, 131 | y: point.y 132 | }, 133 | isDragging: true, 134 | offsetViewport: offsetViewport, 135 | offsetViewportDom: offsetViewportDom, 136 | viewportClientRect: self._viewport.getBoundingClientRect(), 137 | parentClientRect: self._parent.getBoundingClientRect() 138 | }); 139 | 140 | domEvent.bind(document, 'mousemove', onMousemove); 141 | domEvent.bind(document, 'mouseup', onMouseup); 142 | }; 143 | } 144 | 145 | function onMousemove(event) { 146 | var point = getPoint(event); 147 | 148 | // set viewbox if dragging active 149 | if (self._state.isDragging) { 150 | 151 | // getBoundingClientRect might return zero-dimensional when called for the first time 152 | if (!self._state._svgClientRect || isZeroDimensional(self._state._svgClientRect)) { 153 | self._state._svgClientRect = self._svg.getBoundingClientRect(); 154 | } 155 | 156 | // update viewport DOM 157 | var offsetViewportDom = self._state.offsetViewportDom, 158 | viewportClientRect = self._state.viewportClientRect, 159 | parentClientRect = self._state.parentClientRect; 160 | 161 | assign(self._viewportDom.style, { 162 | top: (point.y - offsetViewportDom.y - parentClientRect.top) + 'px', 163 | left: (point.x - offsetViewportDom.x - parentClientRect.left) + 'px' 164 | }); 165 | 166 | // update overlay 167 | var clipPath = getOverlayClipPath(parentClientRect, { 168 | top: point.y - offsetViewportDom.y - parentClientRect.top, 169 | left: point.x - offsetViewportDom.x - parentClientRect.left, 170 | width: viewportClientRect.width, 171 | height: viewportClientRect.height 172 | }); 173 | 174 | assign(self._overlay.style, { 175 | clipPath: clipPath 176 | }); 177 | 178 | var diagramPoint = mapMousePositionToDiagramPoint({ 179 | x: point.x - self._state._svgClientRect.left, 180 | y: point.y - self._state._svgClientRect.top 181 | }, self._svg, self._lastViewbox); 182 | 183 | setViewboxCenteredAroundPoint({ 184 | x: diagramPoint.x - self._state.offsetViewport.x, 185 | y: diagramPoint.y - self._state.offsetViewport.y 186 | }, self._canvas); 187 | } 188 | } 189 | 190 | function onMouseup(event) { 191 | var point = getPoint(event); 192 | 193 | if (self._state.isDragging) { 194 | 195 | // treat event as click 196 | if (self._state.initialDragPosition.x === point.x 197 | && self._state.initialDragPosition.y === point.y) { 198 | centerViewbox(event); 199 | } 200 | 201 | self._update(); 202 | 203 | // end dragging 204 | assign(self._state, { 205 | cachedViewbox: null, 206 | initialDragPosition: null, 207 | isDragging: false, 208 | offsetViewport: null, 209 | offsetViewportDom: null 210 | }); 211 | 212 | domEvent.unbind(document, 'mousemove', onMousemove); 213 | domEvent.unbind(document, 'mouseup', onMouseup); 214 | } 215 | } 216 | 217 | // dragging viewport scrolls canvas 218 | domEvent.bind(this._viewportDom, 'mousedown', mousedown(false)); 219 | domEvent.bind(this._svg, 'mousedown', mousedown(true)); 220 | 221 | domEvent.bind(this._parent, 'wheel', function(event) { 222 | 223 | // stop propagation and handle scroll differently 224 | event.preventDefault(); 225 | event.stopPropagation(); 226 | 227 | // only zoom in on ctrl; this aligns with diagram-js navigation behavior 228 | if (!event.ctrlKey) { 229 | return; 230 | } 231 | 232 | // getBoundingClientRect might return zero-dimensional when called for the first time 233 | if (!self._state._svgClientRect || isZeroDimensional(self._state._svgClientRect)) { 234 | self._state._svgClientRect = self._svg.getBoundingClientRect(); 235 | } 236 | 237 | // disallow zooming through viewport outside of minimap as it is very confusing 238 | if (!isPointInside(event, self._state._svgClientRect)) { 239 | return; 240 | } 241 | 242 | var factor = event.deltaMode === 0 ? 0.020 : 0.32; 243 | 244 | var delta = ( 245 | Math.sqrt( 246 | Math.pow(event.deltaY, 2) + 247 | Math.pow(event.deltaX, 2) 248 | ) * sign(event.deltaY) * -factor 249 | ); 250 | 251 | // add until threshold reached 252 | self._state.zoomDelta += delta; 253 | 254 | if (Math.abs(self._state.zoomDelta) > DELTA_THRESHOLD) { 255 | var direction = delta > 0 ? 1 : -1; 256 | 257 | var currentLinearZoomLevel = Math.log(canvas.zoom()) / Math.log(10); 258 | 259 | // zoom with half the step size of stepZoom 260 | var stepSize = getStepSize(RANGE, NUM_STEPS * 2); 261 | 262 | // snap to a proximate zoom step 263 | var newLinearZoomLevel = Math.round(currentLinearZoomLevel / stepSize) * stepSize; 264 | 265 | // increase or decrease one zoom step in the given direction 266 | newLinearZoomLevel += stepSize * direction; 267 | 268 | // calculate the absolute logarithmic zoom level based on the linear zoom level 269 | // (e.g. 2 for an absolute x2 zoom) 270 | var newLogZoomLevel = Math.pow(10, newLinearZoomLevel); 271 | 272 | canvas.zoom(cap(RANGE, newLogZoomLevel), diagramPoint); 273 | 274 | // reset 275 | self._state.zoomDelta = 0; 276 | 277 | var diagramPoint = mapMousePositionToDiagramPoint({ 278 | x: event.clientX - self._state._svgClientRect.left, 279 | y: event.clientY - self._state._svgClientRect.top 280 | }, self._svg, self._lastViewbox); 281 | 282 | setViewboxCenteredAroundPoint(diagramPoint, self._canvas); 283 | 284 | self._update(); 285 | } 286 | }); 287 | 288 | domEvent.bind(this._toggle, 'click', function(event) { 289 | event.preventDefault(); 290 | event.stopPropagation(); 291 | 292 | self.toggle(); 293 | }); 294 | 295 | // add shape on shape/connection added 296 | eventBus.on([ 'shape.added', 'connection.added' ], function(context) { 297 | var element = context.element; 298 | 299 | self._addElement(element); 300 | 301 | self._update(); 302 | }); 303 | 304 | // remove shape on shape/connection removed 305 | eventBus.on([ 'shape.removed', 'connection.removed' ], function(context) { 306 | var element = context.element; 307 | 308 | self._removeElement(element); 309 | 310 | self._update(); 311 | }); 312 | 313 | // update on elements changed 314 | eventBus.on('elements.changed', LOW_PRIORITY, function(context) { 315 | var elements = context.elements; 316 | 317 | elements.forEach(function(element) { 318 | self._updateElement(element); 319 | }); 320 | 321 | self._update(); 322 | }); 323 | 324 | // update on element ID update 325 | eventBus.on('element.updateId', function(context) { 326 | var element = context.element, 327 | newId = context.newId; 328 | 329 | self._updateElementId(element, newId); 330 | }); 331 | 332 | // update on viewbox changed 333 | eventBus.on('canvas.viewbox.changed', function() { 334 | if (!self._state.isDragging) { 335 | self._update(); 336 | } 337 | }); 338 | 339 | eventBus.on('canvas.resized', function() { 340 | 341 | // only update if present in DOM 342 | if (document.body.contains(self._parent)) { 343 | if (!self._state.isDragging) { 344 | self._update(); 345 | } 346 | 347 | self._state._svgClientRect = self._svg.getBoundingClientRect(); 348 | } 349 | 350 | }); 351 | 352 | eventBus.on([ 'root.set', 'plane.set' ], function(event) { 353 | self._clear(); 354 | 355 | var element = event.element || event.plane.rootElement; 356 | 357 | element.children.forEach(function(el) { 358 | self._addElement(el); 359 | }); 360 | 361 | self._update(); 362 | }); 363 | 364 | } 365 | 366 | Minimap.$inject = [ 367 | 'config.minimap', 368 | 'injector', 369 | 'eventBus', 370 | 'canvas', 371 | 'elementRegistry' 372 | ]; 373 | 374 | Minimap.prototype._init = function() { 375 | var canvas = this._canvas, 376 | container = canvas.getContainer(); 377 | 378 | // create parent div 379 | var parent = this._parent = document.createElement('div'); 380 | 381 | domClasses(parent).add('djs-minimap'); 382 | 383 | container.appendChild(parent); 384 | 385 | // create toggle 386 | var toggle = this._toggle = document.createElement('div'); 387 | 388 | domClasses(toggle).add('toggle'); 389 | 390 | parent.appendChild(toggle); 391 | 392 | // create map 393 | var map = this._map = document.createElement('div'); 394 | 395 | domClasses(map).add('map'); 396 | 397 | parent.appendChild(map); 398 | 399 | // create svg 400 | var svg = this._svg = svgCreate('svg'); 401 | svgAttr(svg, { width: '100%', height: '100%' }); 402 | svgAppend(map, svg); 403 | 404 | // add groups 405 | var elementsGroup = this._elementsGroup = svgCreate('g'); 406 | svgAppend(svg, elementsGroup); 407 | 408 | var viewportGroup = this._viewportGroup = svgCreate('g'); 409 | svgAppend(svg, viewportGroup); 410 | 411 | // add viewport SVG 412 | var viewport = this._viewport = svgCreate('rect'); 413 | 414 | svgClasses(viewport).add('viewport'); 415 | 416 | svgAppend(viewportGroup, viewport); 417 | 418 | // prevent drag propagation 419 | domEvent.bind(parent, 'mousedown', function(event) { 420 | event.stopPropagation(); 421 | }); 422 | 423 | // add viewport DOM 424 | var viewportDom = this._viewportDom = document.createElement('div'); 425 | 426 | domClasses(viewportDom).add('viewport-dom'); 427 | 428 | this._parent.appendChild(viewportDom); 429 | 430 | // add overlay 431 | var overlay = this._overlay = document.createElement('div'); 432 | 433 | domClasses(overlay).add('overlay'); 434 | 435 | this._parent.appendChild(overlay); 436 | }; 437 | 438 | Minimap.prototype._update = function() { 439 | var viewbox = this._canvas.viewbox(), 440 | innerViewbox = viewbox.inner, 441 | outerViewbox = viewbox.outer; 442 | 443 | if (!validViewbox(viewbox)) { 444 | return; 445 | } 446 | 447 | var x, y, width, height; 448 | 449 | var widthDifference = outerViewbox.width - innerViewbox.width, 450 | heightDifference = outerViewbox.height - innerViewbox.height; 451 | 452 | // update viewbox 453 | // x 454 | if (innerViewbox.width < outerViewbox.width) { 455 | x = innerViewbox.x - widthDifference / 2; 456 | width = outerViewbox.width; 457 | 458 | if (innerViewbox.x + innerViewbox.width < outerViewbox.width) { 459 | x = Math.min(0, innerViewbox.x); 460 | } 461 | } else { 462 | x = innerViewbox.x; 463 | width = innerViewbox.width; 464 | } 465 | 466 | // y 467 | if (innerViewbox.height < outerViewbox.height) { 468 | y = innerViewbox.y - heightDifference / 2; 469 | height = outerViewbox.height; 470 | 471 | if (innerViewbox.y + innerViewbox.height < outerViewbox.height) { 472 | y = Math.min(0, innerViewbox.y); 473 | } 474 | } else { 475 | y = innerViewbox.y; 476 | height = innerViewbox.height; 477 | } 478 | 479 | // apply some padding 480 | x = x - MINIMAP_VIEWBOX_PADDING; 481 | y = y - MINIMAP_VIEWBOX_PADDING; 482 | width = width + MINIMAP_VIEWBOX_PADDING * 2; 483 | height = height + MINIMAP_VIEWBOX_PADDING * 2; 484 | 485 | this._lastViewbox = { 486 | x: x, 487 | y: y, 488 | width: width, 489 | height: height 490 | }; 491 | 492 | svgAttr(this._svg, { 493 | viewBox: x + ', ' + y + ', ' + width + ', ' + height 494 | }); 495 | 496 | // update viewport SVG 497 | svgAttr(this._viewport, { 498 | x: viewbox.x, 499 | y: viewbox.y, 500 | width: viewbox.width, 501 | height: viewbox.height 502 | }); 503 | 504 | // update viewport DOM 505 | var parentClientRect = this._state._parentClientRect = this._parent.getBoundingClientRect(); 506 | var viewportClientRect = this._viewport.getBoundingClientRect(); 507 | 508 | var withoutParentOffset = { 509 | top: viewportClientRect.top - parentClientRect.top, 510 | left: viewportClientRect.left - parentClientRect.left, 511 | width: viewportClientRect.width, 512 | height: viewportClientRect.height 513 | }; 514 | 515 | assign(this._viewportDom.style, { 516 | top: withoutParentOffset.top + 'px', 517 | left: withoutParentOffset.left + 'px', 518 | width: withoutParentOffset.width + 'px', 519 | height: withoutParentOffset.height + 'px' 520 | }); 521 | 522 | // update overlay 523 | var clipPath = getOverlayClipPath(parentClientRect, withoutParentOffset); 524 | 525 | assign(this._overlay.style, { 526 | clipPath: clipPath 527 | }); 528 | }; 529 | 530 | Minimap.prototype.open = function() { 531 | assign(this._state, { isOpen: true }); 532 | 533 | domClasses(this._parent).add('open'); 534 | 535 | var translate = this._injector.get('translate', false) || function(s) { return s; }; 536 | 537 | domAttr(this._toggle, 'title', translate('Close minimap')); 538 | 539 | this._update(); 540 | 541 | this._eventBus.fire('minimap.toggle', { open: true }); 542 | }; 543 | 544 | Minimap.prototype.close = function() { 545 | assign(this._state, { isOpen: false }); 546 | 547 | domClasses(this._parent).remove('open'); 548 | 549 | var translate = this._injector.get('translate', false) || function(s) { return s; }; 550 | 551 | domAttr(this._toggle, 'title', translate('Open minimap')); 552 | 553 | this._eventBus.fire('minimap.toggle', { open: false }); 554 | }; 555 | 556 | Minimap.prototype.toggle = function(open) { 557 | 558 | var currentOpen = this.isOpen(); 559 | 560 | if (typeof open === 'undefined') { 561 | open = !currentOpen; 562 | } 563 | 564 | if (open == currentOpen) { 565 | return; 566 | } 567 | 568 | if (open) { 569 | this.open(); 570 | } else { 571 | this.close(); 572 | } 573 | }; 574 | 575 | Minimap.prototype.isOpen = function() { 576 | return this._state.isOpen; 577 | }; 578 | 579 | Minimap.prototype._updateElement = function(element) { 580 | 581 | try { 582 | 583 | // if parent is null element has been removed, if parent is undefined parent is root 584 | if (element.parent !== undefined && element.parent !== null) { 585 | this._removeElement(element); 586 | this._addElement(element); 587 | } 588 | } catch (error) { 589 | console.warn('Minimap#_updateElement errored', error); 590 | } 591 | 592 | }; 593 | 594 | Minimap.prototype._updateElementId = function(element, newId) { 595 | 596 | try { 597 | var elementGfx = domQuery('#' + cssEscape(this._prefixId(element.id)), this._elementsGroup); 598 | 599 | if (elementGfx) { 600 | elementGfx.id = this._prefixId(newId); 601 | } 602 | } catch (error) { 603 | console.warn('Minimap#_updateElementId errored', error); 604 | } 605 | 606 | }; 607 | 608 | /** 609 | * Checks if an element is on the currently active plane. 610 | */ 611 | Minimap.prototype.isOnActivePlane = function(element) { 612 | var canvas = this._canvas; 613 | 614 | // diagram-js@8 615 | if (canvas.findRoot) { 616 | return canvas.findRoot(element) === canvas.getRootElement(); 617 | } 618 | 619 | // diagram-js>=7.4.0 620 | if (canvas.findPlane) { 621 | return canvas.findPlane(element) === canvas.getActivePlane(); 622 | } 623 | 624 | // diagram-js<7.4.0 625 | return true; 626 | }; 627 | 628 | 629 | /** 630 | * Adds an element to the minimap. 631 | */ 632 | Minimap.prototype._addElement = function(element) { 633 | var self = this; 634 | 635 | this._removeElement(element); 636 | 637 | if (!this.isOnActivePlane(element)) { 638 | return; 639 | } 640 | 641 | var parent, 642 | x, y; 643 | 644 | var newElementGfx = this._createElement(element); 645 | var newElementParentGfx = domQuery('#' + cssEscape(this._prefixId(element.parent.id)), this._elementsGroup); 646 | 647 | if (newElementGfx) { 648 | 649 | var elementGfx = this._elementRegistry.getGraphics(element); 650 | var parentGfx = this._elementRegistry.getGraphics(element.parent); 651 | 652 | var index = getIndexOfChildInParentChildren(elementGfx, parentGfx); 653 | 654 | // index can be 0 655 | if (index !== 'undefined') { 656 | if (newElementParentGfx) { 657 | 658 | // in cases of doubt add as last child 659 | if (newElementParentGfx.childNodes.length > index) { 660 | insertChildAtIndex(newElementGfx, newElementParentGfx, index); 661 | } else { 662 | insertChildAtIndex(newElementGfx, newElementParentGfx, newElementParentGfx.childNodes.length - 1); 663 | } 664 | 665 | } else { 666 | this._elementsGroup.appendChild(newElementGfx); 667 | } 668 | 669 | } else { 670 | 671 | // index undefined 672 | this._elementsGroup.appendChild(newElementGfx); 673 | } 674 | 675 | if (isConnection(element)) { 676 | parent = element.parent; 677 | x = 0; 678 | y = 0; 679 | 680 | if (typeof parent.x !== 'undefined' && typeof parent.y !== 'undefined') { 681 | x = -parent.x; 682 | y = -parent.y; 683 | } 684 | 685 | svgAttr(newElementGfx, { transform: 'translate(' + x + ' ' + y + ')' }); 686 | } else { 687 | x = element.x; 688 | y = element.y; 689 | 690 | if (newElementParentGfx) { 691 | parent = element.parent; 692 | 693 | x -= parent.x; 694 | y -= parent.y; 695 | } 696 | 697 | svgAttr(newElementGfx, { transform: 'translate(' + x + ' ' + y + ')' }); 698 | } 699 | 700 | if (element.children && element.children.length) { 701 | element.children.forEach(function(child) { 702 | self._addElement(child); 703 | }); 704 | } 705 | 706 | return newElementGfx; 707 | } 708 | }; 709 | 710 | Minimap.prototype._removeElement = function(element) { 711 | var elementGfx = this._svg.getElementById(this._prefixId(element.id)); 712 | 713 | if (elementGfx) { 714 | svgRemove(elementGfx); 715 | } 716 | }; 717 | 718 | Minimap.prototype._createElement = function(element) { 719 | var gfx = this._elementRegistry.getGraphics(element), 720 | visual; 721 | 722 | if (gfx) { 723 | visual = getVisual(gfx); 724 | 725 | if (visual) { 726 | var elementGfx = sanitize(svgClone(visual)); 727 | 728 | svgAttr(elementGfx, { id: this._prefixId(element.id) }); 729 | 730 | return elementGfx; 731 | } 732 | } 733 | }; 734 | 735 | Minimap.prototype._clear = function() { 736 | svgClear(this._elementsGroup); 737 | }; 738 | 739 | Minimap.prototype._prefixId = function(id) { 740 | return 'djs-minimap-' + id + '-' + this._minimapId; 741 | }; 742 | 743 | 744 | function isConnection(element) { 745 | return element.waypoints; 746 | } 747 | 748 | function getOffsetViewport(diagramPoint, viewbox) { 749 | var viewboxCenter = { 750 | x: viewbox.x + (viewbox.width / 2), 751 | y: viewbox.y + (viewbox.height / 2) 752 | }; 753 | 754 | return { 755 | x: diagramPoint.x - viewboxCenter.x, 756 | y: diagramPoint.y - viewboxCenter.y 757 | }; 758 | } 759 | 760 | function mapMousePositionToDiagramPoint(position, svg, lastViewbox) { 761 | 762 | // firefox returns 0 for clientWidth and clientHeight 763 | var boundingClientRect = svg.getBoundingClientRect(); 764 | 765 | // take different aspect ratios of default layers bounding box and minimap into account 766 | var bBox = 767 | fitAspectRatio(lastViewbox, boundingClientRect.width / boundingClientRect.height); 768 | 769 | // map click position to diagram position 770 | var diagramX = map(position.x, 0, boundingClientRect.width, bBox.x, bBox.x + bBox.width), 771 | diagramY = map(position.y, 0, boundingClientRect.height, bBox.y, bBox.y + bBox.height); 772 | 773 | return { 774 | x: diagramX, 775 | y: diagramY 776 | }; 777 | } 778 | 779 | function setViewboxCenteredAroundPoint(point, canvas) { 780 | 781 | // get cached viewbox to preserve zoom 782 | var cachedViewbox = canvas.viewbox(), 783 | cachedViewboxWidth = cachedViewbox.width, 784 | cachedViewboxHeight = cachedViewbox.height; 785 | 786 | canvas.viewbox({ 787 | x: point.x - cachedViewboxWidth / 2, 788 | y: point.y - cachedViewboxHeight / 2, 789 | width: cachedViewboxWidth, 790 | height: cachedViewboxHeight 791 | }); 792 | } 793 | 794 | function fitAspectRatio(bounds, targetAspectRatio) { 795 | var aspectRatio = bounds.width / bounds.height; 796 | 797 | // assigning to bounds throws exception in IE11 798 | var newBounds = assign({}, { 799 | x: bounds.x, 800 | y: bounds.y, 801 | width: bounds.width, 802 | height: bounds.height 803 | }); 804 | 805 | if (aspectRatio > targetAspectRatio) { 806 | 807 | // height needs to be fitted 808 | var height = newBounds.width * (1 / targetAspectRatio), 809 | y = newBounds.y - ((height - newBounds.height) / 2); 810 | 811 | assign(newBounds, { 812 | y: y, 813 | height: height 814 | }); 815 | } else if (aspectRatio < targetAspectRatio) { 816 | 817 | // width needs to be fitted 818 | var width = newBounds.height * targetAspectRatio, 819 | x = newBounds.x - ((width - newBounds.width) / 2); 820 | 821 | assign(newBounds, { 822 | x: x, 823 | width: width 824 | }); 825 | } 826 | 827 | return newBounds; 828 | } 829 | 830 | function map(x, inMin, inMax, outMin, outMax) { 831 | var inRange = inMax - inMin, 832 | outRange = outMax - outMin; 833 | 834 | return (x - inMin) * outRange / inRange + outMin; 835 | } 836 | 837 | /** 838 | * Returns index of child in children of parent. 839 | * 840 | * g 841 | * '- g.djs-element // parentGfx 842 | * '- g.djs-children 843 | * '- g 844 | * '-g.djs-element // childGfx 845 | */ 846 | function getIndexOfChildInParentChildren(childGfx, parentGfx) { 847 | var childrenGroup = domQuery('.djs-children', parentGfx.parentNode); 848 | 849 | if (!childrenGroup) { 850 | return; 851 | } 852 | 853 | var childrenArray = [].slice.call(childrenGroup.childNodes); 854 | 855 | var indexOfChild = -1; 856 | 857 | childrenArray.forEach(function(childGroup, index) { 858 | if (domQuery('.djs-element', childGroup) === childGfx) { 859 | indexOfChild = index; 860 | } 861 | }); 862 | 863 | return indexOfChild; 864 | } 865 | 866 | function insertChildAtIndex(childGfx, parentGfx, index) { 867 | var childContainer = getChildContainer(parentGfx); 868 | 869 | var childrenArray = [].slice.call(childContainer.childNodes); 870 | 871 | var childAtIndex = childrenArray[index]; 872 | 873 | if (childAtIndex) { 874 | parentGfx.insertBefore(childGfx, childAtIndex.nextSibling); 875 | } else { 876 | parentGfx.appendChild(childGfx); 877 | } 878 | } 879 | 880 | function getChildContainer(parentGfx) { 881 | var container = domQuery('.children', parentGfx); 882 | 883 | if (!container) { 884 | container = svgCreate('g', { class: 'children' }); 885 | svgAppend(parentGfx, container); 886 | } 887 | 888 | return container; 889 | } 890 | 891 | function isZeroDimensional(clientRect) { 892 | return clientRect.width === 0 && clientRect.height === 0; 893 | } 894 | 895 | function isPointInside(point, rect) { 896 | return point.x > rect.left 897 | && point.x < rect.left + rect.width 898 | && point.y > rect.top 899 | && point.y < rect.top + rect.height; 900 | } 901 | 902 | var sign = Math.sign || function(n) { 903 | return n >= 0 ? 1 : -1; 904 | }; 905 | 906 | /** 907 | * Get step size for given range and number of steps. 908 | * 909 | * @param {Object} range - Range. 910 | * @param {number} range.min - Range minimum. 911 | * @param {number} range.max - Range maximum. 912 | */ 913 | function getStepSize(range, steps) { 914 | 915 | var minLinearRange = Math.log(range.min) / Math.log(10), 916 | maxLinearRange = Math.log(range.max) / Math.log(10); 917 | 918 | var absoluteLinearRange = Math.abs(minLinearRange) + Math.abs(maxLinearRange); 919 | 920 | return absoluteLinearRange / steps; 921 | } 922 | 923 | function cap(range, scale) { 924 | return Math.max(range.min, Math.min(range.max, scale)); 925 | } 926 | 927 | function getOverlayClipPath(outer, inner) { 928 | var coordinates = [ 929 | toCoordinatesString(inner.left, inner.top), 930 | toCoordinatesString(inner.left + inner.width, inner.top), 931 | toCoordinatesString(inner.left + inner.width, inner.top + inner.height), 932 | toCoordinatesString(inner.left, inner.top + inner.height), 933 | toCoordinatesString(inner.left, outer.height), 934 | toCoordinatesString(outer.width, outer.height), 935 | toCoordinatesString(outer.width, 0), 936 | toCoordinatesString(0, 0), 937 | toCoordinatesString(0, outer.height), 938 | toCoordinatesString(inner.left, outer.height) 939 | ].join(', '); 940 | 941 | return 'polygon(' + coordinates + ')'; 942 | } 943 | 944 | function toCoordinatesString(x, y) { 945 | return x + 'px ' + y + 'px'; 946 | } 947 | 948 | function validViewbox(viewBox) { 949 | 950 | return every(viewBox, function(value) { 951 | 952 | // check deeper structures like inner or outer viewbox 953 | if (isObject(value)) { 954 | return validViewbox(value); 955 | } 956 | 957 | return isNumber(value) && isFinite(value); 958 | }); 959 | } 960 | 961 | function getPoint(event) { 962 | if (event.center) { 963 | return event.center; 964 | } 965 | 966 | return { 967 | x: event.clientX, 968 | y: event.clientY 969 | }; 970 | } 971 | 972 | // removes all elements with an id attribute 973 | function sanitize(gfx) { 974 | domQueryAll('[id]', gfx).forEach(function(element) { 975 | element.remove(); 976 | }); 977 | 978 | return gfx; 979 | } --------------------------------------------------------------------------------