├── .gitignore ├── resources └── screencast.gif ├── test ├── globals.js ├── suite.js ├── DirectEditingProvider.js └── DirectEditingSpec.js ├── renovate.json ├── lib ├── index.js ├── DirectEditing.js └── TextBox.js ├── README.md ├── eslint.config.mjs ├── .github └── workflows │ └── CI.yml ├── karma.conf.js ├── LICENSE ├── package.json └── CHANGELOG.md /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | .idea/ -------------------------------------------------------------------------------- /resources/screencast.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bpmn-io/diagram-js-direct-editing/HEAD/resources/screencast.gif -------------------------------------------------------------------------------- /test/globals.js: -------------------------------------------------------------------------------- 1 | import { 2 | use 3 | } from 'chai'; 4 | 5 | import sinonChai from 'sinon-chai'; 6 | 7 | use(sinonChai); 8 | -------------------------------------------------------------------------------- /test/suite.js: -------------------------------------------------------------------------------- 1 | /* global require */ 2 | 3 | import('./globals'); 4 | 5 | var allTests = require.context('.', true, /Spec\.js$/); 6 | 7 | allTests.keys().forEach(allTests); -------------------------------------------------------------------------------- /renovate.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://docs.renovatebot.com/renovate-schema.json", 3 | "extends": [ 4 | "github>bpmn-io/renovate-config:recommended" 5 | ] 6 | } 7 | -------------------------------------------------------------------------------- /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 | }; -------------------------------------------------------------------------------- /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 | ]; -------------------------------------------------------------------------------- /.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@v6 16 | - name: Use Node.js 24 17 | uses: actions/setup-node@v6 18 | with: 19 | node-version: 24 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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /karma.conf.js: -------------------------------------------------------------------------------- 1 | // configures browsers to run test against 2 | // any of [ 'ChromeHeadless', 'Chrome', 'Firefox' ] 3 | const browsers = (process.env.TEST_BROWSERS || 'ChromeHeadless').split(','); 4 | 5 | // use puppeteer provided Chrome for testing 6 | process.env.CHROME_BIN = require('puppeteer').executablePath(); 7 | 8 | 9 | module.exports = function(karma) { 10 | 11 | karma.set({ 12 | frameworks: [ 13 | 'webpack', 14 | 'mocha' 15 | ], 16 | 17 | files: [ 18 | 'test/suite.js' 19 | ], 20 | 21 | preprocessors: { 22 | 'test/suite.js': [ 'webpack' ] 23 | }, 24 | 25 | reporters: [ 'progress' ], 26 | 27 | browsers, 28 | 29 | singleRun: true, 30 | autoWatch: false, 31 | 32 | webpack: { 33 | mode: 'development', 34 | module: { 35 | rules: [ 36 | { 37 | test: require.resolve('./test/globals.js'), 38 | sideEffects: true 39 | } 40 | ] 41 | } 42 | } 43 | }); 44 | 45 | }; 46 | -------------------------------------------------------------------------------- /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. -------------------------------------------------------------------------------- /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": "^6.2.2", 28 | "diagram-js": "^15.0.0", 29 | "eslint": "^9.39.2", 30 | "eslint-plugin-bpmn-io": "^2.2.0", 31 | "karma": "^6.4.4", 32 | "karma-chrome-launcher": "^3.2.0", 33 | "karma-firefox-launcher": "^2.1.3", 34 | "karma-mocha": "^2.0.1", 35 | "karma-webpack": "^5.0.1", 36 | "mocha": "^11.0.0", 37 | "mocha-test-container-support": "^0.2.0", 38 | "npm-run-all2": "^8.0.4", 39 | "puppeteer": "^24.34.0", 40 | "sinon": "^21.0.0", 41 | "sinon-chai": "^4.0.0", 42 | "webpack": "^5.104.1" 43 | }, 44 | "dependencies": { 45 | "min-dash": "^4.0.0", 46 | "min-dom": "^4.2.1" 47 | }, 48 | "peerDependencies": { 49 | "diagram-js": "*" 50 | }, 51 | "files": [ 52 | "lib" 53 | ] 54 | } 55 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | } -------------------------------------------------------------------------------- /test/DirectEditingSpec.js: -------------------------------------------------------------------------------- 1 | import { expect } from 'chai'; 2 | import { spy, restore } from 'sinon'; 3 | 4 | import { 5 | bootstrapDiagram, 6 | inject 7 | } from 'diagram-js/test/helper'; 8 | 9 | import { 10 | forEach 11 | } from 'min-dash'; 12 | 13 | import directEditingModule from '..'; 14 | 15 | import DirectEditingProvider from './DirectEditingProvider'; 16 | 17 | 18 | var DELTA = 2; 19 | 20 | 21 | function triggerMouseEvent(element, event, clientX, clientY) { 22 | var e = document.createEvent('MouseEvent'); 23 | 24 | if (e.initMouseEvent) { 25 | e.initMouseEvent(event, true, true, window, 0, 0, 0, clientX, clientY, false, false, false, false, 0, null); 26 | } 27 | 28 | element.dispatchEvent(e); 29 | } 30 | 31 | function triggerKeyEvent(element, event, code) { 32 | var e = document.createEvent('Events'); 33 | 34 | if (e.initEvent) { 35 | e.initEvent(event, true, true); 36 | } 37 | 38 | e.keyCode = code; 39 | e.which = code; 40 | 41 | element.dispatchEvent(e); 42 | } 43 | 44 | function expectEditingActive(directEditing, parentBounds, contentBounds) { 45 | expect(directEditing.isActive()).to.eql(true); 46 | 47 | var parent = directEditing._textbox.parent, 48 | content = directEditing._textbox.content; 49 | 50 | expect(parent.className).to.eql('djs-direct-editing-parent'); 51 | expect(content.className).to.eql('djs-direct-editing-content'); 52 | 53 | forEach(parentBounds, function(val, key) { 54 | expect(parseInt(parent['offset' + key.charAt(0).toUpperCase() + key.slice(1)])).to.be.closeTo(val, DELTA); 55 | }); 56 | 57 | if (contentBounds) { 58 | forEach(contentBounds, function(val, key) { 59 | expect(content['offset' + key.charAt(0).toUpperCase() + key.slice(1)]).to.be.closeTo(val, DELTA); 60 | }); 61 | } 62 | } 63 | 64 | 65 | describe('diagram-js-direct-editing', function() { 66 | 67 | 68 | describe('bootstrap', function() { 69 | 70 | beforeEach(bootstrapDiagram({ 71 | modules: [ directEditingModule ] 72 | })); 73 | 74 | it('should bootstrap diagram with component', inject(function() { })); 75 | 76 | }); 77 | 78 | 79 | describe('behavior', function() { 80 | 81 | var providerModule = { 82 | __init__: [ 'directEditingProvider' ], 83 | __depends__: [ directEditingModule ], 84 | directEditingProvider: [ 'type', DirectEditingProvider ] 85 | }; 86 | 87 | beforeEach(bootstrapDiagram({ 88 | modules: [ providerModule ] 89 | })); 90 | 91 | afterEach(inject(function(directEditingProvider) { 92 | directEditingProvider.setOptions(undefined); 93 | 94 | restore(); 95 | })); 96 | 97 | 98 | it('should register provider', inject(function(directEditing) { 99 | expect(directEditing._providers[0] instanceof DirectEditingProvider).to.eql(true); 100 | })); 101 | 102 | 103 | describe('controlled by provider', function() { 104 | 105 | it('should activate', inject(function(canvas, directEditing) { 106 | 107 | // given 108 | var shapeWithLabel = { 109 | id: 's1', 110 | x: 20, y: 10, width: 60, height: 50, 111 | label: 'FOO\nBAR' 112 | }; 113 | canvas.addShape(shapeWithLabel); 114 | 115 | var otherShape = { 116 | id: 's2', 117 | x: 220, y: 10, width: 60, height: 50, 118 | label: 'other' 119 | }; 120 | canvas.addShape(otherShape); 121 | 122 | // when 123 | var activated = directEditing.activate(shapeWithLabel); 124 | 125 | // then 126 | expect(activated).to.eql(true); 127 | 128 | expect(directEditing.isActive()).to.eql(true); 129 | expect(directEditing.isActive(shapeWithLabel)).to.eql(true); 130 | expect(directEditing.isActive(otherShape)).to.eql(false); 131 | 132 | expect(directEditing.getValue()).to.eql('FOO\nBAR'); 133 | 134 | var parentBounds = { 135 | left: 20, 136 | top: 10, 137 | width: 60, 138 | height: 50 139 | }; 140 | 141 | var contentBounds = { 142 | top: 0, 143 | left: 0, 144 | width: 60, 145 | height: 38 146 | }; 147 | 148 | // textbox is correctly positioned 149 | expectEditingActive(directEditing, parentBounds, contentBounds); 150 | })); 151 | 152 | 153 | it('should activate with custom bounds', inject(function(canvas, directEditing) { 154 | 155 | // given 156 | var shapeWithLabel = { 157 | id: 's1', 158 | x: 20, y: 10, width: 60, height: 50, 159 | label: 'FOO', 160 | labelBounds: { x: 100, y: 100, width: 50, height: 20 } 161 | }; 162 | canvas.addShape(shapeWithLabel); 163 | 164 | // when 165 | var activated = directEditing.activate(shapeWithLabel); 166 | 167 | // then 168 | expect(activated).to.eql(true); 169 | 170 | var parentBounds = { left: 100, top: 100, width: 50, height: 20 }, 171 | contentBounds = { left: 0, top: 0, width: 50, height: 18 }; 172 | 173 | // textbox is correctly positioned 174 | expectEditingActive(directEditing, parentBounds, contentBounds); 175 | })); 176 | 177 | 178 | it('should activate with vertically centered text', inject(function(canvas, directEditing, directEditingProvider) { 179 | 180 | // given 181 | var shapeWithLabel = { 182 | id: 's1', 183 | x: 20, y: 10, width: 60, height: 50, 184 | label: 'FOO' 185 | }; 186 | canvas.addShape(shapeWithLabel); 187 | 188 | directEditingProvider.setOptions({ centerVertically: true }); 189 | 190 | // when 191 | var activated = directEditing.activate(shapeWithLabel); 192 | 193 | // then 194 | expect(activated).to.eql(true); 195 | expect(directEditing.getValue()).to.eql('FOO'); 196 | 197 | var parentBounds = { left: 20, top: 10, width: 60, height: 50 }, 198 | contentBounds = { left: 0, top: 25, width: 60, height: 18 }; 199 | 200 | // textbox is correctly positioned 201 | expectEditingActive(directEditing, parentBounds, contentBounds); 202 | })); 203 | 204 | 205 | it('should NOT activate', inject(function(canvas, directEditing) { 206 | 207 | // given 208 | var shapeNoLabel = { 209 | id: 's1', 210 | x: 20, y: 10, width: 60, height: 50 211 | }; 212 | canvas.addShape(shapeNoLabel); 213 | 214 | // when 215 | var activated = directEditing.activate(shapeNoLabel); 216 | 217 | // then 218 | expect(activated).to.eql(false); 219 | expect(directEditing.isActive()).to.eql(false); 220 | expect(directEditing.isActive(shapeNoLabel)).to.eql(false); 221 | })); 222 | 223 | 224 | it('should cancel', inject(function(canvas, directEditing) { 225 | 226 | // given 227 | var shapeWithLabel = { 228 | id: 's1', 229 | x: 20, y: 10, width: 60, height: 50, 230 | label: 'FOO' 231 | }; 232 | canvas.addShape(shapeWithLabel); 233 | 234 | directEditing.activate(shapeWithLabel); 235 | 236 | 237 | // when 238 | directEditing.cancel(); 239 | 240 | // then 241 | expect(directEditing.isActive()).to.eql(false); 242 | expect(directEditing.isActive(shapeWithLabel)).to.eql(false); 243 | 244 | // textbox is detached (invisible) 245 | expect(directEditing._textbox.parent.parentNode).not.to.exist; 246 | })); 247 | 248 | 249 | it('should cancel via ESC', inject(function(canvas, directEditing) { 250 | 251 | // given 252 | var shapeWithLabel = { 253 | id: 's1', 254 | x: 20, y: 10, width: 60, height: 50, 255 | label: 'FOO' 256 | }; 257 | canvas.addShape(shapeWithLabel); 258 | 259 | var textbox = directEditing._textbox; 260 | 261 | directEditing.activate(shapeWithLabel); 262 | 263 | // when pressing ESC 264 | triggerKeyEvent(textbox.content, 'keydown', 27); 265 | 266 | // then 267 | expect(directEditing.isActive()).to.eql(false); 268 | 269 | // textbox container is detached (invisible) 270 | expect(textbox.parent.parentNode).not.to.exist; 271 | })); 272 | 273 | 274 | it('should complete + update label via ENTER', inject(function(canvas, directEditing) { 275 | 276 | // given 277 | var shapeWithLabel = { 278 | id: 's1', 279 | x: 20, y: 10, width: 60, height: 50, 280 | label: 'FOO' 281 | }; 282 | canvas.addShape(shapeWithLabel); 283 | 284 | var textbox = directEditing._textbox; 285 | 286 | directEditing.activate(shapeWithLabel); 287 | 288 | textbox.content.innerText = 'BAR'; 289 | 290 | // when pressing Enter 291 | triggerKeyEvent(textbox.content, 'keydown', 13); 292 | 293 | // then 294 | expect(directEditing.isActive()).to.eql(false); 295 | 296 | // textbox is detached (invisible) 297 | expect(textbox.parent.parentNode).not.to.exist; 298 | 299 | expect(shapeWithLabel.label).to.eql('BAR'); 300 | })); 301 | 302 | 303 | it('should complete with unchanged bounds', inject(function(canvas, directEditing) { 304 | 305 | var labelBounds = { x: 100, y: 200, width: 300, height: 20 }; 306 | 307 | var shapeWithLabel = { 308 | id: 's1', 309 | x: 20, y: 10, width: 60, height: 50, 310 | label: 'FOO', 311 | labelBounds: labelBounds 312 | }; 313 | 314 | canvas.addShape(shapeWithLabel); 315 | 316 | var textbox = directEditing._textbox; 317 | 318 | directEditing.activate(shapeWithLabel); 319 | 320 | textbox.content.innerText = 'BAR'; 321 | 322 | directEditing.complete(); 323 | 324 | var bounds = shapeWithLabel.labelBounds; 325 | 326 | expect(bounds).to.eql(labelBounds); 327 | })); 328 | 329 | 330 | it('should complete with changed bounds', inject(function(canvas, directEditing) { 331 | 332 | var labelBounds = { x: 100, y: 200, width: 300, height: 20 }; 333 | 334 | var shapeWithLabel = { 335 | id: 's1', 336 | x: 20, y: 10, width: 60, height: 50, 337 | label: 'FOO', 338 | labelBounds: labelBounds 339 | }; 340 | 341 | canvas.addShape(shapeWithLabel); 342 | 343 | var textbox = directEditing._textbox; 344 | 345 | directEditing.activate(shapeWithLabel); 346 | 347 | textbox.content.innerText = 'BAR'; 348 | textbox.parent.style.left = '60px'; 349 | textbox.parent.style.top = '80px'; 350 | textbox.parent.style.width = '50px'; 351 | textbox.parent.style.height = '100px'; 352 | 353 | directEditing.complete(); 354 | 355 | var bounds = shapeWithLabel.labelBounds; 356 | 357 | expect(bounds).to.eql({ 358 | x: 60, y: 80, width: 50, height: 100 359 | }); 360 | })); 361 | 362 | 363 | it('should update with changed text', inject( 364 | function(canvas, directEditing, directEditingProvider) { 365 | 366 | // given 367 | var updateSpy = spy(directEditingProvider, 'update'); 368 | 369 | var shapeWithLabel = { 370 | id: 's1', 371 | x: 20, y: 10, width: 60, height: 50, 372 | label: 'FOO', 373 | labelBounds: { x: 100, y: 200, width: 300, height: 20 } 374 | }; 375 | 376 | canvas.addShape(shapeWithLabel); 377 | 378 | // when 379 | directEditing.activate(shapeWithLabel); 380 | 381 | var textbox = directEditing._textbox; 382 | 383 | textbox.content.innerText = 'BAR'; 384 | 385 | directEditing.complete(); 386 | 387 | // then 388 | expect(updateSpy).to.have.been.calledOnce; 389 | } 390 | )); 391 | 392 | 393 | it('should update with changed bounds height', inject( 394 | function(canvas, directEditing, directEditingProvider) { 395 | 396 | // given 397 | var updateSpy = spy(directEditingProvider, 'update'); 398 | 399 | var shapeWithLabel = { 400 | id: 's1', 401 | x: 20, y: 10, width: 60, height: 50, 402 | label: 'FOO', 403 | labelBounds: { x: 100, y: 200, width: 300, height: 20 } 404 | }; 405 | 406 | canvas.addShape(shapeWithLabel); 407 | 408 | // when 409 | directEditing.activate(shapeWithLabel); 410 | 411 | var textbox = directEditing._textbox; 412 | 413 | textbox.parent.style.height = '21px'; 414 | 415 | directEditing.complete(); 416 | 417 | // then 418 | expect(updateSpy).to.have.been.calledOnce; 419 | } 420 | )); 421 | 422 | 423 | it('should update with changed bounds width', inject( 424 | function(canvas, directEditing, directEditingProvider) { 425 | 426 | // given 427 | var updateSpy = spy(directEditingProvider, 'update'); 428 | 429 | var shapeWithLabel = { 430 | id: 's1', 431 | x: 20, y: 10, width: 60, height: 50, 432 | label: 'FOO', 433 | labelBounds: { x: 100, y: 200, width: 300, height: 20 } 434 | }; 435 | 436 | canvas.addShape(shapeWithLabel); 437 | 438 | // when 439 | directEditing.activate(shapeWithLabel); 440 | 441 | var textbox = directEditing._textbox; 442 | 443 | textbox.parent.style.width = '301px'; 444 | 445 | directEditing.complete(); 446 | 447 | // then 448 | expect(updateSpy).to.have.been.calledOnce; 449 | } 450 | )); 451 | 452 | 453 | it('should NOT update with unchanged text or bounds height/width', inject( 454 | function(canvas, directEditing, directEditingProvider) { 455 | 456 | // given 457 | var updateSpy = spy(directEditingProvider, 'update'); 458 | 459 | var shapeWithLabel = { 460 | id: 's1', 461 | x: 20, y: 10, width: 60, height: 50, 462 | label: 'FOO', 463 | labelBounds: { x: 100, y: 200, width: 300, height: 20 } 464 | }; 465 | 466 | canvas.addShape(shapeWithLabel); 467 | 468 | // when 469 | directEditing.activate(shapeWithLabel); 470 | 471 | directEditing.complete(); 472 | 473 | // then 474 | expect(updateSpy).to.not.have.been.called; 475 | } 476 | )); 477 | 478 | }); 479 | 480 | 481 | describe('textbox', function() { 482 | 483 | it('should init label on open', inject(function(canvas, directEditing) { 484 | 485 | // given 486 | var shape = { 487 | id: 's1', 488 | x: 20, y: 10, width: 60, height: 50, 489 | label: 'FOO' 490 | }; 491 | canvas.addShape(shape); 492 | 493 | // when 494 | directEditing.activate(shape); 495 | 496 | // then 497 | expect(directEditing._textbox.content.innerText).to.eql('FOO'); 498 | 499 | })); 500 | 501 | 502 | it('should clear label after close', inject(function(canvas, directEditing) { 503 | 504 | // given 505 | var shape = { 506 | id: 's1', 507 | x: 20, y: 10, width: 60, height: 50, 508 | label: 'FOO' 509 | }; 510 | canvas.addShape(shape); 511 | 512 | // when 513 | directEditing.activate(shape); 514 | directEditing.cancel(); 515 | 516 | // then 517 | expect(directEditing._textbox.content.innerText).to.eql(''); 518 | })); 519 | 520 | 521 | it('should trim label when getting value', inject(function(canvas, directEditing) { 522 | 523 | // given 524 | var shape = { 525 | id: 's1', 526 | x: 20, y: 10, width: 60, height: 50, 527 | label: '\nFOO\n' 528 | }; 529 | canvas.addShape(shape); 530 | 531 | // when 532 | directEditing.activate(shape); 533 | 534 | // then 535 | expect(directEditing.getValue()).to.eql('FOO'); 536 | })); 537 | 538 | 539 | it('should show resize handle if resizable', inject(function(canvas, directEditing, directEditingProvider) { 540 | 541 | // given 542 | var shape = { 543 | id: 's1', 544 | x: 20, y: 10, width: 60, height: 50, 545 | label: 'FOO' 546 | }; 547 | canvas.addShape(shape); 548 | 549 | directEditingProvider.setOptions({ resizable: true }); 550 | 551 | // when 552 | directEditing.activate(shape); 553 | 554 | // then 555 | var resizeHandle = directEditing._textbox.parent.getElementsByClassName('djs-direct-editing-resize-handle')[0]; 556 | 557 | expect(resizeHandle).to.exist; 558 | })); 559 | 560 | 561 | it('should not show resize handle if not resizable', inject(function(canvas, directEditing) { 562 | 563 | // given 564 | var shape = { 565 | id: 's1', 566 | x: 20, y: 10, width: 60, height: 50, 567 | label: 'FOO' 568 | }; 569 | canvas.addShape(shape); 570 | 571 | // when 572 | directEditing.activate(shape); 573 | 574 | // then 575 | var resizeHandle = directEditing._textbox.parent.getElementsByClassName('djs-direct-editing-resize-handle')[0]; 576 | 577 | expect(resizeHandle).not.to.exist; 578 | })); 579 | 580 | 581 | it('should resize automatically', inject(function(canvas, directEditing, directEditingProvider) { 582 | 583 | // given 584 | var shape = { 585 | id: 's1', 586 | x: 20, y: 10, width: 60, height: 50, 587 | label: 'FOO\nBAR\nBAZ\nFOO\nBAR\nBAZ' 588 | }; 589 | canvas.addShape(shape); 590 | 591 | directEditingProvider.setOptions({ autoResize: true }); 592 | 593 | directEditing.activate(shape); 594 | 595 | var parent = directEditing._textbox.parent, 596 | content = directEditing._textbox.content; 597 | 598 | // when 599 | triggerKeyEvent(directEditing._textbox.content, 'input', 65); 600 | 601 | // then 602 | var parentHeight = parent.getBoundingClientRect().height, 603 | contentScrollHeight = content.scrollHeight; 604 | 605 | expect(parentHeight).to.be.closeTo(contentScrollHeight, DELTA); 606 | })); 607 | 608 | 609 | it('should resize on resize handle drag', inject(function(canvas, directEditing, directEditingProvider) { 610 | 611 | // given 612 | var shape = { 613 | id: 's1', 614 | x: 20, y: 10, width: 60, height: 50, 615 | label: 'FOO' 616 | }; 617 | canvas.addShape(shape); 618 | 619 | directEditingProvider.setOptions({ resizable: true }); 620 | 621 | directEditing.activate(shape); 622 | 623 | var parent = directEditing._textbox.parent, 624 | resizeHandle = directEditing._textbox.resizeHandle; 625 | 626 | // when 627 | var oldParentBounds = parent.getBoundingClientRect(), 628 | resizeHandleBounds = resizeHandle.getBoundingClientRect(); 629 | 630 | var clientX = resizeHandleBounds.left + resizeHandleBounds.width / 2, 631 | clientY = resizeHandleBounds.top + resizeHandleBounds.height / 2; 632 | 633 | triggerMouseEvent(resizeHandle, 'mousedown', clientX, clientY); 634 | triggerMouseEvent(resizeHandle, 'mousemove', clientX + 100, clientY + 100); 635 | triggerMouseEvent(resizeHandle, 'mouseup', clientX + 100, clientY + 100); 636 | 637 | // then 638 | var newParentBounds = parent.getBoundingClientRect(); 639 | 640 | expect(newParentBounds.width).to.be.closeTo(oldParentBounds.width + 100, DELTA); 641 | expect(newParentBounds.height).to.be.closeTo(oldParentBounds.height + 100, DELTA); 642 | })); 643 | 644 | 645 | it('should not insert HTML', inject(function(canvas, directEditing) { 646 | 647 | // given 648 | var shape = { 649 | id: 's1', 650 | x: 20, y: 10, width: 60, height: 50, 651 | label: 'FOO' 652 | }; 653 | canvas.addShape(shape); 654 | 655 | directEditing.activate(shape); 656 | 657 | var textBox = directEditing._textbox; 658 | 659 | // when 660 | textBox.insertText('