├── .github └── workflows │ └── CI.yml ├── .gitignore ├── CHANGELOG.md ├── LICENSE ├── README.md ├── eslint.config.mjs ├── karma.conf.js ├── lib ├── DirectEditing.js ├── TextBox.js └── index.js ├── package-lock.json ├── package.json ├── renovate.json ├── resources └── screencast.gif └── test ├── DirectEditingProvider.js ├── DirectEditingSpec.js └── suite.js /.github/workflows/CI.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | on: [ push, pull_request ] 3 | jobs: 4 | Build: 5 | runs-on: ubuntu-latest 6 | 7 | strategy: 8 | matrix: 9 | integration-deps: 10 | - "" 11 | - "diagram-js@^14" 12 | 13 | steps: 14 | - name: Checkout 15 | uses: actions/checkout@v4 16 | - name: Use Node.js 20 17 | uses: actions/setup-node@v4 18 | with: 19 | node-version: 20 20 | cache: 'npm' 21 | - name: Install dependencies 22 | run: npm ci 23 | - name: Install dependencies for integration test 24 | if: ${{ matrix.integration-deps != '' }} 25 | run: npm install ${{ matrix.integration-deps }} 26 | - name: Setup project 27 | uses: bpmn-io/actions/setup@latest 28 | - name: Build 29 | env: 30 | TEST_BROWSERS: ChromeHeadless 31 | run: npm run all 32 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | .idea/ -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | All notable changes to [diagram-js-direct-editing](https://github.com/bpmn-io/diagram-js-direct-editing) 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 | ## 3.2.0 10 | 11 | * `FEAT`: restore focus on Canvas after close 12 | * `FEAT`: support `diagram-js@15.0.0` 13 | 14 | ## 3.1.0 15 | 16 | * `DEPS`: update to `min-dom@4.2.1` 17 | 18 | ## 3.0.1 19 | 20 | _This reverts `v3.0.0`._ 21 | 22 | * `FEAT`: restore background for all textboxes. You can remove the background with custom styles or a style config in direct editing provider. 23 | 24 | ## 3.0.0 25 | 26 | * `FEAT`: remove background for non-resizable textboxes ([#23](https://github.com/bpmn-io/diagram-js-direct-editing/issues/23)) 27 | 28 | ### Breaking Changes 29 | 30 | * By default, no background is shown when editing a static sized element. To restore old behavior, add a style config when activating direct editing: 31 | ``` 32 | const MyProvider = { 33 | activate: (element) => { 34 | return { 35 | style: { 36 | backgroundColor: '#ffffff', 37 | border: '1px solid #ccc' 38 | } 39 | // ... 40 | } 41 | } 42 | } 43 | ``` 44 | 45 | ## 2.1.2 46 | 47 | _This reverts `v2.1.1`._ 48 | 49 | * `FIX`: restore `main` package export 50 | 51 | ## 2.1.1 52 | 53 | * `FIX`: drop `main` package export 54 | 55 | ## 2.1.0 56 | 57 | * `FEAT`: allow loading as a module 58 | 59 | ## 2.0.1 60 | 61 | * `FIX`: add package export 62 | 63 | ## 2.0.0 64 | 65 | * `DEPS`: bump utility dependencies 66 | 67 | ## 1.8.0 68 | 69 | * `DEPS`: support diagram-js@9 70 | 71 | ## 1.7.0 72 | 73 | * `FEAT`: allow to query for active element ([#25](https://github.com/bpmn-io/diagram-js-direct-editing/pull/25)) 74 | 75 | ## 1.6.4 76 | 77 | * `DEPS`: support diagram-js@8 78 | 79 | ## 1.6.3 80 | 81 | * `FIX`: preserve Windows newline characters ([#19](https://github.com/bpmn-io/diagram-js-direct-editing/pull/19)) 82 | 83 | ## 1.6.2 84 | 85 | * `DEPS`: support diagram-js@7 86 | 87 | ## 1.6.1 88 | 89 | * `DEPS`: support diagram-js@6 90 | 91 | ## 1.6.0 92 | 93 | * `DEPS`: support diagram-js@5 94 | 95 | ## 1.5.0 96 | 97 | * `DEPS`: support diagram-js@4 98 | 99 | ## 1.4.3 100 | 101 | * `FIX`: prevent injection of HTML and JS evaluation on paste ([#13](https://github.com/bpmn-io/diagram-js-direct-editing/issues/13)) 102 | 103 | ## 1.4.2 104 | 105 | * `FIX`: only trigger update if text or bounds changed ([#11](https://github.com/bpmn-io/diagram-js-direct-editing/pull/11)) 106 | 107 | ## 1.4.1 108 | 109 | * `FIX`: ignore superfluous whitespace around labels 110 | * `FIX`: return correct updated text box bounds 111 | * `CHORE`: only compute text box bounds if actually necessary 112 | 113 | ## 1.4.0 114 | 115 | * `FEAT`: mark as compatible to `diagram-js@3` 116 | * `FEAT`: accept `fontFamily` and `fontWeight` styles 117 | * `CHORE`: use `box-sizing: border-box` for proper computation of 118 | 119 | ## 1.2.2 120 | 121 | _This reverts v1.2.1._ 122 | 123 | ## 1.2.1 124 | 125 | * `FIX`: use `textContent` to retrieve correct editing values in IE 11 ([#5](https://github.com/bpmn-io/diagram-js-direct-editing/issues/5)) 126 | 127 | ## ... 128 | 129 | Check `git log` for earlier history. 130 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2014-2017 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-direct-editing 2 | 3 | [![CI](https://github.com/bpmn-io/diagram-js-direct-editing/workflows/CI/badge.svg)](https://github.com/bpmn-io/diagram-js-direct-editing/actions?query=workflow%3ACI) 4 | 5 | A direct editing box for [diagram-js](https://github.com/bpmn-io/diagram-js). 6 | 7 | ![Direct Editing Demo](./resources/screencast.gif) 8 | 9 | 10 | ## License 11 | 12 | MIT -------------------------------------------------------------------------------- /eslint.config.mjs: -------------------------------------------------------------------------------- 1 | import bpmnIoPlugin from 'eslint-plugin-bpmn-io'; 2 | 3 | const files = { 4 | build: [ 5 | '*.js', 6 | '*.mjs' 7 | ], 8 | test: [ 9 | 'test/**/*.js' 10 | ] 11 | }; 12 | 13 | export default [ 14 | 15 | // build 16 | ...bpmnIoPlugin.configs.node.map((config) => { 17 | return { 18 | ...config, 19 | files: files.build 20 | }; 21 | }), 22 | 23 | // lib + test 24 | ...bpmnIoPlugin.configs.browser.map((config) => { 25 | return { 26 | ...config, 27 | ignores: files.build 28 | }; 29 | }), 30 | 31 | // test 32 | ...bpmnIoPlugin.configs.mocha.map((config) => { 33 | return { 34 | ...config, 35 | files: files.test 36 | }; 37 | }) 38 | ]; -------------------------------------------------------------------------------- /karma.conf.js: -------------------------------------------------------------------------------- 1 | /* eslint-env node */ 2 | 3 | // configures browsers to run test against 4 | // any of [ 'ChromeHeadless', 'Chrome', 'Firefox' ] 5 | const 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 | 13 | karma.set({ 14 | frameworks: [ 15 | 'webpack', 16 | 'mocha', 17 | 'sinon-chai' 18 | ], 19 | 20 | files: [ 21 | 'test/suite.js' 22 | ], 23 | 24 | preprocessors: { 25 | 'test/suite.js': [ 'webpack' ] 26 | }, 27 | 28 | reporters: [ 'progress' ], 29 | 30 | browsers, 31 | 32 | singleRun: true, 33 | autoWatch: false, 34 | 35 | webpack: { 36 | mode: 'development' 37 | } 38 | }); 39 | 40 | }; 41 | -------------------------------------------------------------------------------- /lib/DirectEditing.js: -------------------------------------------------------------------------------- 1 | import { 2 | bind, 3 | find 4 | } from 'min-dash'; 5 | 6 | import TextBox from './TextBox.js'; 7 | 8 | 9 | /** 10 | * A direct editing component that allows users 11 | * to edit an elements text directly in the diagram 12 | * 13 | * @param {EventBus} eventBus the event bus 14 | * @param {Canvas} canvas the canvas 15 | */ 16 | export default function DirectEditing(eventBus, canvas) { 17 | 18 | this._eventBus = eventBus; 19 | this._canvas = canvas; 20 | 21 | this._providers = []; 22 | this._textbox = new TextBox({ 23 | container: canvas.getContainer(), 24 | keyHandler: bind(this._handleKey, this), 25 | resizeHandler: bind(this._handleResize, this) 26 | }); 27 | } 28 | 29 | DirectEditing.$inject = [ 'eventBus', 'canvas' ]; 30 | 31 | 32 | /** 33 | * Register a direct editing provider 34 | 35 | * @param {Object} provider the provider, must expose an #activate(element) method that returns 36 | * an activation context ({ bounds: {x, y, width, height }, text }) if 37 | * direct editing is available for the given element. 38 | * Additionally the provider must expose a #update(element, value) method 39 | * to receive direct editing updates. 40 | */ 41 | DirectEditing.prototype.registerProvider = function(provider) { 42 | this._providers.push(provider); 43 | }; 44 | 45 | 46 | /** 47 | * Returns true if direct editing is currently active 48 | * 49 | * @param {djs.model.Base} [element] 50 | * 51 | * @return {boolean} 52 | */ 53 | DirectEditing.prototype.isActive = function(element) { 54 | return !!(this._active && (!element || this._active.element === element)); 55 | }; 56 | 57 | 58 | /** 59 | * Cancel direct editing, if it is currently active 60 | */ 61 | DirectEditing.prototype.cancel = function() { 62 | if (!this._active) { 63 | return; 64 | } 65 | 66 | this._fire('cancel'); 67 | this.close(); 68 | }; 69 | 70 | 71 | DirectEditing.prototype._fire = function(event, context) { 72 | this._eventBus.fire('directEditing.' + event, context || { active: this._active }); 73 | }; 74 | 75 | DirectEditing.prototype.close = function() { 76 | this._textbox.destroy(); 77 | 78 | this._fire('deactivate'); 79 | 80 | this._active = null; 81 | 82 | this.resizable = undefined; 83 | 84 | // restoreFocus API is available from diagram-js@15.0.0 85 | this._canvas.restoreFocus && this._canvas.restoreFocus(); 86 | }; 87 | 88 | 89 | DirectEditing.prototype.complete = function() { 90 | 91 | var active = this._active; 92 | 93 | if (!active) { 94 | return; 95 | } 96 | 97 | var containerBounds, 98 | previousBounds = active.context.bounds, 99 | newBounds = this.$textbox.getBoundingClientRect(), 100 | newText = this.getValue(), 101 | previousText = active.context.text; 102 | 103 | if ( 104 | newText !== previousText || 105 | newBounds.height !== previousBounds.height || 106 | newBounds.width !== previousBounds.width 107 | ) { 108 | containerBounds = this._textbox.container.getBoundingClientRect(); 109 | 110 | active.provider.update(active.element, newText, active.context.text, { 111 | x: newBounds.left - containerBounds.left, 112 | y: newBounds.top - containerBounds.top, 113 | width: newBounds.width, 114 | height: newBounds.height 115 | }); 116 | } 117 | 118 | this._fire('complete'); 119 | 120 | this.close(); 121 | }; 122 | 123 | 124 | DirectEditing.prototype.getValue = function() { 125 | return this._textbox.getValue(); 126 | }; 127 | 128 | 129 | DirectEditing.prototype._handleKey = function(e) { 130 | 131 | // stop bubble 132 | e.stopPropagation(); 133 | 134 | var key = e.keyCode || e.charCode; 135 | 136 | // ESC 137 | if (key === 27) { 138 | e.preventDefault(); 139 | return this.cancel(); 140 | } 141 | 142 | // Enter 143 | if (key === 13 && !e.shiftKey) { 144 | e.preventDefault(); 145 | return this.complete(); 146 | } 147 | }; 148 | 149 | 150 | DirectEditing.prototype._handleResize = function(event) { 151 | this._fire('resize', event); 152 | }; 153 | 154 | 155 | /** 156 | * Activate direct editing on the given element 157 | * 158 | * @param {Object} ElementDescriptor the descriptor for a shape or connection 159 | * @return {Boolean} true if the activation was possible 160 | */ 161 | DirectEditing.prototype.activate = function(element) { 162 | if (this.isActive()) { 163 | this.cancel(); 164 | } 165 | 166 | // the direct editing context 167 | var context; 168 | 169 | var provider = find(this._providers, function(p) { 170 | return ((context = p.activate(element))) ? p : null; 171 | }); 172 | 173 | // check if activation took place 174 | if (context) { 175 | this.$textbox = this._textbox.create( 176 | context.bounds, 177 | context.style, 178 | context.text, 179 | context.options 180 | ); 181 | 182 | this._active = { 183 | element: element, 184 | context: context, 185 | provider: provider 186 | }; 187 | 188 | if (context.options && context.options.resizable) { 189 | this.resizable = true; 190 | } 191 | 192 | this._fire('activate'); 193 | } 194 | 195 | return !!context; 196 | }; 197 | -------------------------------------------------------------------------------- /lib/TextBox.js: -------------------------------------------------------------------------------- 1 | import { 2 | assign, 3 | bind, 4 | pick 5 | } from 'min-dash'; 6 | 7 | import { 8 | domify, 9 | query as domQuery, 10 | event as domEvent, 11 | remove as domRemove 12 | } from 'min-dom'; 13 | 14 | var min = Math.min, 15 | max = Math.max; 16 | 17 | function preventDefault(e) { 18 | e.preventDefault(); 19 | } 20 | 21 | function stopPropagation(e) { 22 | e.stopPropagation(); 23 | } 24 | 25 | function isTextNode(node) { 26 | return node.nodeType === Node.TEXT_NODE; 27 | } 28 | 29 | function toArray(nodeList) { 30 | return [].slice.call(nodeList); 31 | } 32 | 33 | /** 34 | * Initializes a container for a content editable div. 35 | * 36 | * Structure: 37 | * 38 | * container 39 | * parent 40 | * content 41 | * resize-handle 42 | * 43 | * @param {object} options 44 | * @param {DOMElement} options.container The DOM element to append the contentContainer to 45 | * @param {Function} options.keyHandler Handler for key events 46 | * @param {Function} options.resizeHandler Handler for resize events 47 | */ 48 | export default function TextBox(options) { 49 | this.container = options.container; 50 | 51 | this.parent = domify( 52 | '
' + 53 | '
' + 54 | '
' 55 | ); 56 | 57 | this.content = domQuery('[contenteditable]', this.parent); 58 | 59 | this.keyHandler = options.keyHandler || function() {}; 60 | this.resizeHandler = options.resizeHandler || function() {}; 61 | 62 | this.autoResize = bind(this.autoResize, this); 63 | this.handlePaste = bind(this.handlePaste, this); 64 | } 65 | 66 | 67 | /** 68 | * Create a text box with the given position, size, style and text content 69 | * 70 | * @param {Object} bounds 71 | * @param {Number} bounds.x absolute x position 72 | * @param {Number} bounds.y absolute y position 73 | * @param {Number} [bounds.width] fixed width value 74 | * @param {Number} [bounds.height] fixed height value 75 | * @param {Number} [bounds.maxWidth] maximum width value 76 | * @param {Number} [bounds.maxHeight] maximum height value 77 | * @param {Number} [bounds.minWidth] minimum width value 78 | * @param {Number} [bounds.minHeight] minimum height value 79 | * @param {Object} [style] 80 | * @param {String} value text content 81 | * 82 | * @return {DOMElement} The created content DOM element 83 | */ 84 | TextBox.prototype.create = function(bounds, style, value, options) { 85 | var self = this; 86 | 87 | var parent = this.parent, 88 | content = this.content, 89 | container = this.container; 90 | 91 | options = this.options = options || {}; 92 | 93 | style = this.style = style || {}; 94 | 95 | var parentStyle = pick(style, [ 96 | 'width', 97 | 'height', 98 | 'maxWidth', 99 | 'maxHeight', 100 | 'minWidth', 101 | 'minHeight', 102 | 'left', 103 | 'top', 104 | 'backgroundColor', 105 | 'position', 106 | 'overflow', 107 | 'border', 108 | 'wordWrap', 109 | 'textAlign', 110 | 'outline', 111 | 'transform' 112 | ]); 113 | 114 | assign(parent.style, { 115 | width: bounds.width + 'px', 116 | height: bounds.height + 'px', 117 | maxWidth: bounds.maxWidth + 'px', 118 | maxHeight: bounds.maxHeight + 'px', 119 | minWidth: bounds.minWidth + 'px', 120 | minHeight: bounds.minHeight + 'px', 121 | left: bounds.x + 'px', 122 | top: bounds.y + 'px', 123 | backgroundColor: '#ffffff', 124 | position: 'absolute', 125 | overflow: 'visible', 126 | border: '1px solid #ccc', 127 | boxSizing: 'border-box', 128 | wordWrap: 'normal', 129 | textAlign: 'center', 130 | outline: 'none' 131 | }, parentStyle); 132 | 133 | var contentStyle = pick(style, [ 134 | 'fontFamily', 135 | 'fontSize', 136 | 'fontWeight', 137 | 'lineHeight', 138 | 'padding', 139 | 'paddingTop', 140 | 'paddingRight', 141 | 'paddingBottom', 142 | 'paddingLeft' 143 | ]); 144 | 145 | assign(content.style, { 146 | boxSizing: 'border-box', 147 | width: '100%', 148 | outline: 'none', 149 | wordWrap: 'break-word' 150 | }, contentStyle); 151 | 152 | if (options.centerVertically) { 153 | assign(content.style, { 154 | position: 'absolute', 155 | top: '50%', 156 | transform: 'translate(0, -50%)' 157 | }, contentStyle); 158 | } 159 | 160 | content.innerText = value; 161 | 162 | domEvent.bind(content, 'keydown', this.keyHandler); 163 | domEvent.bind(content, 'mousedown', stopPropagation); 164 | domEvent.bind(content, 'paste', self.handlePaste); 165 | 166 | if (options.autoResize) { 167 | domEvent.bind(content, 'input', this.autoResize); 168 | } 169 | 170 | if (options.resizable) { 171 | this.resizable(style); 172 | } 173 | 174 | container.appendChild(parent); 175 | 176 | // set selection to end of text 177 | this.setSelection(content.lastChild, content.lastChild && content.lastChild.length); 178 | 179 | return parent; 180 | }; 181 | 182 | /** 183 | * Intercept paste events to remove formatting from pasted text. 184 | */ 185 | TextBox.prototype.handlePaste = function(e) { 186 | var options = this.options, 187 | style = this.style; 188 | 189 | e.preventDefault(); 190 | 191 | var text; 192 | 193 | if (e.clipboardData) { 194 | 195 | // Chrome, Firefox, Safari 196 | text = e.clipboardData.getData('text/plain'); 197 | } else { 198 | 199 | // Internet Explorer 200 | text = window.clipboardData.getData('Text'); 201 | } 202 | 203 | this.insertText(text); 204 | 205 | if (options.autoResize) { 206 | var hasResized = this.autoResize(style); 207 | 208 | if (hasResized) { 209 | this.resizeHandler(hasResized); 210 | } 211 | } 212 | }; 213 | 214 | TextBox.prototype.insertText = function(text) { 215 | text = normalizeEndOfLineSequences(text); 216 | 217 | // insertText command not supported by Internet Explorer 218 | var success = document.execCommand('insertText', false, text); 219 | 220 | if (success) { 221 | return; 222 | } 223 | 224 | this._insertTextIE(text); 225 | }; 226 | 227 | TextBox.prototype._insertTextIE = function(text) { 228 | 229 | // Internet Explorer 230 | var range = this.getSelection(), 231 | startContainer = range.startContainer, 232 | endContainer = range.endContainer, 233 | startOffset = range.startOffset, 234 | endOffset = range.endOffset, 235 | commonAncestorContainer = range.commonAncestorContainer; 236 | 237 | var childNodesArray = toArray(commonAncestorContainer.childNodes); 238 | 239 | var container, 240 | offset; 241 | 242 | if (isTextNode(commonAncestorContainer)) { 243 | var containerTextContent = startContainer.textContent; 244 | 245 | startContainer.textContent = 246 | containerTextContent.substring(0, startOffset) 247 | + text 248 | + containerTextContent.substring(endOffset); 249 | 250 | container = startContainer; 251 | offset = startOffset + text.length; 252 | 253 | } else if (startContainer === this.content && endContainer === this.content) { 254 | var textNode = document.createTextNode(text); 255 | 256 | this.content.insertBefore(textNode, childNodesArray[startOffset]); 257 | 258 | container = textNode; 259 | offset = textNode.textContent.length; 260 | } else { 261 | var startContainerChildIndex = childNodesArray.indexOf(startContainer), 262 | endContainerChildIndex = childNodesArray.indexOf(endContainer); 263 | 264 | childNodesArray.forEach(function(childNode, index) { 265 | 266 | if (index === startContainerChildIndex) { 267 | childNode.textContent = 268 | startContainer.textContent.substring(0, startOffset) + 269 | text + 270 | endContainer.textContent.substring(endOffset); 271 | } else if (index > startContainerChildIndex && index <= endContainerChildIndex) { 272 | domRemove(childNode); 273 | } 274 | }); 275 | 276 | container = startContainer; 277 | offset = startOffset + text.length; 278 | } 279 | 280 | if (container && offset !== undefined) { 281 | 282 | // is necessary in Internet Explorer 283 | setTimeout(function() { 284 | self.setSelection(container, offset); 285 | }); 286 | } 287 | }; 288 | 289 | /** 290 | * Automatically resize element vertically to fit its content. 291 | */ 292 | TextBox.prototype.autoResize = function() { 293 | var parent = this.parent, 294 | content = this.content; 295 | 296 | var fontSize = parseInt(this.style.fontSize) || 12; 297 | 298 | if (content.scrollHeight > parent.offsetHeight || 299 | content.scrollHeight < parent.offsetHeight - fontSize) { 300 | var bounds = parent.getBoundingClientRect(); 301 | 302 | var height = content.scrollHeight; 303 | parent.style.height = height + 'px'; 304 | 305 | this.resizeHandler({ 306 | width: bounds.width, 307 | height: bounds.height, 308 | dx: 0, 309 | dy: height - bounds.height 310 | }); 311 | } 312 | }; 313 | 314 | /** 315 | * Make an element resizable by adding a resize handle. 316 | */ 317 | TextBox.prototype.resizable = function() { 318 | var self = this; 319 | 320 | var parent = this.parent, 321 | resizeHandle = this.resizeHandle; 322 | 323 | var minWidth = parseInt(this.style.minWidth) || 0, 324 | minHeight = parseInt(this.style.minHeight) || 0, 325 | maxWidth = parseInt(this.style.maxWidth) || Infinity, 326 | maxHeight = parseInt(this.style.maxHeight) || Infinity; 327 | 328 | if (!resizeHandle) { 329 | resizeHandle = this.resizeHandle = domify( 330 | '
' 331 | ); 332 | 333 | var startX, startY, startWidth, startHeight; 334 | 335 | var onMouseDown = function(e) { 336 | preventDefault(e); 337 | stopPropagation(e); 338 | 339 | startX = e.clientX; 340 | startY = e.clientY; 341 | 342 | var bounds = parent.getBoundingClientRect(); 343 | 344 | startWidth = bounds.width; 345 | startHeight = bounds.height; 346 | 347 | domEvent.bind(document, 'mousemove', onMouseMove); 348 | domEvent.bind(document, 'mouseup', onMouseUp); 349 | }; 350 | 351 | var onMouseMove = function(e) { 352 | preventDefault(e); 353 | stopPropagation(e); 354 | 355 | var newWidth = min(max(startWidth + e.clientX - startX, minWidth), maxWidth); 356 | var newHeight = min(max(startHeight + e.clientY - startY, minHeight), maxHeight); 357 | 358 | parent.style.width = newWidth + 'px'; 359 | parent.style.height = newHeight + 'px'; 360 | 361 | self.resizeHandler({ 362 | width: startWidth, 363 | height: startHeight, 364 | dx: e.clientX - startX, 365 | dy: e.clientY - startY 366 | }); 367 | }; 368 | 369 | var onMouseUp = function(e) { 370 | preventDefault(e); 371 | stopPropagation(e); 372 | 373 | domEvent.unbind(document,'mousemove', onMouseMove, false); 374 | domEvent.unbind(document, 'mouseup', onMouseUp, false); 375 | }; 376 | 377 | domEvent.bind(resizeHandle, 'mousedown', onMouseDown); 378 | } 379 | 380 | assign(resizeHandle.style, { 381 | position: 'absolute', 382 | bottom: '0px', 383 | right: '0px', 384 | cursor: 'nwse-resize', 385 | width: '0', 386 | height: '0', 387 | borderTop: (parseInt(this.style.fontSize) / 4 || 3) + 'px solid transparent', 388 | borderRight: (parseInt(this.style.fontSize) / 4 || 3) + 'px solid #ccc', 389 | borderBottom: (parseInt(this.style.fontSize) / 4 || 3) + 'px solid #ccc', 390 | borderLeft: (parseInt(this.style.fontSize) / 4 || 3) + 'px solid transparent' 391 | }); 392 | 393 | parent.appendChild(resizeHandle); 394 | }; 395 | 396 | 397 | /** 398 | * Clear content and style of the textbox, unbind listeners and 399 | * reset CSS style. 400 | */ 401 | TextBox.prototype.destroy = function() { 402 | var parent = this.parent, 403 | content = this.content, 404 | resizeHandle = this.resizeHandle; 405 | 406 | // clear content 407 | content.innerText = ''; 408 | 409 | // clear styles 410 | parent.removeAttribute('style'); 411 | content.removeAttribute('style'); 412 | 413 | domEvent.unbind(content, 'keydown', this.keyHandler); 414 | domEvent.unbind(content, 'mousedown', stopPropagation); 415 | domEvent.unbind(content, 'input', this.autoResize); 416 | domEvent.unbind(content, 'paste', this.handlePaste); 417 | 418 | if (resizeHandle) { 419 | resizeHandle.removeAttribute('style'); 420 | 421 | domRemove(resizeHandle); 422 | } 423 | 424 | domRemove(parent); 425 | }; 426 | 427 | 428 | TextBox.prototype.getValue = function() { 429 | return this.content.innerText.trim(); 430 | }; 431 | 432 | 433 | TextBox.prototype.getSelection = function() { 434 | var selection = window.getSelection(), 435 | range = selection.getRangeAt(0); 436 | 437 | return range; 438 | }; 439 | 440 | 441 | TextBox.prototype.setSelection = function(container, offset) { 442 | var range = document.createRange(); 443 | 444 | if (container === null) { 445 | range.selectNodeContents(this.content); 446 | } else { 447 | range.setStart(container, offset); 448 | range.setEnd(container, offset); 449 | } 450 | 451 | var selection = window.getSelection(); 452 | 453 | selection.removeAllRanges(); 454 | selection.addRange(range); 455 | }; 456 | 457 | // helpers ////////// 458 | 459 | function normalizeEndOfLineSequences(string) { 460 | return string.replace(/\r\n|\r|\n/g, '\n'); 461 | } -------------------------------------------------------------------------------- /lib/index.js: -------------------------------------------------------------------------------- 1 | import InteractionEventsModule from 'diagram-js/lib/features/interaction-events'; 2 | 3 | import DirectEditing from './DirectEditing.js'; 4 | 5 | export default { 6 | __depends__: [ 7 | InteractionEventsModule 8 | ], 9 | __init__: [ 'directEditing' ], 10 | directEditing: [ 'type', DirectEditing ] 11 | }; -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "diagram-js-direct-editing", 3 | "version": "3.2.0", 4 | "description": "Direct editing support for diagram-js", 5 | "scripts": { 6 | "all": "run-s lint test", 7 | "dev": "npm run test -- --auto-watch --no-single-run", 8 | "lint": "eslint .", 9 | "test": "karma start" 10 | }, 11 | "repository": { 12 | "type": "git", 13 | "url": "https://github.com/bpmn-io/diagram-js-direct-editing" 14 | }, 15 | "keywords": [ 16 | "diagram-js", 17 | "diagram-js-plugin" 18 | ], 19 | "engines": { 20 | "node": "*" 21 | }, 22 | "main": "lib/index.js", 23 | "module": "lib/index.js", 24 | "author": "bpmn.io", 25 | "license": "MIT", 26 | "devDependencies": { 27 | "chai": "^4.3.10", 28 | "diagram-js": "^15.0.0", 29 | "eslint": "^9.12.0", 30 | "eslint-plugin-bpmn-io": "^2.0.2", 31 | "karma": "^6.4.2", 32 | "karma-chrome-launcher": "^3.2.0", 33 | "karma-firefox-launcher": "^2.1.2", 34 | "karma-mocha": "^2.0.1", 35 | "karma-sinon-chai": "^2.0.2", 36 | "karma-webpack": "^5.0.0", 37 | "mocha": "^10.2.0", 38 | "mocha-test-container-support": "^0.2.0", 39 | "npm-run-all2": "^8.0.0", 40 | "puppeteer": "^24.0.0", 41 | "sinon": "^17.0.1", 42 | "sinon-chai": "^3.7.0", 43 | "webpack": "^5.89.0" 44 | }, 45 | "dependencies": { 46 | "min-dash": "^4.0.0", 47 | "min-dom": "^4.2.1" 48 | }, 49 | "peerDependencies": { 50 | "diagram-js": "*" 51 | }, 52 | "files": [ 53 | "lib" 54 | ] 55 | } 56 | -------------------------------------------------------------------------------- /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/screencast.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bpmn-io/diagram-js-direct-editing/a5e111e0ef2f14914084fdeb9178c92ef483e73b/resources/screencast.gif -------------------------------------------------------------------------------- /test/DirectEditingProvider.js: -------------------------------------------------------------------------------- 1 | import { 2 | assign 3 | } from 'min-dash'; 4 | 5 | 6 | export default function DirectEditingProvider(directEditing) { 7 | directEditing.registerProvider(this); 8 | } 9 | 10 | DirectEditingProvider.$inject = [ 'directEditing' ]; 11 | 12 | DirectEditingProvider.prototype.activate = function(element) { 13 | var context = {}; 14 | 15 | if (element.label) { 16 | assign(context, { 17 | bounds: element.labelBounds || element, 18 | text: element.label 19 | }); 20 | 21 | assign(context, { 22 | options: this.options || {} 23 | }); 24 | 25 | return context; 26 | } 27 | }; 28 | 29 | DirectEditingProvider.prototype.update = function(element, text, oldText, bounds) { 30 | element.label = text; 31 | 32 | var labelBounds = element.labelBounds || element; 33 | 34 | assign(labelBounds, bounds); 35 | }; 36 | 37 | DirectEditingProvider.prototype.setOptions = function(options) { 38 | this.options = options; 39 | }; 40 | -------------------------------------------------------------------------------- /test/DirectEditingSpec.js: -------------------------------------------------------------------------------- 1 | /* global sinon */ 2 | 3 | import { 4 | bootstrapDiagram, 5 | inject 6 | } from 'diagram-js/test/helper'; 7 | 8 | import { 9 | forEach 10 | } from 'min-dash'; 11 | 12 | import directEditingModule from '..'; 13 | 14 | import DirectEditingProvider from './DirectEditingProvider'; 15 | 16 | 17 | var DELTA = 2; 18 | 19 | 20 | function triggerMouseEvent(element, event, clientX, clientY) { 21 | var e = document.createEvent('MouseEvent'); 22 | 23 | if (e.initMouseEvent) { 24 | e.initMouseEvent(event, true, true, window, 0, 0, 0, clientX, clientY, false, false, false, false, 0, null); 25 | } 26 | 27 | element.dispatchEvent(e); 28 | } 29 | 30 | function triggerKeyEvent(element, event, code) { 31 | var e = document.createEvent('Events'); 32 | 33 | if (e.initEvent) { 34 | e.initEvent(event, true, true); 35 | } 36 | 37 | e.keyCode = code; 38 | e.which = code; 39 | 40 | element.dispatchEvent(e); 41 | } 42 | 43 | function expectEditingActive(directEditing, parentBounds, contentBounds) { 44 | expect(directEditing.isActive()).to.eql(true); 45 | 46 | var parent = directEditing._textbox.parent, 47 | content = directEditing._textbox.content; 48 | 49 | expect(parent.className).to.eql('djs-direct-editing-parent'); 50 | expect(content.className).to.eql('djs-direct-editing-content'); 51 | 52 | forEach(parentBounds, function(val, key) { 53 | expect(parseInt(parent['offset' + key.charAt(0).toUpperCase() + key.slice(1)])).to.be.closeTo(val, DELTA); 54 | }); 55 | 56 | if (contentBounds) { 57 | forEach(contentBounds, function(val, key) { 58 | expect(content['offset' + key.charAt(0).toUpperCase() + key.slice(1)]).to.be.closeTo(val, DELTA); 59 | }); 60 | } 61 | } 62 | 63 | 64 | describe('diagram-js-direct-editing', function() { 65 | 66 | 67 | describe('bootstrap', function() { 68 | 69 | beforeEach(bootstrapDiagram({ 70 | modules: [ directEditingModule ] 71 | })); 72 | 73 | it('should bootstrap diagram with component', inject(function() { })); 74 | 75 | }); 76 | 77 | 78 | describe('behavior', function() { 79 | 80 | var providerModule = { 81 | __init__: [ 'directEditingProvider' ], 82 | __depends__: [ directEditingModule ], 83 | directEditingProvider: [ 'type', DirectEditingProvider ] 84 | }; 85 | 86 | beforeEach(bootstrapDiagram({ 87 | modules: [ providerModule ] 88 | })); 89 | 90 | afterEach(inject(function(directEditingProvider) { 91 | directEditingProvider.setOptions(undefined); 92 | sinon.restore(); 93 | })); 94 | 95 | 96 | it('should register provider', inject(function(directEditing) { 97 | expect(directEditing._providers[0] instanceof DirectEditingProvider).to.eql(true); 98 | })); 99 | 100 | 101 | describe('controlled by provider', function() { 102 | 103 | it('should activate', inject(function(canvas, directEditing) { 104 | 105 | // given 106 | var shapeWithLabel = { 107 | id: 's1', 108 | x: 20, y: 10, width: 60, height: 50, 109 | label: 'FOO\nBAR' 110 | }; 111 | canvas.addShape(shapeWithLabel); 112 | 113 | var otherShape = { 114 | id: 's2', 115 | x: 220, y: 10, width: 60, height: 50, 116 | label: 'other' 117 | }; 118 | canvas.addShape(otherShape); 119 | 120 | // when 121 | var activated = directEditing.activate(shapeWithLabel); 122 | 123 | // then 124 | expect(activated).to.eql(true); 125 | 126 | expect(directEditing.isActive()).to.eql(true); 127 | expect(directEditing.isActive(shapeWithLabel)).to.eql(true); 128 | expect(directEditing.isActive(otherShape)).to.eql(false); 129 | 130 | expect(directEditing.getValue()).to.eql('FOO\nBAR'); 131 | 132 | var parentBounds = { 133 | left: 20, 134 | top: 10, 135 | width: 60, 136 | height: 50 137 | }; 138 | 139 | var contentBounds = { 140 | top: 0, 141 | left: 0, 142 | width: 60, 143 | height: 38 144 | }; 145 | 146 | // textbox is correctly positioned 147 | expectEditingActive(directEditing, parentBounds, contentBounds); 148 | })); 149 | 150 | 151 | it('should activate with custom bounds', inject(function(canvas, directEditing) { 152 | 153 | // given 154 | var shapeWithLabel = { 155 | id: 's1', 156 | x: 20, y: 10, width: 60, height: 50, 157 | label: 'FOO', 158 | labelBounds: { x: 100, y: 100, width: 50, height: 20 } 159 | }; 160 | canvas.addShape(shapeWithLabel); 161 | 162 | // when 163 | var activated = directEditing.activate(shapeWithLabel); 164 | 165 | // then 166 | expect(activated).to.eql(true); 167 | 168 | var parentBounds = { left: 100, top: 100, width: 50, height: 20 }, 169 | contentBounds = { left: 0, top: 0, width: 50, height: 18 }; 170 | 171 | // textbox is correctly positioned 172 | expectEditingActive(directEditing, parentBounds, contentBounds); 173 | })); 174 | 175 | 176 | it('should activate with vertically centered text', inject(function(canvas, directEditing, directEditingProvider) { 177 | 178 | // given 179 | var shapeWithLabel = { 180 | id: 's1', 181 | x: 20, y: 10, width: 60, height: 50, 182 | label: 'FOO' 183 | }; 184 | canvas.addShape(shapeWithLabel); 185 | 186 | directEditingProvider.setOptions({ centerVertically: true }); 187 | 188 | // when 189 | var activated = directEditing.activate(shapeWithLabel); 190 | 191 | // then 192 | expect(activated).to.eql(true); 193 | expect(directEditing.getValue()).to.eql('FOO'); 194 | 195 | var parentBounds = { left: 20, top: 10, width: 60, height: 50 }, 196 | contentBounds = { left: 0, top: 25, width: 60, height: 18 }; 197 | 198 | // textbox is correctly positioned 199 | expectEditingActive(directEditing, parentBounds, contentBounds); 200 | })); 201 | 202 | 203 | it('should NOT activate', inject(function(canvas, directEditing) { 204 | 205 | // given 206 | var shapeNoLabel = { 207 | id: 's1', 208 | x: 20, y: 10, width: 60, height: 50 209 | }; 210 | canvas.addShape(shapeNoLabel); 211 | 212 | // when 213 | var activated = directEditing.activate(shapeNoLabel); 214 | 215 | // then 216 | expect(activated).to.eql(false); 217 | expect(directEditing.isActive()).to.eql(false); 218 | expect(directEditing.isActive(shapeNoLabel)).to.eql(false); 219 | })); 220 | 221 | 222 | it('should cancel', inject(function(canvas, directEditing) { 223 | 224 | // given 225 | var shapeWithLabel = { 226 | id: 's1', 227 | x: 20, y: 10, width: 60, height: 50, 228 | label: 'FOO' 229 | }; 230 | canvas.addShape(shapeWithLabel); 231 | 232 | directEditing.activate(shapeWithLabel); 233 | 234 | 235 | // when 236 | directEditing.cancel(); 237 | 238 | // then 239 | expect(directEditing.isActive()).to.eql(false); 240 | expect(directEditing.isActive(shapeWithLabel)).to.eql(false); 241 | 242 | // textbox is detached (invisible) 243 | expect(directEditing._textbox.parent.parentNode).not.to.exist; 244 | })); 245 | 246 | 247 | it('should cancel via ESC', inject(function(canvas, directEditing) { 248 | 249 | // given 250 | var shapeWithLabel = { 251 | id: 's1', 252 | x: 20, y: 10, width: 60, height: 50, 253 | label: 'FOO' 254 | }; 255 | canvas.addShape(shapeWithLabel); 256 | 257 | var textbox = directEditing._textbox; 258 | 259 | directEditing.activate(shapeWithLabel); 260 | 261 | // when pressing ESC 262 | triggerKeyEvent(textbox.content, 'keydown', 27); 263 | 264 | // then 265 | expect(directEditing.isActive()).to.eql(false); 266 | 267 | // textbox container is detached (invisible) 268 | expect(textbox.parent.parentNode).not.to.exist; 269 | })); 270 | 271 | 272 | it('should complete + update label via ENTER', inject(function(canvas, directEditing) { 273 | 274 | // given 275 | var shapeWithLabel = { 276 | id: 's1', 277 | x: 20, y: 10, width: 60, height: 50, 278 | label: 'FOO' 279 | }; 280 | canvas.addShape(shapeWithLabel); 281 | 282 | var textbox = directEditing._textbox; 283 | 284 | directEditing.activate(shapeWithLabel); 285 | 286 | textbox.content.innerText = 'BAR'; 287 | 288 | // when pressing Enter 289 | triggerKeyEvent(textbox.content, 'keydown', 13); 290 | 291 | // then 292 | expect(directEditing.isActive()).to.eql(false); 293 | 294 | // textbox is detached (invisible) 295 | expect(textbox.parent.parentNode).not.to.exist; 296 | 297 | expect(shapeWithLabel.label).to.eql('BAR'); 298 | })); 299 | 300 | 301 | it('should complete with unchanged bounds', inject(function(canvas, directEditing) { 302 | 303 | var labelBounds = { x: 100, y: 200, width: 300, height: 20 }; 304 | 305 | var shapeWithLabel = { 306 | id: 's1', 307 | x: 20, y: 10, width: 60, height: 50, 308 | label: 'FOO', 309 | labelBounds: labelBounds 310 | }; 311 | 312 | canvas.addShape(shapeWithLabel); 313 | 314 | var textbox = directEditing._textbox; 315 | 316 | directEditing.activate(shapeWithLabel); 317 | 318 | textbox.content.innerText = 'BAR'; 319 | 320 | directEditing.complete(); 321 | 322 | var bounds = shapeWithLabel.labelBounds; 323 | 324 | expect(bounds).to.eql(labelBounds); 325 | })); 326 | 327 | 328 | it('should complete with changed bounds', inject(function(canvas, directEditing) { 329 | 330 | var labelBounds = { x: 100, y: 200, width: 300, height: 20 }; 331 | 332 | var shapeWithLabel = { 333 | id: 's1', 334 | x: 20, y: 10, width: 60, height: 50, 335 | label: 'FOO', 336 | labelBounds: labelBounds 337 | }; 338 | 339 | canvas.addShape(shapeWithLabel); 340 | 341 | var textbox = directEditing._textbox; 342 | 343 | directEditing.activate(shapeWithLabel); 344 | 345 | textbox.content.innerText = 'BAR'; 346 | textbox.parent.style.left = '60px'; 347 | textbox.parent.style.top = '80px'; 348 | textbox.parent.style.width = '50px'; 349 | textbox.parent.style.height = '100px'; 350 | 351 | directEditing.complete(); 352 | 353 | var bounds = shapeWithLabel.labelBounds; 354 | 355 | expect(bounds).to.eql({ 356 | x: 60, y: 80, width: 50, height: 100 357 | }); 358 | })); 359 | 360 | 361 | it('should update with changed text', inject( 362 | function(canvas, directEditing, directEditingProvider) { 363 | 364 | // given 365 | sinon.spy(directEditingProvider, 'update'); 366 | 367 | var shapeWithLabel = { 368 | id: 's1', 369 | x: 20, y: 10, width: 60, height: 50, 370 | label: 'FOO', 371 | labelBounds: { x: 100, y: 200, width: 300, height: 20 } 372 | }; 373 | 374 | canvas.addShape(shapeWithLabel); 375 | 376 | // when 377 | directEditing.activate(shapeWithLabel); 378 | 379 | var textbox = directEditing._textbox; 380 | 381 | textbox.content.innerText = 'BAR'; 382 | 383 | directEditing.complete(); 384 | 385 | // then 386 | expect(directEditingProvider.update).to.have.been.calledOnce; 387 | } 388 | )); 389 | 390 | 391 | it('should update with changed bounds height', inject( 392 | function(canvas, directEditing, directEditingProvider) { 393 | 394 | // given 395 | sinon.spy(directEditingProvider, 'update'); 396 | 397 | var shapeWithLabel = { 398 | id: 's1', 399 | x: 20, y: 10, width: 60, height: 50, 400 | label: 'FOO', 401 | labelBounds: { x: 100, y: 200, width: 300, height: 20 } 402 | }; 403 | 404 | canvas.addShape(shapeWithLabel); 405 | 406 | // when 407 | directEditing.activate(shapeWithLabel); 408 | 409 | var textbox = directEditing._textbox; 410 | 411 | textbox.parent.style.height = '21px'; 412 | 413 | directEditing.complete(); 414 | 415 | // then 416 | expect(directEditingProvider.update).to.have.been.calledOnce; 417 | } 418 | )); 419 | 420 | 421 | it('should update with changed bounds width', inject( 422 | function(canvas, directEditing, directEditingProvider) { 423 | 424 | // given 425 | sinon.spy(directEditingProvider, 'update'); 426 | 427 | var shapeWithLabel = { 428 | id: 's1', 429 | x: 20, y: 10, width: 60, height: 50, 430 | label: 'FOO', 431 | labelBounds: { x: 100, y: 200, width: 300, height: 20 } 432 | }; 433 | 434 | canvas.addShape(shapeWithLabel); 435 | 436 | // when 437 | directEditing.activate(shapeWithLabel); 438 | 439 | var textbox = directEditing._textbox; 440 | 441 | textbox.parent.style.width = '301px'; 442 | 443 | directEditing.complete(); 444 | 445 | // then 446 | expect(directEditingProvider.update).to.have.been.calledOnce; 447 | } 448 | )); 449 | 450 | 451 | it('should NOT update with unchanged text or bounds height/width', inject( 452 | function(canvas, directEditing, directEditingProvider) { 453 | 454 | // given 455 | sinon.spy(directEditingProvider, 'update'); 456 | 457 | var shapeWithLabel = { 458 | id: 's1', 459 | x: 20, y: 10, width: 60, height: 50, 460 | label: 'FOO', 461 | labelBounds: { x: 100, y: 200, width: 300, height: 20 } 462 | }; 463 | 464 | canvas.addShape(shapeWithLabel); 465 | 466 | // when 467 | directEditing.activate(shapeWithLabel); 468 | 469 | directEditing.complete(); 470 | 471 | // then 472 | expect(directEditingProvider.update).to.not.have.been.called; 473 | } 474 | )); 475 | 476 | }); 477 | 478 | 479 | describe('textbox', function() { 480 | 481 | it('should init label on open', inject(function(canvas, directEditing) { 482 | 483 | // given 484 | var shape = { 485 | id: 's1', 486 | x: 20, y: 10, width: 60, height: 50, 487 | label: 'FOO' 488 | }; 489 | canvas.addShape(shape); 490 | 491 | // when 492 | directEditing.activate(shape); 493 | 494 | // then 495 | expect(directEditing._textbox.content.innerText).to.eql('FOO'); 496 | 497 | })); 498 | 499 | 500 | it('should clear label after close', inject(function(canvas, directEditing) { 501 | 502 | // given 503 | var shape = { 504 | id: 's1', 505 | x: 20, y: 10, width: 60, height: 50, 506 | label: 'FOO' 507 | }; 508 | canvas.addShape(shape); 509 | 510 | // when 511 | directEditing.activate(shape); 512 | directEditing.cancel(); 513 | 514 | // then 515 | expect(directEditing._textbox.content.innerText).to.eql(''); 516 | })); 517 | 518 | 519 | it('should trim label when getting value', inject(function(canvas, directEditing) { 520 | 521 | // given 522 | var shape = { 523 | id: 's1', 524 | x: 20, y: 10, width: 60, height: 50, 525 | label: '\nFOO\n' 526 | }; 527 | canvas.addShape(shape); 528 | 529 | // when 530 | directEditing.activate(shape); 531 | 532 | // then 533 | expect(directEditing.getValue()).to.eql('FOO'); 534 | })); 535 | 536 | 537 | it('should show resize handle if resizable', inject(function(canvas, directEditing, directEditingProvider) { 538 | 539 | // given 540 | var shape = { 541 | id: 's1', 542 | x: 20, y: 10, width: 60, height: 50, 543 | label: 'FOO' 544 | }; 545 | canvas.addShape(shape); 546 | 547 | directEditingProvider.setOptions({ resizable: true }); 548 | 549 | // when 550 | directEditing.activate(shape); 551 | 552 | // then 553 | var resizeHandle = directEditing._textbox.parent.getElementsByClassName('djs-direct-editing-resize-handle')[0]; 554 | 555 | expect(resizeHandle).to.exist; 556 | })); 557 | 558 | 559 | it('should not show resize handle if not resizable', inject(function(canvas, directEditing) { 560 | 561 | // given 562 | var shape = { 563 | id: 's1', 564 | x: 20, y: 10, width: 60, height: 50, 565 | label: 'FOO' 566 | }; 567 | canvas.addShape(shape); 568 | 569 | // when 570 | directEditing.activate(shape); 571 | 572 | // then 573 | var resizeHandle = directEditing._textbox.parent.getElementsByClassName('djs-direct-editing-resize-handle')[0]; 574 | 575 | expect(resizeHandle).not.to.exist; 576 | })); 577 | 578 | 579 | it('should resize automatically', inject(function(canvas, directEditing, directEditingProvider) { 580 | 581 | // given 582 | var shape = { 583 | id: 's1', 584 | x: 20, y: 10, width: 60, height: 50, 585 | label: 'FOO\nBAR\nBAZ\nFOO\nBAR\nBAZ' 586 | }; 587 | canvas.addShape(shape); 588 | 589 | directEditingProvider.setOptions({ autoResize: true }); 590 | 591 | directEditing.activate(shape); 592 | 593 | var parent = directEditing._textbox.parent, 594 | content = directEditing._textbox.content; 595 | 596 | // when 597 | triggerKeyEvent(directEditing._textbox.content, 'input', 65); 598 | 599 | // then 600 | var parentHeight = parent.getBoundingClientRect().height, 601 | contentScrollHeight = content.scrollHeight; 602 | 603 | expect(parentHeight).to.be.closeTo(contentScrollHeight, DELTA); 604 | })); 605 | 606 | 607 | it('should resize on resize handle drag', inject(function(canvas, directEditing, directEditingProvider) { 608 | 609 | // given 610 | var shape = { 611 | id: 's1', 612 | x: 20, y: 10, width: 60, height: 50, 613 | label: 'FOO' 614 | }; 615 | canvas.addShape(shape); 616 | 617 | directEditingProvider.setOptions({ resizable: true }); 618 | 619 | directEditing.activate(shape); 620 | 621 | var parent = directEditing._textbox.parent, 622 | resizeHandle = directEditing._textbox.resizeHandle; 623 | 624 | // when 625 | var oldParentBounds = parent.getBoundingClientRect(), 626 | resizeHandleBounds = resizeHandle.getBoundingClientRect(); 627 | 628 | var clientX = resizeHandleBounds.left + resizeHandleBounds.width / 2, 629 | clientY = resizeHandleBounds.top + resizeHandleBounds.height / 2; 630 | 631 | triggerMouseEvent(resizeHandle, 'mousedown', clientX, clientY); 632 | triggerMouseEvent(resizeHandle, 'mousemove', clientX + 100, clientY + 100); 633 | triggerMouseEvent(resizeHandle, 'mouseup', clientX + 100, clientY + 100); 634 | 635 | // then 636 | var newParentBounds = parent.getBoundingClientRect(); 637 | 638 | expect(newParentBounds.width).to.be.closeTo(oldParentBounds.width + 100, DELTA); 639 | expect(newParentBounds.height).to.be.closeTo(oldParentBounds.height + 100, DELTA); 640 | })); 641 | 642 | 643 | it('should not insert HTML', inject(function(canvas, directEditing) { 644 | 645 | // given 646 | var shape = { 647 | id: 's1', 648 | x: 20, y: 10, width: 60, height: 50, 649 | label: 'FOO' 650 | }; 651 | canvas.addShape(shape); 652 | 653 | directEditing.activate(shape); 654 | 655 | var textBox = directEditing._textbox; 656 | 657 | // when 658 | textBox.insertText('