├── .browserslistrc ├── .editorconfig ├── .eslintrc.js ├── .gitignore ├── .npmignore ├── .travis.yml ├── LICENSE ├── README.md ├── babel.config.js ├── jest.config.js ├── lib ├── assets │ └── styles │ │ └── main.css ├── components │ ├── Hotzone.vue │ └── Zone.vue ├── directives │ ├── addItem.js │ ├── changeSize.js │ └── dragItem.js ├── index.js └── utils │ └── index.js ├── package.json ├── postcss.config.js ├── public ├── favicon.png └── index.html ├── src ├── App.vue └── main.js ├── tests └── unit │ ├── components │ ├── hotzone.spec.js │ └── zone.spec.js │ └── utils.spec.js ├── vue.config.js └── yarn.lock /.browserslistrc: -------------------------------------------------------------------------------- 1 | > 1% 2 | last 2 versions 3 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | [*.{js,jsx,ts,tsx,vue}] 2 | indent_style = space 3 | indent_size = 2 4 | trim_trailing_whitespace = true 5 | insert_final_newline = true 6 | -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | root: true, 3 | env: { 4 | node: true, 5 | jest: true 6 | }, 7 | 'extends': [ 8 | 'plugin:vue/essential' 9 | ], 10 | rules: { 11 | 'no-console': process.env.NODE_ENV === 'production' ? 'error' : 'off', 12 | 'no-debugger': process.env.NODE_ENV === 'production' ? 'error' : 'off' 13 | }, 14 | parserOptions: { 15 | parser: 'babel-eslint' 16 | }, 17 | overrides: [ 18 | { 19 | files: [ 20 | '**/__tests__/*.{j,t}s?(x)', 21 | '**/tests/unit/**/*.spec.{j,t}s?(x)' 22 | ], 23 | env: { 24 | jest: true 25 | } 26 | } 27 | ] 28 | } 29 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | node_modules 3 | /dist 4 | 5 | # Coverage directory used by tools like istanbul 6 | coverage 7 | 8 | # local env files 9 | .env.local 10 | .env.*.local 11 | 12 | # Log files 13 | npm-debug.log* 14 | yarn-debug.log* 15 | yarn-error.log* 16 | 17 | # Editor directories and files 18 | .idea 19 | .vscode 20 | *.suo 21 | *.ntvs* 22 | *.njsproj 23 | *.sln 24 | *.sw? 25 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | __tests__ 2 | coverage 3 | dist 4 | publish 5 | src 6 | .eslintrc 7 | .gitignore 8 | .babelrc 9 | .postcssrc.js 10 | babel.config.js 11 | jest.config.js 12 | vue.config.js 13 | yarn.lock 14 | README.md 15 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | node_js: 3 | - 12 4 | install: 5 | - yarn 6 | script: 7 | - yarn lint 8 | - yarn test:unit 9 | after_success: 10 | - codecov 11 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2018-present, OrangeXC 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 all 13 | 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 THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

vue-hotzone logo

2 | 3 |

4 | Build Status 5 | Coverage Status 6 | Npm download 7 | Npm version 8 | GitHub License 9 |

