├── .babelrc ├── .gitignore ├── README.md ├── TODO.md ├── dist └── selectable.js ├── examples-vue1 ├── example1.html ├── example2.html └── example3.html ├── examples-vue2 ├── example.css ├── example1.html ├── example2.html ├── example3.html └── example4.html ├── package.json ├── selectable.js ├── v-selectable.js └── webpack.config.js /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": ["@babel/env"], 3 | "plugins": ["@babel/plugin-proposal-class-properties"] 4 | } 5 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | .idea/ 3 | .vscode/ 4 | /package-lock.json 5 | /yarn.lock 6 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # vue-selectable 2 | 3 | ## Overview 4 | 5 | It's common task to make mouse selection of some objects on the page. This directive makes this task 6 | extremely easy, all you need to implement object selection is few lines of code. 7 | It was designed after jQuery Selectable interaction, with some details borrowed from `angular-multiple-selection`. 8 | It supports plain mouse selection and adding to previously selected values with 9 | `Ctrl` key pressed during selection. Single items can be excluded from selection 10 | with Ctrl + click on them. Scrolling of document or some specified block while selecting 11 | is also supported now, but only on Y axis. 12 | 13 | ## Requirements 14 | 15 | * vue: ^1.0 16 | * vue2: ^2.0 17 | * Browsers - briefly tested on Chrome 58 & 83, IE 11, Firefox 53 18 | 19 | ## Install 20 | 21 | From npm: 22 | ``` 23 | $ npm install vue-selectable --save 24 | ``` 25 | 26 | ## Usage 27 | 28 | To use directive normally you'll need two arrays, one for selected items - with boolean values for 29 | every selectable item, another for items under selection box. By default elements identified by 30 | `selectable` class will be considered as selectable items. 31 | Another thing that you'll definitely need is a element that will be selection box. Directive 32 | will change `height`, `width`, `top`, and `left` attributes of this element, and toggle its 33 | visibility by changing `display` attribute from `block` to `none` and vise versa. 34 | 35 | ### Vue 1.x (ES6 syntax) 36 | 37 | NB: for Vue 2.x all you need is to write `v-for` as `v-for="(item, i) in items"` 38 | 39 | ```html 40 |
45 |
46 |
{{ item }}
49 |
50 | ``` 51 | 52 | ```js 53 | import selectable from 'vue-selectable'; 54 | 55 | new Vue({ 56 | el: '#app', 57 | data: { 58 | selected: [], 59 | selecting: [], 60 | items: ['abc', 'bcd', 'cde'] 61 | }, 62 | directives: { selectable }, 63 | methods: { 64 | selectedGetter() { return this.selected; }, 65 | selectedSetter(v) { this.selected = v; }, 66 | selectingSetter(v) { this.selecting = v; } 67 | } 68 | }); 69 | ``` 70 | 71 | Selection items list in directive can be updated by calling `setSelectableItems(el, itemSelector)`, 72 | where `el` is element where directive applied. Optional argument `itemSelector` can be used to change 73 | selectable items selector. Function returns number of selectable items added or -1 in case of error. 74 | 75 | ## Options 76 | 77 | `v-selectable` requires one mandatory parameter - directive parameter - object with 3 functions, 78 | `selectedGetter`, `selectedSetter`, and `selectingSetter`, to get/set arrays 'selected' (selection 79 | status - array of boolean), 'selecting' (array of items selection status during selection drag, 80 | array of boolean; must be used to display realtime selection visual feedback). 81 | 82 | `selectedSetter` function also receives "selecting" array as a second argument. This could help when 83 | we need some custom selection logic. 84 | 85 | If you have 5 selectable items, `selected` array will have 5 elements initially 86 | set to `false`. When user selects some item(s), values change to `true` accordingly. The same applies 87 | for `selecting` array. 88 | 89 | Also you can specify additional parameters in the object for JS `selectable` component underneath. 90 | For example you can set `rootElement` to some element other than "document" 91 | (to attach event listeners to it). Also it's possible to set `rootElement` to `null` initially and then 92 | set it when necessary element appears in the DOM tree; event listeners will attach right after 93 | (but the trick will work only once). Or you can set `renderSelecting/renderSelection` options to true to have 94 | directive manage CSS classes instead of Vue.js framework. 95 | 96 | Other parameters available: 97 | * `data-items` - CSS selector to identify selectable items, by default it is set to `.selectable` 98 | (elements with CSS class "selectable") 99 | * `data-box` - selection box element. By default it tries to use element with `selection` CSS class 100 | * `data-constraint` - box that constrains selection area (selection box can be only inside area 101 | limited to this element), by default selection area limited to element with directive 102 | 103 | ## Exported util functions 104 | Two utility functions are exported with a directive to help configuring directive. Both require DOM node 105 | with directive as a first argument ("el"). 106 | * `setSelectableItems(el, [itemSelector])` - if used without second argument, rereads DOM to fetch 107 | selectable items (useful after e.g. AJAX load of items). Another CSS selector can be specified to 108 | create a new list of selectable items. 109 | * `setOptions(el, options)` - sets directive options on the fly. For now is required to set 110 | `scrollingFrame` internal parameter for Vue.js v2 (see `examples-vue2/example3.html`). 111 | 112 | ## Internal options 113 | Except already described `selectedGetter`, `selectedSetter`, and `selectingSetter`, directive has these 114 | internal options, that can be set using directive declaration (as getters/setters) or on the fly: 115 | * `disableTextSelection` (boolean) - disable browser text selection when selection box is active (turned on by default) 116 | * `scrollingFrame` (DOM node) - element with scrollbar, that contains list of selectable items 117 | * `scrollSpeed` (int) - speed of scroll (in px per 16ms, default 10px) 118 | * `scrollDistance` (int) - distance from borders (in px, default 10px) when scroll begins to work 119 | * `scrollDocumentEnabled` (boolean) - enable (default)/disable document scrolling while selecting items, ignored when scrollingFrame is configured 120 | * `renderSelected` (boolean) - add CSS selectedClass to elements currently selected (w/o framework) 121 | * `renderSelecting` (boolean) - add CSS selectedClass to elements currently under selection box (w/o framework) 122 | * `selectingClass` (string) - CSS class used to mark items under selection box (".selecting" by default) 123 | * `selectedClass` (string) - CSS class used to mark selected items (".selected" by default) 124 | * `overrideAddMode` (boolean) - selection frame always adds items to selection when this flag is true, despite "Ctrl" or "Meta" keys being pressed (false by default) 125 | 126 | ## Examples 127 | 128 | Example usages can be found in `examples-vue1` directory for Vue.js v1 and `examples-vue2` for Vue.js v2. 129 | Examples for v2 were tested against version 2.3.3 and 2.6.11. 130 | 131 | ## License 132 | 133 | [MIT](http://opensource.org/licenses/MIT) 134 | -------------------------------------------------------------------------------- /TODO.md: -------------------------------------------------------------------------------- 1 | # List of known issues and planned features 2 | 3 | * Perform scroll for entire document automatically (works for frame already) - fix needed. 4 | * Make absBox() results on `selectables[]` and `boundingBox` cacheable (ala autoRefresh: off in 5 | jQuery Selectable) - add flag `.static` to directive 6 | * Make selection work with `SHIFT` key modifier. 7 | * Add possibility to require 'full element cover' for selection. 8 | -------------------------------------------------------------------------------- /dist/selectable.js: -------------------------------------------------------------------------------- 1 | !function(e,t){"object"==typeof exports&&"object"==typeof module?module.exports=t():"function"==typeof define&&define.amd?define("vueSelectable",[],t):"object"==typeof exports?exports.vueSelectable=t():e.vueSelectable=t()}(window,(function(){return function(e){var t={};function s(n){if(t[n])return t[n].exports;var i=t[n]={i:n,l:!1,exports:{}};return e[n].call(i.exports,i,i.exports,s),i.l=!0,i.exports}return s.m=e,s.c=t,s.d=function(e,t,n){s.o(e,t)||Object.defineProperty(e,t,{enumerable:!0,get:n})},s.r=function(e){"undefined"!=typeof Symbol&&Symbol.toStringTag&&Object.defineProperty(e,Symbol.toStringTag,{value:"Module"}),Object.defineProperty(e,"__esModule",{value:!0})},s.t=function(e,t){if(1&t&&(e=s(e)),8&t)return e;if(4&t&&"object"==typeof e&&e&&e.__esModule)return e;var n=Object.create(null);if(s.r(n),Object.defineProperty(n,"default",{enumerable:!0,value:e}),2&t&&"string"!=typeof e)for(var i in e)s.d(n,i,function(t){return e[t]}.bind(null,i));return n},s.n=function(e){var t=e&&e.__esModule?function(){return e.default}:function(){return e};return s.d(t,"a",t),t},s.o=function(e,t){return Object.prototype.hasOwnProperty.call(e,t)},s.p="",s(s.s=0)}([function(e,t,s){"use strict";function n(e,t){return function(e){if(Array.isArray(e))return e}(e)||function(e,t){if("undefined"==typeof Symbol||!(Symbol.iterator in Object(e)))return;var s=[],n=!0,i=!1,l=void 0;try{for(var r,o=e[Symbol.iterator]();!(n=(r=o.next()).done)&&(s.push(r.value),!t||s.length!==t);n=!0);}catch(e){i=!0,l=e}finally{try{n||null==o.return||o.return()}finally{if(i)throw l}}return s}(e,t)||function(e,t){if(!e)return;if("string"==typeof e)return i(e,t);var s=Object.prototype.toString.call(e).slice(8,-1);"Object"===s&&e.constructor&&(s=e.constructor.name);if("Map"===s||"Set"===s)return Array.from(e);if("Arguments"===s||/^(?:Ui|I)nt(?:8|16|32)(?:Clamped)?Array$/.test(s))return i(e,t)}(e,t)||function(){throw new TypeError("Invalid attempt to destructure non-iterable instance.\nIn order to be iterable, non-array objects must have a [Symbol.iterator]() method.")}()}function i(e,t){(null==t||t>e.length)&&(t=e.length);for(var s=0,n=new Array(t);s1)for(var t=1;t0&&void 0!==arguments[0]?arguments[0]:{};l(this,e),o(this,"el",null),o(this,"selectBox",null),o(this,"selectBoxSelector",".selection"),o(this,"rootElement",document),o(this,"boundingBox",document),o(this,"boundingBoxSelector",null),o(this,"dragging",!1),o(this,"startX",null),o(this,"startY",null),o(this,"endX",null),o(this,"endY",null),o(this,"selectables",[]),o(this,"selected",[]),o(this,"selectedSetter",null),o(this,"selectedGetter",null),o(this,"selectingSetter",null),o(this,"selecting",[]),o(this,"addMode",!1),o(this,"disableTextSelection",!0),o(this,"handlers",{mousedown:null,mouseup:null,mousemove:null}),o(this,"scrollingFrame",null),o(this,"scrollSpeed",10),o(this,"scrollDistance",10),o(this,"scrollDocumentEnabled",!0),o(this,"scrollRepeater",null),o(this,"renderSelected",!1),o(this,"renderSelecting",!1),o(this,"overrideAddMode",!1),o(this,"selectingClass","selecting"),o(this,"selectedClass","selected"),o(this,"firstRun",!0),this.handlers.mousedown=this.mouseDown.bind(this),this.handlers.mouseup=this.mouseUp.bind(this),this.handlers.mousemove=this.mouseMove.bind(this),h(this,t),this.attach()}var t,s,i;return t=e,i=[{key:"disableTextSelection",value:function(e){return e.preventDefault(),!1}},{key:"absBox",value:function(e){var t=e.getBoundingClientRect();return{top:t.top+window.pageYOffset,left:t.left+window.pageXOffset,width:t.width,height:t.height}}}],(s=[{key:"attach",value:function(){var e=this;this.rootElement&&Object.keys(this.handlers).forEach((function(t){return e.rootElement.addEventListener(t,e.handlers[t])}))}},{key:"detach",value:function(){var t=this;Object.keys(this.handlers).forEach((function(e){return t.rootElement.removeEventListener(e,t.handlers[e])})),this.disableTextSelection&&this.dragging&&this.rootElement.removeEventListener("selectstart",e.disableTextSelection),this.scrollRepeater&&clearInterval(this.scrollRepeater),this.selectables=[],this.selectBox=null,this.boundingBox=null,this.rootElement=null,this.scrollingFrame=null,this.element=null}},{key:"setSelectables",value:function(e){this.selectables=e,this.selected=e.map((function(e){return!1})),"function"==typeof this.selectedSetter&&this.selectedSetter(this.selected,this.selected)}},{key:"mouseDown",value:function(t){var s=this.el===t.target||this.el.contains(t.target);if(0===t.button&&s){this.boundingBoxSelector&&(this.boundingBox=document.querySelector(this.boundingBoxSelector));var i=e.absBox(this.boundingBox);if(!(t.pageXi.width+i.left||t.pageYi.height+i.top)){this.disableTextSelection&&this.rootElement.addEventListener("selectstart",e.disableTextSelection),this.scrollRepeater&&(clearInterval(this.scrollRepeater),this.scrollRepeater=null);var l=n(this.bound(t),2),r=l[0],o=l[1];if(this.selectBox=document.querySelector(this.selectBoxSelector),this.scrollingFrame&&(o+=this.scrollingFrame.scrollTop),this.startX=r,this.startY=o,this.endX=r,this.endY=o,this.dragging=!0,this.selecting=this.selectables.map((function(e){return!1})),"function"==typeof this.selectingSetter&&this.selectingSetter(this.selecting),this.addMode=this.overrideAddMode||t.ctrlKey||t.metaKey,this.addMode){if("function"==typeof this.selectedGetter){var c=this.selectedGetter()||[];this.selected=this.selectables.map((function(e,t){return!!c[t]}))}}else this.selected=this.selecting,"function"==typeof this.selectedSetter&&this.selectedSetter(this.selected,this.selecting);this.updateSelection(),this.render()}}}},{key:"mouseUp",value:function(t){var s=this;if(this.dragging){if(0!==t.button)return;this.disableTextSelection&&this.rootElement.removeEventListener("selectstart",e.disableTextSelection);var i=n(this.bound(t),2),l=i[0],r=i[1];if(this.endX=l,this.endY=r,this.scrollingFrame&&(this.endY+=this.scrollingFrame.scrollTop),this.scrollRepeater&&(clearInterval(this.scrollRepeater),this.scrollRepeater=null),this.dragging=!1,this.updateSelection(),"function"==typeof this.selectedGetter){var o=this.selectedGetter()||[];this.selected=this.selectables.map((function(e,t){return!!o[t]}))}if(this.addMode){var c=this.selecting.reduce((function(e,t){return e+t?1:0}),0),a=this.selecting.findIndex((function(e){return!!e}));1===c&&this.selected[a]?this.selected[a]=!1:this.selected=this.selected.map((function(e,t){return e||s.selecting[t]}))}else this.selected=this.selecting;"function"==typeof this.selectedSetter&&this.selectedSetter(this.selected,this.selecting),this.selecting=[],this.selectingSetter&&this.selectingSetter(this.selecting),this.render()}}},{key:"mouseMove",value:function(e){if(this.dragging){var t=n(this.bound(e),2),s=t[0],i=t[1];this.endX=s,this.endY=i,this.scrollRepeater&&(clearInterval(this.scrollRepeater),this.scrollRepeater=null),this.scrollingFrame?this.endY+=this.scrollFrame(e):this.scrollDocumentEnabled&&this.scrollDocument(e),this.updateSelection(),this.render()}}},{key:"scrollFrame",value:function(e){var t=this,s=this.scrollingFrame,n=s.getBoundingClientRect(),i=0;return e.pageY>=n.bottom-this.scrollDistance?i=this.scrollSpeed:e.pageY<=n.top+this.scrollDistance&&(i=-this.scrollSpeed),s.scrollTop+=i,(e.pageY>=n.bottom||e.pageY<=n.top)&&(this.scrollRepeater&&clearInterval(this.scrollRepeater),this.scrollRepeater=setInterval((function(){return t.mouseMove(e)}),16)),s.scrollTop}},{key:"scrollDocument",value:function(e){var t=this,s=0;this.endY<=window.pageYOffset?s=-this.scrollSpeed:this.endY>=window.pageYOffset+window.innerHeight&&(s=this.scrollSpeed),0!==s&&(window.scrollBy(0,s),this.scrollRepeater&&clearInterval(this.scrollRepeater),this.scrollRepeater=setInterval((function(){return t.mouseMove(e)}),16))}},{key:"bound",value:function(t){var s=e.absBox(this.boundingBox);return[Math.min(Math.max(s.left,t.pageX),s.width+s.left),Math.min(Math.max(s.top,t.pageY),s.height+s.top)]}},{key:"updateSelection",value:function(){var t=this.getSelectionBox();t.top-=this.scrollingFrame?this.scrollingFrame.scrollTop:0,this.selecting=this.selectables.map(e.absBox).map((function(e){return Math.abs(2*(t.left-e.left)+t.width-e.width) 2 | 3 | 4 | 5 | Test vue 6 | 7 | 8 | 56 | 57 | 58 |
59 |
60 |
62 |
63 |
65 | ... 66 | {{ i }} 67 |
68 |
69 | 70 | 71 |
72 | 102 | 103 | 104 | -------------------------------------------------------------------------------- /examples-vue1/example2.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Test vue (closer to real use) 6 | 7 | 8 | 64 | 65 | 66 |
67 |
68 | 69 |
70 | 71 |
72 |
73 |
74 | 75 | 76 | 77 | 78 | 79 | 80 | 81 | 82 | 84 | 85 | 89 | 90 | 91 | 92 | 93 |
NameSurnameAge
86 | {{ person.name }} 87 | 88 | {{ person.surname }}{{ person.age }}
94 |
95 | 96 |
97 | 98 | 99 | 100 |
101 |
102 | 170 | 171 | -------------------------------------------------------------------------------- /examples-vue1/example3.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Test vue (closer to real use) 6 | 7 | 8 | 71 | 72 | 73 |
74 |
75 | 76 | Selected items count: {{ selectedCount }} 77 |
78 | 79 |
80 |
81 |
82 |
83 | 84 | 85 | 86 | 87 | 88 | 89 | 90 | 91 | 98 | 99 | 103 | 104 | 105 | 106 | 107 |
NameSurnameAge
100 | {{ person.name }} 101 | 102 | {{ person.surname }}{{ person.age }}
108 |
109 |
110 | 111 |
112 | 113 | 114 | 115 |
116 |
117 | 191 | 192 | -------------------------------------------------------------------------------- /examples-vue2/example.css: -------------------------------------------------------------------------------- 1 | #app table { 2 | font-size: 16px; 3 | width: 500px; 4 | } 5 | #app table th, 6 | #app table td { 7 | padding: 5px; 8 | margin: 1px; 9 | background-color: #f0f0f0; 10 | } 11 | #app table > thead > tr > th { 12 | background-color: #ccc; 13 | } 14 | #app table > thead > tr > th:first-child, 15 | #app table > tbody > tr > td:first-child { 16 | width: 300px; 17 | -webkit-touch-callout: none; 18 | -webkit-user-select: none; 19 | -khtml-user-select: none; 20 | -moz-user-select: none; 21 | -ms-user-select: none; 22 | user-select: none; 23 | } 24 | 25 | /* Styles for v-selectable display */ 26 | #selectbox { 27 | position: absolute; 28 | border: 1px dotted #000; 29 | z-index: 9999; 30 | top: 0; 31 | left: 0; 32 | cursor: default; 33 | display: none; 34 | } 35 | #app table > tbody > tr > td.selecting:first-child { 36 | background-color: yellow; 37 | } 38 | #app table > tbody > tr > td.selected:first-child { 39 | background-color: orange; 40 | } 41 | .hidden { 42 | display: none; 43 | } 44 | .wrapper { 45 | overflow-y: scroll; 46 | height: 300px; 47 | width: 700px; 48 | margin: 20px auto; 49 | border: 10px solid #f0f0f0; 50 | } 51 | .content { 52 | position: relative; 53 | } 54 | #selection-area { 55 | position: absolute; 56 | z-index: -500; 57 | top: 0; 58 | left: 0; 59 | height: 660px; 60 | width: 314px; 61 | } 62 | -------------------------------------------------------------------------------- /examples-vue2/example1.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Test vue 6 | 7 | 8 | 56 | 57 | 58 |
59 |
60 |
62 |
63 |
65 | ... 66 | {{ n }} 67 |
68 |
69 | 70 | 71 |
72 | 95 | 96 | 97 | -------------------------------------------------------------------------------- /examples-vue2/example2.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Test vue (closer to real use) 6 | 7 | 8 | 64 | 65 | 66 |
67 |
68 | 69 |
70 | 71 |
72 |
73 |
74 | 75 | 76 | 77 | 78 | 79 | 80 | 81 | 82 | 84 | 85 | 89 | 90 | 91 | 92 | 93 |
NameSurnameAge
86 | {{ person.name }} 87 | 88 | {{ person.surname }}{{ person.age }}
94 |
95 | 96 |
97 | 98 | 99 | 100 |
101 |
102 | 170 | 171 | 172 | -------------------------------------------------------------------------------- /examples-vue2/example3.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Test vue (closer to real use) 6 | 7 | 8 | 71 | 72 | 73 |
74 |
75 | 76 | Selected items count: {{ selectedCount }} 77 |
78 | 79 |
80 |
81 |
82 |
83 | 84 | 85 | 86 | 87 | 88 | 89 | 90 | 91 | 97 | 98 | 102 | 103 | 104 | 105 | 106 |
NameSurnameAge
99 | {{ person.name }} 100 | 101 | {{ person.surname }}{{ person.age }}
107 |
108 |
109 | 110 |
111 | 112 | 113 | 114 |
115 |
116 | 194 | 195 | 196 | -------------------------------------------------------------------------------- /examples-vue2/example4.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Test vue (closer to real use, ES6 syntax) 6 | 7 | 8 | 9 | 10 | 11 |
12 |

Closer to real use (ES6)

13 | 14 |
15 | 16 | 17 |
18 | 19 |
20 |
21 |
22 |
23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 41 | 44 | 48 | 49 | 50 | 51 | 52 | 53 |
NameSurnameAge 
45 | {{ person.name }} 46 | 47 | {{ person.surname }}{{ person.age }}
54 |
55 |
56 | 57 |
58 | 59 | 60 | 61 | 62 | 63 |
64 | 65 |
66 | Selected persons ({{ selectedCount }}): {{ selectedPersons }} 67 |
68 |
69 | 184 | 185 | 186 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "vue-selectable", 3 | "version": "0.5.0", 4 | "description": "Directive to make objects selectable in vue.js application", 5 | "main": "dist/selectable.js", 6 | "repository": { 7 | "type": "git", 8 | "url": "https://github.com/JSmith01/vue-selectable" 9 | }, 10 | "directories": { 11 | "example": "examples-vue2" 12 | }, 13 | "dependencies": {}, 14 | "devDependencies": { 15 | "@babel/core": "^7.10.3", 16 | "@babel/plugin-proposal-class-properties": "^7.10.1", 17 | "@babel/preset-env": "^7.10.3", 18 | "babel-loader": "^8.1.0", 19 | "webpack": "^4.43.0" 20 | }, 21 | "scripts": { 22 | "build": "webpack" 23 | }, 24 | "keywords": [ 25 | "vue", 26 | "selectable" 27 | ], 28 | "author": "Dmitry Zlygin ", 29 | "license": "MIT" 30 | } 31 | -------------------------------------------------------------------------------- /selectable.js: -------------------------------------------------------------------------------- 1 | export function objectAssignSimple(target) { 2 | if (arguments.length > 1) { 3 | for (let i = 1; i < arguments.length; i++) { 4 | if (typeof arguments[i] !== 'object' || arguments[i] === null) { 5 | continue; 6 | } 7 | let keys = Object.keys(arguments[i]); 8 | for (let j = 0; j < keys.length; j++) { 9 | target[keys[j]] = arguments[i][keys[j]]; 10 | } 11 | } 12 | } 13 | 14 | return target; 15 | } 16 | 17 | const objectAssign = Object.assign || objectAssignSimple; 18 | 19 | export default class selectable { 20 | /** 21 | * Element which has selectable attached 22 | * @type {HTMLElement} 23 | */ 24 | el = null; 25 | 26 | selectBox = null; 27 | selectBoxSelector = '.selection'; 28 | 29 | /** 30 | * Event listeners are attached to this element 31 | * @type {HTMLDocument} 32 | */ 33 | rootElement = document; 34 | 35 | /** 36 | * Element that limits where selection can be made 37 | * @type {HTMLDocument} 38 | */ 39 | boundingBox = document; 40 | 41 | /** 42 | * CSS selector of element that limits where selection can be made (has higher priority than boundingBox) 43 | * @type {HTMLDocument} 44 | */ 45 | boundingBoxSelector = null; 46 | dragging = false; 47 | startX = null; 48 | startY = null; 49 | endX = null; 50 | endY = null; 51 | selectables = []; 52 | selected = []; 53 | 54 | /** 55 | * Called to pass out list of selected items 56 | * @type {Function | null} 57 | */ 58 | selectedSetter = null; 59 | 60 | /** 61 | * Called to get list of selected items 62 | * @type {Function | null} 63 | */ 64 | selectedGetter = null; 65 | 66 | /** 67 | * Called to set list of items under selection box 68 | * @type {Function | null} 69 | */ 70 | selectingSetter = null; 71 | 72 | selecting = []; 73 | addMode = false; 74 | 75 | disableTextSelection = true; 76 | 77 | handlers = { 78 | mousedown: null, 79 | mouseup: null, 80 | mousemove: null, 81 | }; 82 | 83 | /** 84 | * Scrolling element that contains 85 | * @type {HTMLElement|null} 86 | */ 87 | scrollingFrame = null; 88 | 89 | /** 90 | * Speed of scroll (in px per 16ms) 91 | * @type {number} 92 | */ 93 | scrollSpeed = 10; 94 | 95 | /** 96 | * Distance from borders (in px) when scroll begins to work 97 | * @type {number} 98 | */ 99 | scrollDistance = 10; 100 | 101 | /** 102 | * Enable/disable document scrolling while selecting items, ignored when scrollingFrame is configured 103 | * @type {boolean} 104 | */ 105 | scrollDocumentEnabled = true; 106 | 107 | /** 108 | * Timeout ID (from setInterval() call) 109 | * @type {null|object} 110 | */ 111 | scrollRepeater = null; 112 | 113 | /** 114 | * Add CSS selectedClass to elements currently selected (w/o framework) 115 | * @type {boolean} 116 | */ 117 | renderSelected = false; 118 | 119 | /** 120 | * Add CSS selectedClass to elements currently under selection box (w/o framework) 121 | * @type {boolean} 122 | */ 123 | renderSelecting = false; 124 | 125 | /** 126 | * Selection frame always adds items to selection, despite "Ctrl" or "Meta" keys being pressed 127 | * @type {boolean} 128 | */ 129 | overrideAddMode = false; 130 | 131 | selectingClass = 'selecting'; 132 | selectedClass = 'selected'; 133 | 134 | firstRun = true; 135 | 136 | /** 137 | * Initializes selection component 138 | * @param {Object} options misc selection options 139 | */ 140 | constructor(options = {}) { 141 | this.handlers.mousedown = this.mouseDown.bind(this); 142 | this.handlers.mouseup = this.mouseUp.bind(this); 143 | this.handlers.mousemove = this.mouseMove.bind(this); 144 | 145 | objectAssign(this, options); 146 | 147 | this.attach(); 148 | } 149 | 150 | /** 151 | * Adds event handlers to the root element 152 | */ 153 | attach() { 154 | if (this.rootElement) { 155 | Object.keys(this.handlers).forEach( 156 | event => this.rootElement.addEventListener(event, this.handlers[event]) 157 | ); 158 | } 159 | } 160 | 161 | /** 162 | * Removes all registered event handlers and clears references to DOM nodes 163 | */ 164 | detach() { 165 | Object.keys(this.handlers).forEach(event => this.rootElement.removeEventListener(event, this.handlers[event])); 166 | if (this.disableTextSelection && this.dragging) { 167 | this.rootElement.removeEventListener('selectstart', selectable.disableTextSelection); 168 | } 169 | if (this.scrollRepeater) { 170 | clearInterval(this.scrollRepeater); 171 | } 172 | this.selectables = []; 173 | this.selectBox = null; 174 | this.boundingBox = null; 175 | this.rootElement = null; 176 | this.scrollingFrame = null; 177 | this.element = null; 178 | } 179 | 180 | /** 181 | * Updates list of selectable items 182 | * @param {Element[]} elements 183 | */ 184 | setSelectables(elements) { 185 | this.selectables = elements; 186 | this.selected = elements.map(i => false); 187 | if (typeof this.selectedSetter === 'function') { 188 | this.selectedSetter(this.selected, this.selected); 189 | } 190 | } 191 | 192 | /** 193 | * Disables text selection (as a default browser action) 194 | * @param {Event} e 195 | * @return {boolean} 196 | */ 197 | static disableTextSelection(e) { 198 | e.preventDefault(); 199 | return false; 200 | } 201 | 202 | /** 203 | * Mouse key down handler 204 | * @param {MouseEvent} e 205 | */ 206 | mouseDown(e) { 207 | const isSrcDescendant = this.el === e.target || this.el.contains(e.target); 208 | if (e.button !== 0 || !isSrcDescendant) { 209 | return; 210 | } 211 | if (!!this.boundingBoxSelector) { 212 | this.boundingBox = document.querySelector(this.boundingBoxSelector); 213 | } 214 | let bb = selectable.absBox(this.boundingBox); 215 | if (e.pageX < bb.left || e.pageX > bb.width + bb.left || 216 | e.pageY < bb.top || e.pageY > bb.height + bb.top) { 217 | return; 218 | } 219 | if (this.disableTextSelection) { 220 | this.rootElement.addEventListener('selectstart', selectable.disableTextSelection); 221 | } 222 | if (this.scrollRepeater) { 223 | clearInterval(this.scrollRepeater); 224 | this.scrollRepeater = null; 225 | } 226 | let [x, y] = this.bound(e); 227 | this.selectBox = document.querySelector(this.selectBoxSelector); 228 | if (this.scrollingFrame) { 229 | y += this.scrollingFrame.scrollTop; 230 | } 231 | this.startX = x; 232 | this.startY = y; 233 | this.endX = x; 234 | this.endY = y; 235 | this.dragging = true; 236 | this.selecting = this.selectables.map(i => false); // reset all selection 237 | if (typeof this.selectingSetter === 'function') { 238 | this.selectingSetter(this.selecting); 239 | } 240 | this.addMode = this.overrideAddMode || e.ctrlKey || e.metaKey; 241 | if (!this.addMode) { 242 | this.selected = this.selecting; 243 | if (typeof this.selectedSetter === 'function') { 244 | this.selectedSetter(this.selected, this.selecting); 245 | } 246 | } else if (typeof this.selectedGetter === 'function') { 247 | let gotSelection = this.selectedGetter() || []; 248 | this.selected = this.selectables.map((v, i) => !!gotSelection[i]); 249 | } 250 | this.updateSelection(); 251 | this.render(); 252 | } 253 | 254 | /** 255 | * Mouse key up handler 256 | * @param {MouseEvent} e 257 | */ 258 | mouseUp(e) { 259 | if (this.dragging) { 260 | if (e.button !== 0) { 261 | return; 262 | } 263 | if (this.disableTextSelection) { 264 | this.rootElement.removeEventListener('selectstart', selectable.disableTextSelection); 265 | } 266 | let [x, y] = this.bound(e); 267 | this.endX = x; 268 | this.endY = y; 269 | if (this.scrollingFrame) { 270 | this.endY += this.scrollingFrame.scrollTop; 271 | } 272 | if (this.scrollRepeater) { 273 | clearInterval(this.scrollRepeater); 274 | this.scrollRepeater = null; 275 | } 276 | this.dragging = false; 277 | this.updateSelection(); 278 | if (typeof this.selectedGetter === 'function') { 279 | let gotSelection = this.selectedGetter() || []; 280 | this.selected = this.selectables.map((v, i) => !!gotSelection[i]); 281 | } 282 | if (this.addMode) { 283 | let selectingItemsQty = this.selecting.reduce((a, i) => a + i ? 1 : 0, 0); 284 | let idx = this.selecting.findIndex(v => !!v); 285 | if (selectingItemsQty === 1 && this.selected[idx]) { 286 | this.selected[idx] = false; 287 | } else { 288 | this.selected = this.selected.map((v, i) => v || this.selecting[i]); 289 | } 290 | } else { 291 | this.selected = this.selecting; 292 | } 293 | if (typeof this.selectedSetter === 'function') { 294 | this.selectedSetter(this.selected, this.selecting); 295 | } 296 | this.selecting = []; 297 | if (this.selectingSetter) { 298 | this.selectingSetter(this.selecting); 299 | } 300 | this.render(); 301 | } 302 | } 303 | 304 | /** 305 | * Mouse move handler 306 | * @param {MouseEvent} e 307 | */ 308 | mouseMove(e) { 309 | if (this.dragging) { 310 | let [x, y] = this.bound(e); 311 | this.endX = x; 312 | this.endY = y; 313 | if (this.scrollRepeater) { 314 | clearInterval(this.scrollRepeater); 315 | this.scrollRepeater = null; 316 | } 317 | if (this.scrollingFrame) { 318 | this.endY += this.scrollFrame(e); 319 | } else if (this.scrollDocumentEnabled) { 320 | this.scrollDocument(e); 321 | } 322 | this.updateSelection(); 323 | this.render(); 324 | } 325 | } 326 | 327 | /** 328 | * Scroll frame with selectable items when mouse reaches one of edges 329 | * @param {MouseEvent} e 330 | * @return {int} 331 | */ 332 | scrollFrame(e) { 333 | let sf = this.scrollingFrame; 334 | let frame = sf.getBoundingClientRect(); 335 | let diff = 0; 336 | if (e.pageY >= frame.bottom - this.scrollDistance) { 337 | diff = this.scrollSpeed; 338 | } else if (e.pageY <= frame.top + this.scrollDistance) { 339 | diff = -this.scrollSpeed; 340 | } 341 | sf.scrollTop += diff; 342 | 343 | // repeat mouse move event if mouse were close to borders 344 | if (e.pageY >= frame.bottom || e.pageY <= frame.top) { 345 | if (this.scrollRepeater) { 346 | clearInterval(this.scrollRepeater); 347 | } 348 | this.scrollRepeater = setInterval(() => this.mouseMove(e), 16); 349 | } 350 | 351 | return sf.scrollTop; 352 | } 353 | 354 | /** 355 | * Scroll document with selectable items when mouse reaches one of edges 356 | * @param {MouseEvent} e 357 | */ 358 | scrollDocument(e) { 359 | let diff = 0; 360 | if (this.endY <= window.pageYOffset) { 361 | diff = -this.scrollSpeed; 362 | } else if (this.endY >= window.pageYOffset + window.innerHeight) { 363 | diff = this.scrollSpeed; 364 | } 365 | 366 | if (diff !== 0) { 367 | window.scrollBy(0, diff); 368 | if (this.scrollRepeater) { 369 | clearInterval(this.scrollRepeater); 370 | } 371 | this.scrollRepeater = setInterval(() => this.mouseMove(e), 16); 372 | } 373 | } 374 | 375 | /** 376 | * Returns [x, y] coordinates from mouse event limited to selection area 377 | * @param {MouseEvent} e 378 | * @return {[int, int]} 379 | */ 380 | bound(e) { 381 | let bb = selectable.absBox(this.boundingBox); 382 | return [ 383 | Math.min(Math.max(bb.left, e.pageX), bb.width + bb.left), 384 | Math.min(Math.max(bb.top, e.pageY), bb.height + bb.top) 385 | ]; 386 | } 387 | 388 | /** 389 | * Returns element's absolute position (on the page) and size 390 | * @param {Element} element 391 | * @return {{top: number, left: number, width: Number, height: Number}} 392 | */ 393 | static absBox(element) { 394 | let box = element.getBoundingClientRect(); 395 | 396 | return { top: box.top + window.pageYOffset, left: box.left + window.pageXOffset , width: box.width, height: box.height }; 397 | } 398 | 399 | /** 400 | * Updates list of selected items (under current selection box) 401 | */ 402 | updateSelection() { 403 | let s = this.getSelectionBox(); 404 | s.top -= this.scrollingFrame ? this.scrollingFrame.scrollTop : 0; 405 | this.selecting = this.selectables.map(selectable.absBox).map(b => 406 | (Math.abs((s.left - b.left) * 2 + s.width - b.width) < (s.width + b.width)) && 407 | (Math.abs((s.top - b.top) * 2 + s.height - b.height) < (s.height + b.height)) 408 | ); 409 | if (this.selectingSetter) { 410 | this.selectingSetter(this.selecting); 411 | } 412 | } 413 | 414 | /** 415 | * Gets size and relative position of selection box 416 | * @return {{left: number, top: number, width: number, height: number}} 417 | */ 418 | getSelectionBox() { 419 | return { 420 | left: Math.min(this.startX, this.endX), 421 | top: Math.min(this.startY, this.endY), 422 | width: Math.abs(this.startX - this.endX), 423 | height: Math.abs(this.startY - this.endY) 424 | }; 425 | } 426 | 427 | /** 428 | * Renders visible state for selectable items 429 | */ 430 | renderSelection() { 431 | if (!this.renderSelected && !this.renderSelecting) { 432 | return; 433 | } 434 | this.selectables.forEach((e, i) => { 435 | if (this.renderSelecting) { 436 | if (this.dragging && !!this.selecting[i]) { 437 | e.classList.add(this.selectingClass); 438 | } else { 439 | e.classList.remove(this.selectingClass); 440 | } 441 | } 442 | if (this.renderSelected) { 443 | if (!this.selected[i]) { 444 | e.classList.remove(this.selectedClass); 445 | } else { 446 | e.classList.add(this.selectedClass); 447 | } 448 | } 449 | }); 450 | } 451 | 452 | /** 453 | * Renders current selection state 454 | */ 455 | render() { 456 | let elStyle = this.selectBox.style; 457 | if (this.dragging) { 458 | let box = this.getSelectionBox(); 459 | let bb = selectable.absBox(this.boundingBox); 460 | elStyle.display = 'block'; 461 | if (this.firstRun) { 462 | let selectBoxStart = selectable.absBox(this.selectBox); 463 | this.selectBoxStartX = bb.left - selectBoxStart.left; 464 | this.selectBoxStartY = bb.top - selectBoxStart.top; 465 | this.firstRun = false; 466 | } 467 | elStyle.left = (box.left - bb.left + this.selectBoxStartX) + 'px'; 468 | elStyle.top = (box.top - bb.top + this.selectBoxStartY - 469 | (this.scrollingFrame ? this.scrollingFrame.scrollTop : 0)) + 'px'; 470 | elStyle.width = box.width + 'px'; 471 | elStyle.height = box.height + 'px'; 472 | } else { 473 | elStyle.display = 'none'; 474 | } 475 | this.renderSelection(); 476 | } 477 | } 478 | -------------------------------------------------------------------------------- /v-selectable.js: -------------------------------------------------------------------------------- 1 | import selectable, { objectAssignSimple } from './selectable'; 2 | 3 | const objectAssign = Object.assign || objectAssignSimple; 4 | 5 | function initSelectable(el, params, arg) { 6 | el.selectable = new selectable(objectAssign({ 7 | boundingBox: !!params.constraint ? document.querySelector(params.constraint) : el, 8 | selectBoxSelector: params.box || '.selection', 9 | boundingBoxSelector: params.constraint, 10 | el 11 | }, arg)); 12 | el.selectable.setSelectables(Array.prototype.slice.call(el.querySelectorAll(params.items || '.selectable'))); 13 | } 14 | 15 | const vueSelectable = { 16 | twoWay: false, 17 | 18 | params: ['items', 'box', 'constraint'], 19 | 20 | bind(el, binding) { 21 | let arg, params; 22 | if (!!el && !!binding) { 23 | // Vue.js v2 24 | arg = binding.value; 25 | params = el.dataset; 26 | initSelectable(el, params, arg); 27 | } 28 | }, 29 | 30 | update(value) { 31 | if (!!this && !!this.el && !this.el.selectable) { 32 | // Vue.js v1 - init selectable 33 | initSelectable(this.el, this.el.dataset, value); 34 | } 35 | }, 36 | 37 | unbind(el) { 38 | if (!el) { 39 | el = this.el; 40 | } 41 | el.selectable.detach(); 42 | el.selectable = null; 43 | } 44 | }; 45 | 46 | export default vueSelectable; 47 | 48 | /** 49 | * Allows to change internal selectable items list 50 | * @param {HTMLElement} el Element where v-selectable directive applied 51 | * @param {string} itemSelector (optional) CSS selector of elements to be used as selectable items 52 | * @return {number} number of selectable items or -1 if no selectable component found 53 | */ 54 | export function setSelectableItems(el, itemSelector) { 55 | if (!!el && !!el.selectable && typeof el.selectable.setSelectables === 'function') { 56 | let items = Array.prototype.slice.call(el.querySelectorAll(itemSelector || el.dataset.items || '.selectable')); 57 | el.selectable.setSelectables(items); 58 | return items.length; 59 | } else { 60 | return -1; 61 | } 62 | } 63 | 64 | /** 65 | * Sets options to directive 66 | * @param {HTMLElement} el Element where v-selectable directive applied 67 | * @param {object} options 68 | */ 69 | export function setOptions(el, options) { 70 | if (!!el && !!el.selectable && typeof el.selectable.setSelectables === 'function') { 71 | const needsAttach = el.selectable.rootElement == null && options.rootElement != null; 72 | objectAssign(el.selectable, options); 73 | if (needsAttach) { 74 | el.selectable.attach(); 75 | } 76 | } 77 | } 78 | -------------------------------------------------------------------------------- /webpack.config.js: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | 3 | module.exports = { 4 | mode: 'production', 5 | entry: path.resolve(__dirname, 'v-selectable.js'), 6 | output: { 7 | path: path.resolve(__dirname, 'dist'), 8 | filename: 'selectable.js', 9 | library: 'vueSelectable', 10 | libraryTarget: 'umd', 11 | umdNamedDefine: true 12 | }, 13 | module: { 14 | rules: [ 15 | {test: /\.js$/, exclude: /node_modules/, loader: "babel-loader"} 16 | ] 17 | } 18 | }; 19 | --------------------------------------------------------------------------------