10 | 11 | ## Introduction 12 | 13 | A vue2 hotzone component 14 | 15 | ### [Demo](https://vue-hotzone.orangexc.xyz/) | [案例](https://vue-hotzone.orangexc.xyz/) 16 | 17 | ## Install 18 | 19 | ```bash 20 | npm i vue-hotzone --save 21 | # or 22 | yarn add vue-hotzone 23 | ``` 24 | 25 | ## Use 26 | 27 | ```js 28 | // Use in component 29 | import hotzone from 'vue-hotzone' 30 | 31 | export default { 32 | components: { 33 | hotzone 34 | } 35 | } 36 | 37 | // Use in global 38 | import hotzone from 'vue-hotzone' 39 | 40 | Vue.component(hotzone.name, hotzone) 41 | 42 | // or 43 | Vue.use(hotzone) 44 | ``` 45 | 46 | ```html 47 | 48 | ``` 49 | 50 | ## Options 51 | 52 | ### Attributes 53 | You can set them to your data function 54 | 55 | | Attribute | Type | Description | Keys | 56 | |:----------|:-------|:---------------------------------|:-------------------------------------------| 57 | | image | String | image of hotzone(required: true) | | 58 | | max | Number | max number of zones | | 59 | | zonesInit | Array | init zones | item(heightPer, leftPer, topPer, widthPer) | 60 | 61 | ### Events 62 | 63 | | Event Name | Description | Parameters | 64 | |:-----------|:-------------------------------------------------------------------------|:--------------------------------| 65 | | change | triggers when the zones changes | the array of the zones | 66 | | add | triggers when the zone add | the add zone item | 67 | | remove | triggers when the zone remove | the index of the remove zone | 68 | | overRange | triggers when zones number > max | the index of the overRange zone | 69 | | erase | triggers when add zone overRange or smaller than the minimum area(48*48) | the index of the erase zone | 70 | 71 | ## Develop 72 | 73 | ```bash 74 | $ git clone https://github.com/OrangeXC/vue-hotzone.git 75 | 76 | $ cd vue-hotzone 77 | 78 | $ yarn 79 | 80 | $ yarn serve 81 | ``` 82 | 83 | ## License 84 | 85 | Vue-hotzone is [MIT licensed](https://github.com/OrangeXC/vue-hotzone/blob/master/LICENSE). 86 | -------------------------------------------------------------------------------- /babel.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | presets: [ 3 | '@vue/cli-plugin-babel/preset' 4 | ] 5 | } 6 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | preset: '@vue/cli-plugin-unit-jest' 3 | } 4 | -------------------------------------------------------------------------------- /lib/assets/styles/main.css: -------------------------------------------------------------------------------- 1 | .hz-m-wrap { 2 | position: relative; 3 | /*overflow: hidden;*/ 4 | } 5 | .hz-m-wrap .hz-u-img { 6 | display: block; 7 | width: 100%; 8 | max-width: 100%; 9 | height: auto; 10 | max-height: 100%; 11 | user-select: none; 12 | } 13 | .hz-m-wrap .hz-m-area { 14 | position: absolute; 15 | top: 0; 16 | right: 0; 17 | bottom: 0; 18 | left: 0; 19 | cursor: crosshair; 20 | } 21 | .hz-m-wrap .hz-m-item { 22 | position: absolute; 23 | display: block; 24 | } 25 | .hz-m-wrap .hz-m-box { 26 | position: relative; 27 | width: 100%; 28 | height: 100%; 29 | box-shadow: 0 0 6px #000; 30 | background-color: #e31414; 31 | font-size: 12px; 32 | cursor: pointer; 33 | color: #fff; 34 | opacity: 0.8; 35 | } 36 | .hz-m-wrap .hz-m-box > li { 37 | position: absolute; 38 | text-align: center; 39 | user-select: none; 40 | } 41 | .hz-m-wrap .hz-m-box.hz-z-hidden > li { 42 | display: none; 43 | } 44 | .hz-m-wrap .hz-m-box.hz-m-hoverbox:hover { 45 | box-shadow: 0 0 0 2px #373950; 46 | } 47 | .hz-m-wrap .hz-m-box.hz-m-hoverbox .hz-icon:hover { 48 | background-color: #373950; 49 | } 50 | .hz-m-wrap .hz-m-box .hz-icon { 51 | width: 24px; 52 | height: 24px; 53 | line-height: 24px; 54 | font-size: 20px; 55 | text-align: center; 56 | } 57 | .hz-m-wrap .hz-m-box .hz-icon:hover { 58 | background-color: #e31414; 59 | opacity: 0.8; 60 | } 61 | .hz-m-wrap .hz-m-box .hz-u-index { 62 | top: 0; 63 | left: 0; 64 | width: 24px; 65 | height: 24px; 66 | line-height: 24px; 67 | background-color: #000; 68 | } 69 | .hz-m-wrap .hz-m-box .hz-u-close { 70 | top: 0; 71 | right: 0; 72 | } 73 | .hz-m-wrap .hz-m-box .hz-m-copy { 74 | display: inline-block; 75 | } 76 | .hz-m-wrap .hz-m-box .hz-small-icon { 77 | border: 0; 78 | border-radius: 0; 79 | } 80 | .hz-m-wrap .hz-m-box .hz-u-square { 81 | width: 8px; 82 | height: 8px; 83 | opacity: 0.8; 84 | } 85 | .hz-m-wrap .hz-m-box .hz-u-square:after { 86 | content: ''; 87 | position: absolute; 88 | top: 2px; 89 | left: 2px; 90 | width: 4px; 91 | height: 4px; 92 | border-radius: 4px; 93 | background-color: #fff; 94 | } 95 | .hz-m-wrap .hz-m-box .hz-u-square-tl { 96 | top: -4px; 97 | left: -4px; 98 | cursor: nw-resize; 99 | } 100 | .hz-m-wrap .hz-m-box .hz-u-square-tc { 101 | top: -4px; 102 | left: 50%; 103 | transform: translateX(-50%); 104 | cursor: n-resize; 105 | } 106 | .hz-m-wrap .hz-m-box .hz-u-square-tr { 107 | top: -4px; 108 | right: -4px; 109 | cursor: ne-resize; 110 | } 111 | .hz-m-wrap .hz-m-box .hz-u-square-cl { 112 | top: 50%; 113 | left: -4px; 114 | transform: translateY(-50%); 115 | cursor: w-resize; 116 | } 117 | .hz-m-wrap .hz-m-box .hz-u-square-cr { 118 | top: 50%; 119 | right: -4px; 120 | transform: translateY(-50%); 121 | cursor: w-resize; 122 | } 123 | .hz-m-wrap .hz-m-box .hz-u-square-bl { 124 | bottom: -4px; 125 | left: -4px; 126 | cursor: sw-resize; 127 | } 128 | .hz-m-wrap .hz-m-box .hz-u-square-bc { 129 | bottom: -4px; 130 | left: 50%; 131 | transform: translateX(-50%); 132 | cursor: s-resize; 133 | } 134 | .hz-m-wrap .hz-m-box .hz-u-square-br { 135 | bottom: -4px; 136 | right: -4px; 137 | cursor: se-resize; 138 | } 139 | /* reset */ 140 | .hz-m-modal, .hz-m-wrap { 141 | font-size: 12px; 142 | /* 清除内外边距 */ 143 | /* 重置列表元素 */ 144 | /* 重置文本格式元素 */ 145 | /* 初始化 input */ 146 | } 147 | .hz-m-modal ul, .hz-m-wrap ul, .hz-m-modal ol, .hz-m-wrap ol, .hz-m-modal li, .hz-m-wrap li { 148 | margin: 0; 149 | padding: 0; 150 | } 151 | .hz-m-modal ul, .hz-m-wrap ul, .hz-m-modal ol, .hz-m-wrap ol { 152 | list-style: none; 153 | } 154 | .hz-m-modal a, .hz-m-wrap a { 155 | text-decoration: none; 156 | } 157 | .hz-m-modal a:hover, .hz-m-wrap a:hover { 158 | text-decoration: underline; 159 | } 160 | .hz-m-modal p, .hz-m-wrap p { 161 | -webkit-margin-before: 0; 162 | -webkit-margin-after: 0; 163 | } 164 | .hz-m-modal input[type="checkbox"], .hz-m-wrap input[type="checkbox"] { 165 | cursor: pointer; 166 | } 167 | /* basic */ 168 | /* modal 样式 */ 169 | .hz-m-modal { 170 | position: fixed; 171 | top: 0; 172 | right: 0; 173 | bottom: 0; 174 | left: 0; 175 | z-index: 1000; 176 | overflow-y: auto; 177 | -webkit-overflow-scrolling: touch; 178 | touch-action: cross-slide-y pinch-zoom double-tap-zoom; 179 | text-align: center; 180 | overflow: hidden; 181 | } 182 | .hz-m-modal:before { 183 | content: ""; 184 | display: inline-block; 185 | vertical-align: middle; 186 | height: 100%; 187 | } 188 | .hz-m-modal .hz-modal_dialog { 189 | display: inline-block; 190 | vertical-align: middle; 191 | text-align: left; 192 | border-radius: 3px; 193 | } 194 | .hz-m-modal .hz-modal_title { 195 | margin: 0; 196 | } 197 | .hz-m-modal .hz-modal_close { 198 | float: right; 199 | margin: -6px -4px 0 0; 200 | } 201 | @media (max-width: 767px) { 202 | .hz-m-modal .hz-modal_dialog { 203 | width: auto; 204 | } 205 | } 206 | html.z-modal, html.z-modal body { 207 | overflow: hidden; 208 | } 209 | .hz-m-modal { 210 | background: rgba(0, 0, 0, 0.6); 211 | } 212 | .hz-m-modal .hz-modal_dialog { 213 | width: 450px; 214 | background: #fff; 215 | -webkit-box-shadow: 0 2px 3px rgba(0, 0, 0, 0.125); 216 | box-shadow: 0 2px 3px rgba(0, 0, 0, 0.125); 217 | } 218 | .hz-m-modal .hz-modal_hd { 219 | padding: 15px; 220 | border-bottom: 1px solid #f4f4f4; 221 | } 222 | .hz-m-modal .hz-modal_title { 223 | font-size: 18px; 224 | } 225 | .hz-m-modal .hz-modal_close { 226 | margin: -15px -15px 0 0; 227 | padding: 6px; 228 | color: #bbb; 229 | cursor: pointer; 230 | } 231 | .hz-m-modal .hz-modal_close:hover { 232 | color: #888; 233 | } 234 | .hz-m-modal .hz-modal_close .hz-u-icon-close { 235 | font-size: 18px; 236 | transition: transform 500ms ease-in-out; 237 | transform: rotate(0deg); 238 | width: 18px; 239 | text-align: center; 240 | } 241 | .hz-m-modal .hz-modal_close:hover .hz-u-icon-close { 242 | transform: rotate(270deg); 243 | } 244 | .hz-m-modal .hz-modal_bd { 245 | padding: 15px 15px 0 15px; 246 | min-height: 10px; 247 | } 248 | .hz-m-modal .hz-modal_ft { 249 | padding: 15px; 250 | text-align: center; 251 | border-top: 1px solid #f4f4f4; 252 | } 253 | .hz-m-modal .hz-modal_ft .hz-u-btn { 254 | margin: 0 10px; 255 | } 256 | @media (max-width: 767px) { 257 | .hz-m-modal .hz-modal_dialog { 258 | margin: 10px; 259 | } 260 | } 261 | /* 基本按钮样式 btn */ 262 | .hz-u-btn { 263 | -webkit-user-select: none; 264 | -moz-user-select: none; 265 | -ms-user-select: none; 266 | user-select: none; 267 | -webkit-appearance: none; 268 | border: none; 269 | overflow: visible; 270 | font: inherit; 271 | text-transform: none; 272 | text-decoration: none; 273 | cursor: pointer; 274 | -webkit-box-sizing: border-box; 275 | -moz-box-sizing: border-box; 276 | box-sizing: border-box; 277 | background: none; 278 | display: inline-block; 279 | vertical-align: middle; 280 | text-align: center; 281 | font-size: 12px; 282 | } 283 | .hz-u-btn:hover, .hz-u-btn:focus { 284 | outline: none; 285 | text-decoration: none; 286 | } 287 | .hz-u-btn:disabled { 288 | cursor: not-allowed; 289 | } 290 | .hz-u-btn-block { 291 | display: block; 292 | width: 100%; 293 | } 294 | .hz-u-btn { 295 | padding: 0 16px; 296 | height: 28px; 297 | line-height: 26px; 298 | background: #f4f4f4; 299 | color: #444; 300 | border: 1px solid #ddd; 301 | -moz-border-radius: 3px; 302 | border-radius: 3px; 303 | } 304 | .hz-u-btn:hover, .hz-u-btn:focus { 305 | background: #e5e5e5; 306 | border: 1px solid #adadad; 307 | } 308 | .hz-u-btn:active { 309 | background: #e5e5e5; 310 | -webkit-box-shadow: inset 0 3px 5px rgba(0, 0, 0, 0.125); 311 | box-shadow: inset 0 3px 5px rgba(0, 0, 0, 0.125); 312 | } 313 | .hz-u-btn:disabled { 314 | background: #fff; 315 | border: 1px solid #ccc; 316 | filter: alpha(opacity=65); 317 | opacity: 0.65; 318 | -webkit-box-shadow: none; 319 | box-shadow: none; 320 | } 321 | /* 按钮类型 */ 322 | .hz-u-btn-primary { 323 | background: #67739b; 324 | color: #fff; 325 | border: 1px solid #67739b; 326 | } 327 | .hz-u-btn-primary:hover, .hz-u-btn-primary:focus { 328 | background: #31384b; 329 | color: #fff; 330 | border: 1px solid #31384b; 331 | } 332 | .hz-u-btn-primary:active { 333 | background: #367fa9; 334 | color: #fff; 335 | border: 1px solid #367fa9; 336 | } 337 | .hz-u-btn-primary:disabled { 338 | background: #444; 339 | color: #fff; 340 | border: 1px solid #444; 341 | } 342 | /* input */ 343 | .hz-u-input { 344 | -webkit-box-sizing: border-box; 345 | -moz-box-sizing: border-box; 346 | box-sizing: border-box; 347 | margin: 0; 348 | border: 0; 349 | padding: 0; 350 | border-radius: 0; 351 | font: inherit; 352 | color: inherit; 353 | vertical-align: middle; 354 | } 355 | .hz-u-input { 356 | position: relative; 357 | z-index: 0; 358 | padding: 5px 6px; 359 | border: 1px solid #d2d6de; 360 | color: #555; 361 | background: #fff; 362 | -moz-border-radius: 3px; 363 | border-radius: 3px; 364 | } 365 | .hz-u-input::-webkit-input-placeholder { 366 | color: #bbb; 367 | filter: alpha(opacity=100); 368 | opacity: 1; 369 | } 370 | .hz-u-input::-moz-placeholder { 371 | color: #bbb; 372 | filter: alpha(opacity=100); 373 | opacity: 1; 374 | } 375 | .hz-u-input:-moz-placeholder { 376 | color: #bbb; 377 | filter: alpha(opacity=100); 378 | opacity: 1; 379 | } 380 | .hz-u-input:-ms-placeholder { 381 | color: #bbb; 382 | filter: alpha(opacity=100); 383 | opacity: 1; 384 | } 385 | .hz-u-input:focus { 386 | outline: 0; 387 | background: #fff; 388 | color: #555; 389 | border: 1px solid #3c8dbc; 390 | } 391 | .hz-u-input:disabled { 392 | cursor: not-allowed; 393 | background: #eee; 394 | color: #999; 395 | border: 1px solid #d2d6de; 396 | } 397 | .hz-u-input { 398 | width: 280px; 399 | height: 34px; 400 | } 401 | .hz-u-input.hz-u-input-success { 402 | color: #00a65a; 403 | border-color: #00a65a; 404 | } 405 | .hz-u-input.hz-u-input-warning { 406 | color: #f39c12; 407 | border-color: #f39c12; 408 | } 409 | .hz-u-input.hz-u-input-error { 410 | color: #dd4b39; 411 | border-color: #dd4b39; 412 | } 413 | .hz-u-input.hz-u-input-blank { 414 | border-color: transparent; 415 | border-style: dashed; 416 | background: none; 417 | } 418 | .hz-u-input.hz-u-input-blank:focus { 419 | border-color: #ddd; 420 | } 421 | /* formItem */ 422 | .hz-u-formitem { 423 | display: inline-block; 424 | *zoom: 1; 425 | margin-bottom: 1em; 426 | } 427 | .hz-u-formitem:before, .hz-u-formitem:after { 428 | display: table; 429 | content: ""; 430 | line-height: 0; 431 | } 432 | .hz-u-formitem:after { 433 | clear: both; 434 | } 435 | .hz-u-formitem .hz-formitem_tt { 436 | display: block; 437 | float: left; 438 | text-align: right; 439 | } 440 | .hz-u-formitem .hz-formitem_ct { 441 | display: block; 442 | } 443 | .hz-u-formitem .hz-formitem_rqr { 444 | line-height: 28px; 445 | color: #dd4b39; 446 | } 447 | .hz-u-formitem .hz-formitem_tt { 448 | line-height: 34px; 449 | width: 100px; 450 | } 451 | .hz-u-formitem .hz-formitem_ct { 452 | line-height: 34px; 453 | margin-left: 108px; 454 | } 455 | /* icon */ 456 | .hz-u-icon { 457 | display: inline-block; 458 | font: normal normal normal 14px/1 FontAwesome; 459 | font-size: inherit; 460 | text-rendering: auto; 461 | -webkit-font-smoothing: antialiased; 462 | -moz-osx-font-smoothing: grayscale; 463 | } 464 | /* label */ 465 | .hz-u-label { 466 | display: inline-block; 467 | cursor: pointer; 468 | } 469 | /* margin */ 470 | .hz-f-ml0 { 471 | margin-bottom: 0; 472 | } 473 | /* replicator */ 474 | .hz-u-copy input[data-for-copy] { 475 | transform: translateZ(0); 476 | position: fixed; 477 | bottom: 0; 478 | right: 0; 479 | width: 1px; 480 | height: 1px; 481 | opacity: 0; 482 | overflow: hidden; 483 | z-index: -999; 484 | color: transparent; 485 | background-color: transparent; 486 | border: none; 487 | outline: none; 488 | } 489 | @font-face { 490 | font-family: 'iconfont'; 491 | /* project id 525460 */ 492 | src: url('//at.alicdn.com/t/font_525460_d0ysfwzacahsemi.eot'); 493 | src: url('//at.alicdn.com/t/font_525460_d0ysfwzacahsemi.eot?#iefix') format('embedded-opentype'), url('//at.alicdn.com/t/font_525460_d0ysfwzacahsemi.woff') format('woff'), url('//at.alicdn.com/t/font_525460_d0ysfwzacahsemi.ttf') format('truetype'), url('//at.alicdn.com/t/font_525460_d0ysfwzacahsemi.svg#iconfont') format('svg'); 494 | } 495 | .hz-icon { 496 | font-family: "iconfont" !important; 497 | font-size: 20px; 498 | font-style: normal; 499 | text-align: center; 500 | user-select: none; 501 | -webkit-font-smoothing: antialiased; 502 | -moz-osx-font-smoothing: grayscale; 503 | } 504 | .hz-icon-trash:before { 505 | content: "\e605"; 506 | } 507 | -------------------------------------------------------------------------------- /lib/components/Hotzone.vue: -------------------------------------------------------------------------------- 1 | 23 | 24 | 98 | 99 | 102 | -------------------------------------------------------------------------------- /lib/components/Zone.vue: -------------------------------------------------------------------------------- 1 | 32 | 33 | 100 | -------------------------------------------------------------------------------- /lib/directives/addItem.js: -------------------------------------------------------------------------------- 1 | import _ from '../utils' 2 | 3 | export default { 4 | bind: function (el, binding, vnode) { 5 | const MIN_LIMIT = _.MIN_LIMIT 6 | 7 | el.addEventListener('mousedown', handleMouseDown) 8 | 9 | function handleMouseDown (e) { 10 | e && e.preventDefault() 11 | 12 | let itemInfo = { 13 | top: _.getDistanceY(e, el), 14 | left: _.getDistanceX(e, el), 15 | width: 0, 16 | height: 0 17 | } 18 | let container = _.getOffset(el) 19 | 20 | // Only used once at the beginning of init 21 | let setting = { 22 | topPer: _.decimalPoint(itemInfo.top / container.height), 23 | leftPer: _.decimalPoint(itemInfo.left / container.width), 24 | widthPer: 0, 25 | heightPer: 0 26 | } 27 | let preX = _.getPageX(e) 28 | let preY = _.getPageY(e) 29 | 30 | vnode.context.addItem(setting) 31 | 32 | window.addEventListener('mousemove', handleChange) 33 | window.addEventListener('mouseup', handleMouseUp) 34 | 35 | function handleChange (e) { 36 | e && e.preventDefault() 37 | 38 | let moveX = _.getPageX(e) - preX 39 | let moveY = _.getPageY(e) - preY 40 | preX = _.getPageX(e) 41 | preY = _.getPageY(e) 42 | 43 | // Not consider the direction of movement first, consider only the lower right drag point 44 | let minLimit = 0 45 | let styleInfo = _.dealBR(itemInfo, moveX, moveY, minLimit) 46 | 47 | // Boundary value processing 48 | itemInfo = _.dealEdgeValue(itemInfo, styleInfo, container) 49 | 50 | Object.assign(el.lastElementChild.style, { 51 | top: `${itemInfo.top}px`, 52 | left: `${itemInfo.left}px`, 53 | width: `${itemInfo.width}px`, 54 | height: `${itemInfo.height}px` 55 | }) 56 | } 57 | 58 | function handleMouseUp () { 59 | let perInfo = { 60 | topPer: _.decimalPoint(itemInfo.top / container.height), 61 | leftPer: _.decimalPoint(itemInfo.left / container.width), 62 | widthPer: _.decimalPoint(itemInfo.width / container.width), 63 | heightPer: _.decimalPoint(itemInfo.height / container.height) 64 | } 65 | 66 | if (vnode.context.isOverRange()) { 67 | vnode.context.overRange() 68 | } else if (container.height < MIN_LIMIT && itemInfo.width > MIN_LIMIT) { 69 | vnode.context.changeItem(Object.assign(perInfo, { 70 | topPer: 0, 71 | heightPer: 1 72 | })) 73 | } else if (container.width < MIN_LIMIT && itemInfo.height > MIN_LIMIT) { 74 | vnode.context.changeItem(Object.assign(perInfo, { 75 | leftper: 0, 76 | widthPer: 1 77 | })) 78 | } else if (itemInfo.width > MIN_LIMIT && itemInfo.height > MIN_LIMIT) { 79 | vnode.context.changeItem(perInfo) 80 | } else { 81 | vnode.context.eraseItem() 82 | } 83 | 84 | window.removeEventListener('mousemove', handleChange) 85 | window.removeEventListener('mouseup', handleMouseUp) 86 | } 87 | } 88 | 89 | el.$destroy = () => el.removeEventListener('mousedown', handleMouseDown) 90 | }, 91 | unbind: function (el) { 92 | el.$destroy() 93 | } 94 | } 95 | -------------------------------------------------------------------------------- /lib/directives/changeSize.js: -------------------------------------------------------------------------------- 1 | import _ from '../utils' 2 | 3 | export default { 4 | bind: function (el, binding, vnode) { 5 | el.addEventListener('mousedown', handleMouseDown) 6 | 7 | function handleMouseDown (e) { 8 | let pointer = e.target.dataset.pointer 9 | 10 | if (!pointer) { 11 | return 12 | } 13 | 14 | e && e.stopPropagation() 15 | 16 | let zone = el.parentNode 17 | let setting = vnode.context.setting 18 | let container = _.getOffset(zone.parentNode) 19 | let itemInfo = { 20 | width: _.getOffset(zone).width || 0, 21 | height: _.getOffset(zone).height || 0, 22 | top: setting.topPer * container.height || 0, 23 | left: setting.leftPer * container.width || 0 24 | } 25 | let preX = _.getPageX(e) 26 | let preY = _.getPageY(e) 27 | let flag 28 | 29 | // Hide the info displayed by hover 30 | vnode.context.handlehideZone(true) 31 | 32 | window.addEventListener('mousemove', handleChange) 33 | window.addEventListener('mouseup', handleMouseUp) 34 | 35 | function handleChange (e) { 36 | e && e.preventDefault() 37 | flag = true 38 | 39 | let moveX = _.getPageX(e) - preX 40 | let moveY = _.getPageY(e) - preY 41 | 42 | preX = _.getPageX(e) 43 | preY = _.getPageY(e) 44 | 45 | // Handling the situation when different dragging points are selected 46 | let styleInfo = _[pointer](itemInfo, moveX, moveY) 47 | 48 | // Boundary value processing 49 | itemInfo = _.dealEdgeValue(itemInfo, styleInfo, container) 50 | 51 | Object.assign(zone.style, { 52 | top: `${itemInfo.top}px`, 53 | left: `${itemInfo.left}px`, 54 | width: `${itemInfo.width}px`, 55 | height: `${itemInfo.height}px` 56 | }) 57 | } 58 | 59 | function handleMouseUp () { 60 | if (flag) { 61 | flag = false 62 | let perInfo = { 63 | topPer: _.decimalPoint(itemInfo.top / container.height), 64 | leftPer: _.decimalPoint(itemInfo.left / container.width), 65 | widthPer: _.decimalPoint(itemInfo.width / container.width), 66 | heightPer: _.decimalPoint(itemInfo.height / container.height) 67 | } 68 | vnode.context.changeInfo(perInfo) 69 | 70 | // 兼容数据无变更情况下导致 computed 不更新,数据仍为 px 时 resize 出现的问题 71 | Object.assign(zone.style, { 72 | top: `${itemInfo.top}px`, 73 | left: `${itemInfo.left}px`, 74 | width: `${itemInfo.width}px`, 75 | height: `${itemInfo.height}px` 76 | }) 77 | } 78 | // Show the info 79 | vnode.context.handlehideZone(false) 80 | 81 | window.removeEventListener('mousemove', handleChange) 82 | window.removeEventListener('mouseup', handleMouseUp) 83 | } 84 | } 85 | 86 | el.$destroy = () => el.removeEventListener('mousedown', handleMouseDown) 87 | }, 88 | unbind: function (el) { 89 | el.$destroy() 90 | } 91 | } 92 | -------------------------------------------------------------------------------- /lib/directives/dragItem.js: -------------------------------------------------------------------------------- 1 | import _ from '../utils' 2 | 3 | export default { 4 | bind: function (el, binding, vnode) { 5 | el.addEventListener('mousedown', handleMouseDown) 6 | 7 | function handleMouseDown (e) { 8 | e && e.stopPropagation() 9 | 10 | let container = _.getOffset(el.parentNode) 11 | let preX = _.getPageX(e) 12 | let preY = _.getPageY(e) 13 | let topPer 14 | let leftPer 15 | let flag 16 | 17 | window.addEventListener('mousemove', handleChange) 18 | window.addEventListener('mouseup', handleMouseUp) 19 | 20 | function handleChange (e) { 21 | e && e.preventDefault() 22 | flag = true 23 | 24 | // Hide the info displayed by hover 25 | vnode.context.handlehideZone(true) 26 | 27 | let setting = vnode.context.setting 28 | let moveX = _.getPageX(e) - preX 29 | let moveY = _.getPageY(e) - preY 30 | 31 | setting.topPer = setting.topPer || 0 32 | setting.leftPer = setting.leftPer || 0 33 | topPer = _.decimalPoint(moveY / container.height + setting.topPer) 34 | leftPer = _.decimalPoint(moveX / container.width + setting.leftPer) 35 | 36 | // Hotzone moving boundary processing 37 | if (topPer < 0) { 38 | topPer = 0 39 | moveY = -container.height * setting.topPer 40 | } 41 | 42 | if (leftPer < 0) { 43 | leftPer = 0 44 | moveX = -container.width * setting.leftPer 45 | } 46 | 47 | if (topPer + setting.heightPer > 1) { 48 | topPer = 1 - setting.heightPer 49 | moveY = container.height * (topPer - setting.topPer) 50 | } 51 | 52 | if (leftPer + setting.widthPer > 1) { 53 | leftPer = 1 - setting.widthPer 54 | moveX = container.width * (leftPer - setting.leftPer) 55 | } 56 | 57 | el.style.transform = `translate(${moveX}px, ${moveY}px)` 58 | } 59 | 60 | function handleMouseUp () { 61 | if (flag) { 62 | flag = false 63 | el.style.transform = 'translate(0, 0)' 64 | vnode.context.changeInfo({ 65 | topPer, 66 | leftPer 67 | }) 68 | } 69 | 70 | // Show the info 71 | vnode.context.handlehideZone(false) 72 | 73 | window.removeEventListener('mousemove', handleChange) 74 | window.removeEventListener('mouseup', handleMouseUp) 75 | } 76 | } 77 | 78 | el.$destroy = () => el.removeEventListener('mousedown', handleMouseDown) 79 | }, 80 | unbind: function (el) { 81 | el.$destroy() 82 | } 83 | } 84 | -------------------------------------------------------------------------------- /lib/index.js: -------------------------------------------------------------------------------- 1 | import hotzone from './components/Hotzone.vue' 2 | 3 | hotzone.install = (Vue) => { 4 | Vue.component(hotzone.name, hotzone) 5 | } 6 | 7 | export default hotzone 8 | -------------------------------------------------------------------------------- /lib/utils/index.js: -------------------------------------------------------------------------------- 1 | let _ = { 2 | MIN_LIMIT: 48, // Min size of zone 3 | DECIMAL_PLACES: 4 // Hotzone positioning decimal point limit number of digits 4 | } 5 | 6 | /** 7 | * Get a power result of 10 for the power of the constant 8 | * @return {Number} 9 | */ 10 | _.getMultiple = (decimalPlaces = _.DECIMAL_PLACES) => { 11 | return Math.pow(10, decimalPlaces) 12 | } 13 | 14 | /** 15 | * Limit decimal places 16 | * @param {Number} num 17 | * @return {Number} 18 | */ 19 | _.decimalPoint = (val = 0) => { 20 | return Math.round(val * _.getMultiple()) / _.getMultiple() || 0 21 | } 22 | 23 | /** 24 | * Get element width and height 25 | * @param {Object} elem 26 | * @return {Object} 27 | */ 28 | _.getOffset = (elem = {}) => ({ 29 | width: elem.clientWidth || 0, 30 | height: elem.clientHeight || 0 31 | }) 32 | 33 | /** 34 | * Get pageX 35 | * @param {Object} e 36 | * @return {Number} 37 | */ 38 | _.getPageX = (e) => ('pageX' in e) ? e.pageX : e.touches[0].pageX 39 | 40 | /** 41 | * Get pageY 42 | * @param {Object} e 43 | * @return {Number} 44 | */ 45 | _.getPageY = (e) => ('pageY' in e) ? e.pageY : e.touches[0].pageY 46 | 47 | /** 48 | * Gets the abscissa value of the mouse click relative to the target node 49 | * @param {Object} e 50 | * @param {Object} container 51 | * @return {Number} 52 | */ 53 | _.getDistanceX = (e, container) => 54 | _.getPageX(e) - (container.getBoundingClientRect().left + window.pageXOffset) 55 | 56 | /** 57 | * Gets the ordinate value of the mouse click relative to the target node 58 | * @param {Object} e 59 | * @param {Object} container 60 | * @return {Number} 61 | */ 62 | _.getDistanceY = (e, container) => 63 | _.getPageY(e) - (container.getBoundingClientRect().top + window.pageYOffset) 64 | 65 | /** 66 | * Treatment of boundary conditions when changing the size of the hotzone 67 | * @param {Object} itemInfo 68 | * @param {Object} styleInfo 69 | * @param {Object} container 70 | */ 71 | _.dealEdgeValue = (itemInfo, styleInfo, container) => { 72 | if (styleInfo.hasOwnProperty('left') && styleInfo.left < 0) { 73 | styleInfo.left = 0 74 | styleInfo.width = itemInfo.width + itemInfo.left 75 | } 76 | 77 | if (styleInfo.hasOwnProperty('top') && styleInfo.top < 0) { 78 | styleInfo.top = 0 79 | styleInfo.height = itemInfo.height + itemInfo.top 80 | } 81 | 82 | if (!styleInfo.hasOwnProperty('left') && styleInfo.hasOwnProperty('width')) { 83 | if (itemInfo.left + styleInfo.width > container.width) { 84 | styleInfo.width = container.width - itemInfo.left 85 | } 86 | } 87 | 88 | if (!styleInfo.hasOwnProperty('top') && styleInfo.hasOwnProperty('height')) { 89 | if (itemInfo.top + styleInfo.height > container.height) { 90 | styleInfo.height = container.height - itemInfo.top 91 | } 92 | } 93 | 94 | return Object.assign(itemInfo, styleInfo) 95 | } 96 | 97 | /** 98 | * Handle different drag points, capital letters mean: T-top,L-left,C-center,R-right,B-bottom 99 | * @param {Object} itemInfo 100 | * @param {Number} moveX 101 | * @param {Number} moveY 102 | * @return {Object} 103 | */ 104 | _.dealTL = (itemInfo, moveX, moveY, minLimit = _.MIN_LIMIT) => { 105 | let styleInfo = {} 106 | let width = itemInfo.width - moveX 107 | let height = itemInfo.height - moveY 108 | 109 | if (width >= Math.min(minLimit, itemInfo.width)) { 110 | Object.assign(styleInfo, { 111 | width, 112 | left: itemInfo.left + moveX 113 | }) 114 | } 115 | 116 | if (height >= Math.min(minLimit, itemInfo.height)) { 117 | Object.assign(styleInfo, { 118 | height, 119 | top: itemInfo.top + moveY 120 | }) 121 | } 122 | 123 | return styleInfo 124 | } 125 | 126 | _.dealTC = (itemInfo, moveX, moveY, minLimit = _.MIN_LIMIT) => { 127 | let styleInfo = {} 128 | let height = itemInfo.height - moveY 129 | 130 | if (height >= Math.min(minLimit, itemInfo.height)) { 131 | styleInfo = { 132 | height, 133 | top: itemInfo.top + moveY 134 | } 135 | } 136 | 137 | return styleInfo 138 | } 139 | 140 | _.dealTR = (itemInfo, moveX, moveY, minLimit = _.MIN_LIMIT) => { 141 | let styleInfo = {} 142 | let width = itemInfo.width + moveX 143 | let height = itemInfo.height - moveY 144 | 145 | if (width >= Math.min(minLimit, itemInfo.width)) { 146 | Object.assign(styleInfo, { 147 | width 148 | }) 149 | } 150 | 151 | if (height >= Math.min(minLimit, itemInfo.height)) { 152 | Object.assign(styleInfo, { 153 | height, 154 | top: itemInfo.top + moveY 155 | }) 156 | } 157 | 158 | return styleInfo 159 | } 160 | 161 | _.dealCL = (itemInfo, moveX, moveY, minLimit = _.MIN_LIMIT) => { 162 | let styleInfo = {} 163 | let width = itemInfo.width - moveX 164 | 165 | if (width >= Math.min(minLimit, itemInfo.width)) { 166 | Object.assign(styleInfo, { 167 | width, 168 | left: itemInfo.left + moveX 169 | }) 170 | } 171 | 172 | return styleInfo 173 | } 174 | 175 | _.dealCR = (itemInfo, moveX, moveY, minLimit = _.MIN_LIMIT) => { 176 | let styleInfo = {} 177 | let width = itemInfo.width + moveX 178 | 179 | if (width >= Math.min(minLimit, itemInfo.width)) { 180 | Object.assign(styleInfo, { 181 | width 182 | }) 183 | } 184 | 185 | return styleInfo 186 | } 187 | 188 | _.dealBL = (itemInfo, moveX, moveY, minLimit = _.MIN_LIMIT) => { 189 | let styleInfo = {} 190 | let width = itemInfo.width - moveX 191 | let height = itemInfo.height + moveY 192 | 193 | if (width >= Math.min(minLimit, itemInfo.width)) { 194 | Object.assign(styleInfo, { 195 | width, 196 | left: itemInfo.left + moveX 197 | }) 198 | } 199 | 200 | if (height >= Math.min(minLimit, itemInfo.height)) { 201 | Object.assign(styleInfo, { 202 | height 203 | }) 204 | } 205 | 206 | return styleInfo 207 | } 208 | 209 | _.dealBC = (itemInfo, moveX, moveY, minLimit = _.MIN_LIMIT) => { 210 | let styleInfo = {} 211 | let height = itemInfo.height + moveY 212 | 213 | if (height >= Math.min(minLimit, itemInfo.height)) { 214 | Object.assign(styleInfo, { 215 | height 216 | }) 217 | } 218 | 219 | return styleInfo 220 | } 221 | 222 | _.dealBR = (itemInfo, moveX, moveY, minLimit = _.MIN_LIMIT) => { 223 | let styleInfo = {} 224 | let width = itemInfo.width + moveX 225 | let height = itemInfo.height + moveY 226 | 227 | if (width >= Math.min(minLimit, itemInfo.width)) { 228 | Object.assign(styleInfo, { 229 | width 230 | }) 231 | } 232 | 233 | if (height >= Math.min(minLimit, itemInfo.height)) { 234 | Object.assign(styleInfo, { 235 | height 236 | }) 237 | } 238 | 239 | return styleInfo 240 | } 241 | 242 | export default _ 243 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "vue-hotzone", 3 | "version": "1.1.0", 4 | "private": false, 5 | "main": "lib/index.js", 6 | "scripts": { 7 | "serve": "vue-cli-service serve --open", 8 | "build": "vue-cli-service build", 9 | "test:unit": "vue-cli-service test:unit", 10 | "lint": "vue-cli-service lint" 11 | }, 12 | "devDependencies": { 13 | "@vue/cli-plugin-babel": "4.5.13", 14 | "@vue/cli-plugin-eslint": "4.5.13", 15 | "@vue/cli-plugin-unit-jest": "4.5.13", 16 | "@vue/cli-service": "4.5.13", 17 | "@vue/test-utils": "1.2.2", 18 | "babel-eslint": "10.1.0", 19 | "codecov": "3.8.3", 20 | "core-js": "3.17.3", 21 | "eslint": "7.32.0", 22 | "eslint-plugin-vue": "7.18.0", 23 | "vue": "2.6.14", 24 | "vue-template-compiler": "2.6.14" 25 | }, 26 | "dependencies": {} 27 | } 28 | -------------------------------------------------------------------------------- /postcss.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | plugins: { 3 | autoprefixer: {} 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /public/favicon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/OrangeXC/vue-hotzone/dbf4dc3e238fbe3d6e2abb218885c78d6584c423/public/favicon.png -------------------------------------------------------------------------------- /public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | vue-hotzone 9 | 10 | 11 | 14 |
15 | 16 | 17 | 18 | -------------------------------------------------------------------------------- /src/App.vue: -------------------------------------------------------------------------------- 1 | 40 | 41 | 83 | 84 | 133 | -------------------------------------------------------------------------------- /src/main.js: -------------------------------------------------------------------------------- 1 | import Vue from 'vue' 2 | import App from './App.vue' 3 | 4 | Vue.config.productionTip = false 5 | 6 | new Vue({ 7 | render: h => h(App) 8 | }).$mount('#app') 9 | -------------------------------------------------------------------------------- /tests/unit/components/hotzone.spec.js: -------------------------------------------------------------------------------- 1 | import { mount } from '@vue/test-utils' 2 | import Hotzone from '../../../lib/components/Hotzone' 3 | 4 | describe('component: Hotzone', () => { 5 | const mocks = { 6 | image: 'test.jpg', 7 | zonesInit: [{ 8 | topPer: 0.5, 9 | leftPer: 0.5, 10 | widthPer: 0.5, 11 | heightPer: 0.5 12 | }, { 13 | topPer: 0.3, 14 | leftPer: 0.3, 15 | widthPer: 0.3, 16 | heightPer: 0.3 17 | }], 18 | zone: { 19 | topPer: 0.15, 20 | leftPer: 0.25, 21 | widthPer: 0.35, 22 | heightPer: 0.45 23 | } 24 | } 25 | 26 | test('props', () => { 27 | const options = { 28 | propsData: { 29 | image: mocks.image, 30 | zonesInit: mocks.zonesInit, 31 | max: 5 32 | } 33 | } 34 | 35 | const wrapper = mount(Hotzone, options) 36 | 37 | expect(wrapper.props().image).toBe(mocks.image) 38 | 39 | const image = wrapper.find('.hz-u-img') 40 | 41 | expect(image.attributes().src).toBe(mocks.image) 42 | 43 | expect(wrapper.props().zonesInit).toEqual(mocks.zonesInit) 44 | expect(wrapper.vm.zones).toEqual(mocks.zonesInit) 45 | 46 | expect(wrapper.props().max).toEqual(options.propsData.max) 47 | }) 48 | 49 | test('methods: changeInfo', () => { 50 | const changeItem = jest.fn() 51 | 52 | const wrapper = mount(Hotzone, { 53 | propsData: { 54 | image: mocks.image 55 | }, 56 | methods: { 57 | changeItem 58 | } 59 | }) 60 | 61 | const res = { 62 | info: {}, 63 | index: 0 64 | } 65 | 66 | wrapper.vm.changeInfo(res) 67 | 68 | expect(changeItem).toBeCalledWith(res.info, res.index) 69 | }) 70 | 71 | test('methods: addItem', () => { 72 | const hasChange = jest.fn() 73 | 74 | const wrapper = mount(Hotzone, { 75 | propsData: { 76 | image: mocks.image 77 | }, 78 | methods: { 79 | hasChange 80 | } 81 | }) 82 | 83 | wrapper.vm.addItem(mocks.zone) 84 | 85 | expect(hasChange).toBeCalled() 86 | expect(wrapper.vm.zones).toEqual([mocks.zone]) 87 | expect(wrapper.emitted('add')[0][0]).toEqual(mocks.zone) 88 | }) 89 | 90 | test('methods: eraseItem', () => { 91 | const removeItem = jest.fn() 92 | 93 | const wrapper = mount(Hotzone, { 94 | propsData: { 95 | image: mocks.image, 96 | zonesInit: mocks.zonesInit 97 | }, 98 | methods: { 99 | removeItem 100 | } 101 | }) 102 | 103 | wrapper.vm.eraseItem() 104 | 105 | expect(removeItem).toHaveBeenCalledWith(1) 106 | expect(wrapper.emitted('erase')[0][0]).toBe(1) 107 | 108 | wrapper.vm.eraseItem(0) 109 | 110 | expect(removeItem).toHaveBeenCalledWith(0) 111 | expect(wrapper.emitted('erase')[1][0]).toBe(0) 112 | }) 113 | 114 | test('methods: isOverRange', () => { 115 | const wrapper = mount(Hotzone, { 116 | propsData: { 117 | image: mocks.image, 118 | zonesInit: mocks.zonesInit, 119 | max: 1 120 | } 121 | }) 122 | 123 | expect(wrapper.vm.isOverRange()).toBeTruthy() 124 | 125 | wrapper.setProps({ 126 | max: 2 127 | }) 128 | 129 | expect(wrapper.vm.isOverRange()).toBeFalsy() 130 | }) 131 | 132 | test('methods: overRange', () => { 133 | const removeItem = jest.fn() 134 | 135 | const wrapper = mount(Hotzone, { 136 | propsData: { 137 | image: mocks.image, 138 | zonesInit: mocks.zonesInit 139 | }, 140 | methods: { 141 | removeItem 142 | } 143 | }) 144 | 145 | wrapper.vm.overRange() 146 | 147 | expect(removeItem).toBeCalledWith(1) 148 | expect(wrapper.emitted('overRange')[0][0]).toBe(1) 149 | }) 150 | 151 | test('methods: removeItem', () => { 152 | const hasChange = jest.fn() 153 | 154 | const wrapper = mount(Hotzone, { 155 | propsData: { 156 | image: mocks.image, 157 | zonesInit: mocks.zonesInit 158 | }, 159 | methods: { 160 | hasChange 161 | } 162 | }) 163 | 164 | wrapper.vm.removeItem(0) 165 | 166 | expect(wrapper.vm.zones).toEqual([mocks.zonesInit[1]]) 167 | expect(hasChange).toBeCalled() 168 | expect(wrapper.emitted('remove')[0][0]).toBe(0) 169 | 170 | wrapper.vm.removeItem() 171 | 172 | expect(wrapper.vm.zones).toEqual([]) 173 | expect(hasChange).toBeCalled() 174 | expect(wrapper.emitted('remove')[1][0]).toBe(0) 175 | }) 176 | 177 | test('methods: changeItem', () => { 178 | const hasChange = jest.fn() 179 | 180 | const wrapper = mount(Hotzone, { 181 | propsData: { 182 | image: mocks.image, 183 | zonesInit: mocks.zonesInit 184 | }, 185 | methods: { 186 | hasChange 187 | } 188 | }) 189 | 190 | wrapper.vm.changeItem(mocks.zone, 0) 191 | 192 | expect(wrapper.vm.zones).toEqual([mocks.zone, mocks.zonesInit[1]]) 193 | expect(hasChange).toBeCalled() 194 | 195 | wrapper.vm.changeItem(mocks.zone) 196 | 197 | expect(wrapper.vm.zones).toEqual([mocks.zone, mocks.zone]) 198 | expect(hasChange).toBeCalled() 199 | }) 200 | 201 | test('methods: hasChange', () => { 202 | const wrapper = mount(Hotzone, { 203 | propsData: { 204 | image: mocks.image, 205 | zonesInit: mocks.zonesInit 206 | } 207 | }) 208 | 209 | wrapper.vm.hasChange() 210 | 211 | expect(wrapper.emitted('change')[0][0]).toEqual(mocks.zonesInit) 212 | }) 213 | }) 214 | -------------------------------------------------------------------------------- /tests/unit/components/zone.spec.js: -------------------------------------------------------------------------------- 1 | import { mount } from '@vue/test-utils' 2 | import Zone from '../../../lib/components/Zone' 3 | 4 | describe('component: Zone', () => { 5 | const mocks = { 6 | setting: { 7 | topPer: 0.15, 8 | leftPer: 0.25, 9 | widthPer: 0.35, 10 | heightPer: 0.45 11 | } 12 | } 13 | 14 | test('template', () => { 15 | const wrapper = mount(Zone, { 16 | propsData: { 17 | setting: mocks.setting, 18 | index: 0 19 | } 20 | }) 21 | 22 | const ul = wrapper.find('.hz-m-box') 23 | 24 | expect(ul.classes()).toContain('hz-m-hoverbox') 25 | 26 | const indexWrap = wrapper.find('.hz-u-index') 27 | 28 | expect(indexWrap.attributes().title).toBe('热区1') 29 | expect(indexWrap.text()).toBe('1') 30 | 31 | const closeWrap = wrapper.find('.hz-u-close') 32 | 33 | expect(closeWrap.isVisible()).toBeTruthy() 34 | expect(closeWrap.attributes().title).toBe('删除该热区') 35 | expect(closeWrap.classes()).toContain('hz-icon') 36 | expect(closeWrap.classes()).toContain('hz-icon-trash') 37 | 38 | const pointers = ['dealTL', 'dealTC', 'dealTR', 'dealCL', 'dealCR', 'dealBL', 'dealBC', 'dealBR'] 39 | 40 | pointers.forEach((item, index) => { 41 | const square = wrapper.findAll('.hz-u-square').at(index) 42 | 43 | expect(square.attributes()['data-pointer']).toBe(item) 44 | expect(square.classes()).toContain(`hz-u-square-${item.slice(4).toLowerCase()}`) 45 | }) 46 | }) 47 | 48 | test('mounted', () => { 49 | const setZoneInfo = jest.fn() 50 | 51 | mount(Zone, { 52 | propsData: { 53 | setting: mocks.setting 54 | }, 55 | methods: { 56 | setZoneInfo 57 | } 58 | }) 59 | 60 | expect(setZoneInfo).toBeCalledWith(mocks.setting) 61 | }) 62 | 63 | test('methods: setZoneInfo', () => { 64 | const wrapper = mount(Zone, { 65 | propsData: { 66 | setting: mocks.setting 67 | } 68 | }) 69 | 70 | expect(wrapper.vm.zoneTop).toBe('15%') 71 | expect(wrapper.vm.zoneLeft).toBe('25%') 72 | expect(wrapper.vm.zoneWidth).toBe('35%') 73 | expect(wrapper.vm.zoneHeight).toBe('45%') 74 | expect(wrapper.vm.tooSmall).toBeFalsy() 75 | 76 | wrapper.vm.setZoneInfo({ 77 | topPer: 0.05, 78 | leftPer: 0.05, 79 | widthPer: 0.005, 80 | heightPer: 0.0005 81 | }) 82 | 83 | expect(wrapper.vm.zoneTop).toBe('5%') 84 | expect(wrapper.vm.zoneLeft).toBe('5%') 85 | expect(wrapper.vm.zoneWidth).toBe('0.5%') 86 | expect(wrapper.vm.zoneHeight).toBe('0.05%') 87 | expect(wrapper.vm.tooSmall).toBeTruthy() 88 | }) 89 | 90 | test('methods: handlehideZone', () => { 91 | const wrapper = mount(Zone, { 92 | propsData: { 93 | setting: mocks.setting 94 | } 95 | }) 96 | 97 | expect(wrapper.vm.hideZone).toBeFalsy() 98 | 99 | wrapper.vm.handlehideZone() 100 | 101 | expect(wrapper.vm.hideZone).toBeTruthy() 102 | 103 | wrapper.vm.handlehideZone(true) 104 | 105 | expect(wrapper.vm.hideZone).toBeTruthy() 106 | 107 | wrapper.vm.handlehideZone(false) 108 | 109 | expect(wrapper.vm.hideZone).toBeFalsy() 110 | }) 111 | 112 | test('methods: changeInfo', () => { 113 | const wrapper = mount(Zone, { 114 | propsData: { 115 | setting: mocks.setting, 116 | index: 3 117 | } 118 | }) 119 | 120 | wrapper.vm.changeInfo(mocks.setting) 121 | 122 | expect(wrapper.emitted('changeInfo')[0][0]).toEqual({ 123 | info: mocks.setting, 124 | index: 3 125 | }) 126 | 127 | wrapper.setProps({ index: 0 }) 128 | wrapper.vm.changeInfo() 129 | 130 | expect(wrapper.emitted('changeInfo')[1][0]).toEqual({ 131 | info: {}, 132 | index: 0 133 | }) 134 | }) 135 | 136 | test('methods: delItem', () => { 137 | const wrapper = mount(Zone, { 138 | propsData: { 139 | setting: mocks.setting, 140 | index: 0 141 | } 142 | }) 143 | 144 | const closeWrap = wrapper.find('.hz-u-close') 145 | 146 | closeWrap.trigger('click') 147 | 148 | expect(wrapper.emitted('delItem')[0][0]).toBe(0) 149 | }) 150 | 151 | test('methods: getZoneStyle', () => { 152 | const wrapper = mount(Zone, { 153 | propsData: { 154 | setting: mocks.setting 155 | } 156 | }) 157 | 158 | expect(wrapper.vm.getZoneStyle()).toBe('0%') 159 | expect(wrapper.vm.getZoneStyle(0.36)).toBe('36%') 160 | }) 161 | }) 162 | -------------------------------------------------------------------------------- /tests/unit/utils.spec.js: -------------------------------------------------------------------------------- 1 | import _ from '../../lib/utils' 2 | 3 | describe('utils', () => { 4 | test('getMultiple', () => { 5 | expect(_.getMultiple()).toBe(10000) 6 | expect(_.getMultiple(2)).toBe(100) 7 | }) 8 | 9 | test('decimalPoint', () => { 10 | expect(_.decimalPoint()).toBe(0) 11 | expect(_.decimalPoint(0.5)).toBe(0.5) 12 | expect(_.decimalPoint(0.123456)).toBe(0.1235) 13 | }) 14 | 15 | test('getOffset', () => { 16 | expect(_.getOffset()).toEqual({ 17 | width: 0, 18 | height: 0 19 | }) 20 | 21 | const elem = { 22 | clientWidth: 20, 23 | clientHeight: 50 24 | } 25 | 26 | expect(_.getOffset(elem)).toEqual({ 27 | width: 20, 28 | height: 50 29 | }) 30 | }) 31 | 32 | test('getPageX', () => { 33 | let elem = { 34 | pageX: 26, 35 | touches: [{ 36 | pageX: 18 37 | }] 38 | } 39 | 40 | expect(_.getPageX(elem)).toBe(26) 41 | 42 | delete elem.pageX 43 | 44 | expect(_.getPageX(elem)).toBe(18) 45 | }) 46 | 47 | test('getPageY', () => { 48 | let elem = { 49 | pageY: 26, 50 | touches: [{ 51 | pageY: 18 52 | }] 53 | } 54 | 55 | expect(_.getPageY(elem)).toBe(26) 56 | 57 | delete elem.pageY 58 | 59 | expect(_.getPageY(elem)).toBe(18) 60 | }) 61 | 62 | test('getDistanceX', () => { 63 | const elem = { 64 | pageX: 100 65 | } 66 | 67 | const container = { 68 | getBoundingClientRect: () => ({ 69 | left: 20 70 | }) 71 | } 72 | 73 | window.pageXOffset = 5 74 | 75 | expect(_.getDistanceX(elem, container)).toBe(75) 76 | }) 77 | 78 | test('getDistanceY', () => { 79 | const elem = { 80 | pageY: 100 81 | } 82 | 83 | const container = { 84 | getBoundingClientRect: () => ({ 85 | top: 15 86 | }) 87 | } 88 | 89 | window.pageYOffset = 5 90 | 91 | expect(_.getDistanceY(elem, container)).toBe(80) 92 | }) 93 | 94 | test('dealEdgeValue', () => { 95 | let itemInfo = { 96 | width: 10, 97 | left: 20 98 | } 99 | 100 | let styleInfo = { 101 | left: -1 102 | } 103 | 104 | expect(_.dealEdgeValue(itemInfo, styleInfo, {})).toEqual({ 105 | left: 0, 106 | width: 30 107 | }) 108 | 109 | itemInfo = { 110 | top: 3, 111 | height: 6 112 | } 113 | 114 | styleInfo = { 115 | top: -2 116 | } 117 | 118 | expect(_.dealEdgeValue(itemInfo, styleInfo, {})).toEqual({ 119 | top: 0, 120 | height: 9 121 | }) 122 | 123 | itemInfo = { 124 | left: 2 125 | } 126 | 127 | styleInfo = { 128 | width: 23 129 | } 130 | 131 | let container = { 132 | width: 8 133 | } 134 | 135 | expect(_.dealEdgeValue(itemInfo, styleInfo, container)).toEqual({ 136 | left: 2, 137 | width: 6 138 | }) 139 | 140 | container.width = 1000 141 | 142 | expect(_.dealEdgeValue(itemInfo, styleInfo, container)).toEqual({ 143 | left: 2, 144 | width: 6 145 | }) 146 | 147 | itemInfo = { 148 | top: 10 149 | } 150 | 151 | styleInfo = { 152 | height: 21 153 | } 154 | 155 | container = { 156 | height: 13 157 | } 158 | 159 | expect(_.dealEdgeValue(itemInfo, styleInfo, container)).toEqual({ 160 | top: 10, 161 | height: 3 162 | }) 163 | 164 | container.height = 1000 165 | 166 | expect(_.dealEdgeValue(itemInfo, styleInfo, container)).toEqual({ 167 | top: 10, 168 | height: 3 169 | }) 170 | }) 171 | 172 | test('dealTL', () => { 173 | const itemInfo = { 174 | width: 100, 175 | height: 100, 176 | left: 3, 177 | top: 4 178 | } 179 | 180 | expect(_.dealTL(itemInfo, 2, 2)).toEqual({ 181 | left: 5, 182 | top: 6, 183 | height: 98, 184 | width: 98 185 | }) 186 | 187 | expect(_.dealTL(itemInfo, 100, 2)).toEqual({ 188 | top: 6, 189 | height: 98 190 | }) 191 | 192 | expect(_.dealTL(itemInfo, 2, 100)).toEqual({ 193 | left: 5, 194 | width: 98 195 | }) 196 | }) 197 | 198 | test('dealTC', () => { 199 | const itemInfo = { 200 | width: 100, 201 | height: 100, 202 | left: 3, 203 | top: 4 204 | } 205 | 206 | expect(_.dealTC(itemInfo, 2, 2)).toEqual({ 207 | top: 6, 208 | height: 98 209 | }) 210 | 211 | expect(_.dealTC(itemInfo, 2, 100)).toEqual({}) 212 | }) 213 | 214 | test('dealTR', () => { 215 | const itemInfo = { 216 | width: 100, 217 | height: 100, 218 | left: 3, 219 | top: 4 220 | } 221 | 222 | expect(_.dealTR(itemInfo, 2, 2)).toEqual({ 223 | top: 6, 224 | height: 98, 225 | width: 102 226 | }) 227 | 228 | expect(_.dealTR(itemInfo, -100, 2)).toEqual({ 229 | top: 6, 230 | height: 98 231 | }) 232 | 233 | expect(_.dealTR(itemInfo, 2, 100)).toEqual({ 234 | width: 102 235 | }) 236 | }) 237 | 238 | test('dealCL', () => { 239 | const itemInfo = { 240 | width: 100, 241 | height: 100, 242 | left: 3, 243 | top: 4 244 | } 245 | 246 | expect(_.dealCL(itemInfo, 2, 2)).toEqual({ 247 | left: 5, 248 | width: 98 249 | }) 250 | 251 | expect(_.dealCL(itemInfo, 100, 2)).toEqual({}) 252 | }) 253 | 254 | test('dealCR', () => { 255 | const itemInfo = { 256 | width: 100, 257 | height: 100, 258 | left: 3, 259 | top: 4 260 | } 261 | 262 | expect(_.dealCR(itemInfo, 2, 2)).toEqual({ 263 | width: 102 264 | }) 265 | 266 | expect(_.dealCR(itemInfo, -100, 2)).toEqual({}) 267 | }) 268 | 269 | test('dealBL', () => { 270 | const itemInfo = { 271 | width: 100, 272 | height: 100, 273 | left: 3, 274 | top: 4 275 | } 276 | 277 | expect(_.dealBL(itemInfo, 2, 2)).toEqual({ 278 | height: 102, 279 | left: 5, 280 | width: 98 281 | }) 282 | 283 | expect(_.dealBL(itemInfo, 100, 2)).toEqual({ 284 | height: 102 285 | }) 286 | 287 | expect(_.dealBL(itemInfo, 2, -100)).toEqual({ 288 | left: 5, 289 | width: 98 290 | }) 291 | }) 292 | 293 | test('dealBC', () => { 294 | const itemInfo = { 295 | width: 100, 296 | height: 100, 297 | left: 3, 298 | top: 4 299 | } 300 | 301 | expect(_.dealBC(itemInfo, 2, 2)).toEqual({ 302 | height: 102 303 | }) 304 | 305 | expect(_.dealBC(itemInfo, 2, -100)).toEqual({}) 306 | }) 307 | 308 | test('dealBR', () => { 309 | const itemInfo = { 310 | width: 100, 311 | height: 100, 312 | left: 3, 313 | top: 4 314 | } 315 | 316 | expect(_.dealBR(itemInfo, 2, 2)).toEqual({ 317 | width: 102, 318 | height: 102 319 | }) 320 | 321 | expect(_.dealBR(itemInfo, -100, 2)).toEqual({ 322 | height: 102 323 | }) 324 | 325 | expect(_.dealBR(itemInfo, 2, -100)).toEqual({ 326 | width: 102 327 | }) 328 | }) 329 | }) 330 | -------------------------------------------------------------------------------- /vue.config.js: -------------------------------------------------------------------------------- 1 | /* eslint-env node */ 2 | module.exports = { 3 | publicPath: process.env.NODE_ENV === 'production' ? './' : '' 4 | } 5 | --------------------------------------------------------------------------------