├── .browserslistrc ├── .eslintrc.js ├── .github └── workflows │ └── deploy.yml ├── .gitignore ├── README.md ├── assets └── images │ ├── canvas-spreadsheet.gif │ └── complex-header.png ├── babel.config.js ├── package-lock.json ├── package.json ├── public ├── favicon.ico └── index.html ├── src ├── App.vue ├── assets │ └── logo.png ├── components │ ├── Icon │ │ ├── index.js │ │ ├── index.vue │ │ └── svg │ │ │ ├── arrow-back.svg │ │ │ ├── arrow-forward.svg │ │ │ ├── asce.svg │ │ │ ├── desc.svg │ │ │ ├── reset.svg │ │ │ ├── rotate-left.svg │ │ │ └── rotate-right.svg │ ├── clickoutside.js │ ├── index.scss │ └── index.vue ├── core │ ├── Body.js │ ├── Cell.js │ ├── Clipboard.js │ ├── ColumnHeader.js │ ├── Context.js │ ├── DataGrid.js │ ├── Editor.js │ ├── Events.js │ ├── Header.js │ ├── History.js │ ├── Paint.js │ ├── Row.js │ ├── RowHeader.js │ ├── Scroller.js │ ├── Selector.js │ ├── Tooltip.js │ ├── Validator.js │ ├── config.js │ ├── constants.js │ ├── element.js │ ├── images │ │ ├── date.png │ │ ├── indeterminate.png │ │ ├── offcheck.png │ │ ├── oncheck.png │ │ └── time.png │ └── util.js ├── main.js └── plugins │ └── element.js ├── tests └── unit │ └── example.spec.js ├── vue.config.js └── yarn.lock /.browserslistrc: -------------------------------------------------------------------------------- 1 | > 1% 2 | last 2 versions 3 | -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | root: true, 3 | env: { 4 | node: true 5 | }, 6 | extends: ["plugin:vue/essential", "eslint:recommended"], 7 | // extends: ["plugin:vue/essential", "eslint:recommended", "@vue/prettier"], 8 | parserOptions: { 9 | parser: "babel-eslint" 10 | }, 11 | rules: { 12 | "no-console": process.env.NODE_ENV === "production" ? "error" : "off", 13 | "no-debugger": process.env.NODE_ENV === "production" ? "error" : "off" 14 | }, 15 | overrides: [ 16 | { 17 | files: [ 18 | "**/__tests__/*.{j,t}s?(x)", 19 | "**/tests/unit/**/*.spec.{j,t}s?(x)" 20 | ], 21 | env: { 22 | mocha: true 23 | } 24 | } 25 | ] 26 | }; 27 | -------------------------------------------------------------------------------- /.github/workflows/deploy.yml: -------------------------------------------------------------------------------- 1 | # This is a basic workflow to help you get started with Actions 2 | 3 | name: Deploy-CI 4 | 5 | # Controls when the action will run. Triggers the workflow on push or pull request 6 | # events but only for the master branch 7 | on: 8 | push: 9 | branches: [ master ] 10 | pull_request: 11 | branches: [ master ] 12 | 13 | # A workflow run is made up of one or more jobs that can run sequentially or in parallel 14 | jobs: 15 | # This workflow contains a single job called "build" 16 | deploy: 17 | # The type of runner that the job will run on 18 | runs-on: ubuntu-latest 19 | 20 | # Steps represent a sequence of tasks that will be executed as part of the job 21 | steps: 22 | # Checks-out your repository under $GITHUB_WORKSPACE, so your job can access it 23 | - name: Checkout 24 | uses: actions/checkout@v2 # If you're using actions/checkout@v2 you must set persist-credentials to false in most cases for the deployment to work correctly. 25 | with: 26 | persist-credentials: false 27 | 28 | # Runs a single command using the runners shell 29 | - name: Install and Build 30 | run: | 31 | npm install 32 | npm run-script build 33 | - name: Deploy 34 | uses: JamesIves/github-pages-deploy-action@releases/v3 35 | #run: npm run deploy 36 | with: 37 | ACCESS_TOKEN: ${{ secrets.ACCESS_TOKEN }} 38 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 39 | BRANCH: gh-pages # The branch the action should deploy to. 40 | FOLDER: dist # The folder the action should deploy. 41 | #CLEAN: true # Automatically remove deleted files from the deploy branch 42 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | node_modules 3 | /dist 4 | 5 | # local env files 6 | .env.local 7 | .env.*.local 8 | 9 | # Log files 10 | npm-debug.log* 11 | yarn-debug.log* 12 | yarn-error.log* 13 | 14 | # Editor directories and files 15 | .idea 16 | .vscode 17 | *.suo 18 | *.ntvs* 19 | *.njsproj 20 | *.sln 21 | *.sw? 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # canvas-spreadsheet 2 | > 基于Canvas的一款高性能在线编辑组件,拥有仿Excel的复制粘贴、拖拽柄、实时编辑、6种不同数据类型、基本数据校验等功能 3 | 4 | ## Examples 5 | Please try it on [Live Demo](https://harlen.cn/canvas-spreadsheet/). 6 | 7 | ### Screenshot 8 | ![image](/assets/images/canvas-spreadsheet.gif) 9 | 10 | ## Feature 11 | - [x] 支持(文本、数字、电话、邮箱、日期、下拉)6种基本数据类型 12 | - [x] 支持6种数据类型的格式校验及实时错误提示 13 | - [x] 单元格内容支持(左、中、右)三种对齐方式 14 | - [x] 单元格数据编辑、选区 15 | - [x] 批量复制、粘贴数据 16 | - [x] 批量选择行和列:点击表头选择整列,点击序号整行(支持shift快捷键) 17 | - [x] 拖拽柄拖拽自动填充(Autofill) 18 | - [x] 单元格内容自定义渲染函数,基本的文本转换 19 | - [x] 指定列支持锁定,不可编辑 20 | - [x] 当前焦点单元格所在行、列高亮 21 | - [x] 冻结表头、左侧、右侧冻结列 22 | - [x] 支持行勾选 23 | - [x] 单元格内容溢出显示样式支持(随内容自适应高度、内容隐藏)2种显示方式 24 | - [x] 随屏幕尺寸响应式渲染 25 | - [x] 模拟滚动条 26 | - [x] Tab键、方向键快速切换 27 | - [x] 复合表头 28 | - [x] Undo/Redo(撤销与反撤销) 29 | - [ ] 数据类型增加`级联组件`、`下拉多选`两种类型 30 | - [ ] Context menu右键功能菜单 31 | 32 | ## Usage 33 | > 目前仅提供了Vue版组件,后续会提供React版组件和支持纯JS环境中使用 34 | ### Example for basic usage 35 | ```html 36 | 45 | 218 | ``` 219 | ### Example for Composite header(复合表头) 220 | ![image](/assets/images/complex-header.png) 221 | 222 | 要实现复合表头只需在相应的表头数据中添加children字段即可,支持多层嵌套: 223 | ```js 224 | { 225 | title: "配送信息", 226 | key: "delivery_info", 227 | children: [ 228 | { 229 | title: "寄件人", 230 | key: "delivery_name", 231 | }, 232 | { 233 | title: "配送地址", 234 | key: "delivery_address", 235 | children: [ 236 | { 237 | title: "省", 238 | key: "province", 239 | }, 240 | { 241 | title: "市", 242 | key: "city", 243 | }, 244 | { 245 | title: "区", 246 | key: "region", 247 | } 248 | ] 249 | } 250 | ] 251 | } 252 | ``` 253 | 254 | ## Props 255 | | 参数 | 说明 | 类型 | 可选值 | 默认值 | 256 | |---------- |-------- |---------- |------------- |-------- | 257 | | rowKey | 每行数据的唯一标识 | String | | id | 258 | | width | 编辑器外层容器宽度 | Number | | 视窗宽 | 259 | | height | 编辑器外层容器高度 | Number | | 视窗高 | 260 | | showCheckbox | 是否开启勾选行功能 | Boolean | | false | 261 | | fixedLeft | 左侧冻结的列数 | Number | | 0 | 262 | | fixedRight | 右侧冻结的列数 | Number | | 0 | 263 | | columns | 表头列配置,参考[Columns Props](https://github.com/jakever/canvas-spreadsheet#columns-props) | Array | | | 264 | | data | 表格源数据 | Array | | | 265 | 266 | ## Columns Props 267 | | 参数 | 说明 | 类型 | 可选值 | 默认值 | 268 | |---------- |-------- |---------- |------------- |-------- | 269 | | title | 列的显示名称 | String | | | 270 | | key | 列的取值key | String | | | 271 | | size | 列宽 | String | mini/small/medium/large | mini | 272 | | align | 列内容水平对齐方式 | String | left/center/right | left | 273 | | readonly | 列是否锁定,不可编辑 | Boolean | | | 274 | | type | 列的数据源类型,type为除text之外的其他类型拥有内置的校验规则 | String | text/number/phone/email/select/month/date/datetime | text | 275 | | options | 列数据源类型为`select`时必须提供 | Array | | | 276 | | render | 列内容自定义渲染函数,函数参数为该列数据值 | Function | | | 277 | | rule | 自定义数据校验规则,参考[Columns Rule Props](https://github.com/jakever/canvas-spreadsheet#columns-rule-props) | Object | | | 278 | 279 | ## Columns Rule Props 280 | | 参数 | 说明 | 类型 | 可选值 | 默认值 | 281 | |---------- |-------- |---------- |------------- |-------- | 282 | | required | 表示必填 | Boolean | | | 283 | | message | 数据格式错误提示文案 | String | | | 284 | | immediate | 编辑器初始化时否立即校验 | Boolean | | true | 285 | | validator | 自定义校验规则,若为Function(支持异步),则接收3个参数:第一个参数是当前焦点单元格的数据,第二个参数是当前焦点行的数据,第三个参数是callback函数(定义校验结果或者错误提示信息,优先级高于message字段),参考[自定义Validator](https://github.com/jakever/canvas-spreadsheet#zi-ding-yi-validator-li-zi-ru-xia) | Function/RegExp | | | 286 | 287 | ### 自定义Validator例子如下: 288 | ```js 289 | // 1. 定义统一的错误message 290 | { title: "部门", 291 | key: "dep_name", 292 | size: 'small', 293 | align: 'left', 294 | rule: { 295 | message: '部门字段长度需要为0~10个字符哦!', 296 | immediate: false, 297 | validator: function(value, row, callback) { 298 | if (value.length > 10) { 299 | callback(false) 300 | } else { 301 | callback() 302 | } 303 | } 304 | } 305 | }, 306 | // 2. 定义不同的错误message 307 | { title: "岗位", 308 | key: "job_name", 309 | size: 'small', 310 | align: 'left', 311 | rule: { 312 | message: '岗位字段长度需要为0~10个字符哦!', 313 | immediate: false, 314 | validator: function(value, row, callback) { 315 | if (value.length > 10) { 316 | callback('岗位字段长度必须小于10个字符哦!') 317 | } else if (value.length < 1) { 318 | callback('岗位字段长度必须填哦!') 319 | } else { 320 | callback() 321 | } 322 | } 323 | } 324 | }, 325 | ``` 326 | 327 | ## Methods 328 | > 通过`$refs`实例调用 329 | 330 | | 方法名称 | 说明 | 参数 | 331 | |---------- |-------- |---------- | 332 | | getData | 获取表格所有数据 | | 333 | | getCheckedRows | 获取已勾选的数据 | | 334 | | getChangedRows | 获取仅发生改变的数据 | | 335 | | updateData | 更新表格数据 | Array:需要更新的数据集合 | 336 | | validate | 校验整个表格数据 | callback函数,函数参数接收一个Boolean类型的校验结果值 | 337 | | validateField | 校验指定行、指定列的单元格数据是否正确,返回一个Boolean类型的校验结果值 | x,y,需要校验的数据单元格横坐标和纵坐标 | 338 | | validateChanged | 校验仅发生改变的数据 | callback函数,函数参数接收一个Boolean类型的校验结果值 | 339 | | getValidations | 获取整个表格中校验失败的单元格集合 | | 340 | | setValidations | 设置数据校验错误结果,可以用于将后端校验后的结果批量回填到组件中 | Array:错误数据集合 | 341 | | clearValidations | 清空全部校验结果 | | 342 | 343 | ### validate使用例子如下: 344 | ```js 345 | this.$refs.datagrid.validate(valid => { 346 | if (valid) { 347 | // TODO 校验成功,继续你的操作 348 | } 349 | }) 350 | ``` 351 | ### setValidations使用例子如下: 352 | ```js 353 | const errors = [ 354 | { 355 | id: 1, 356 | 'emp_name': '错误111', 357 | 'emp_no': '错误222' 358 | }, 359 | { 360 | id: 3, 361 | 'job_name': '错误333', 362 | 'emp_no': '错误444' 363 | } 364 | ] 365 | this.$refs.datagrid.setValidations(errors); 366 | ``` 367 | 368 | ## Events 369 | | 事件名称 | 说明 | 回调参数 | 370 | |---------|--------|---------| 371 | | before-select-cell | 选取单元格之前触发 | Array: 当前选中区域单元格集合的数据 | 372 | | after-select-cell | 选取单元格之后触发 | Array: 当前选中区域单元格集合的数据 | 373 | | before-edit-cell | 编辑单元格之前触发 | Object: 当前单元格所在行的数据 | 374 | | after-edit-cell | 编辑完单元格之后触发 | Object: 当前单元格所在行的数据 | 375 | | before-select-row | 选取行之前触发 | Array: 当前选取行的数据 | 376 | | after-select-row | 选取行之后触发 | Array: 当前选取行的数据 | 377 | | before-resize-column | 调整列宽之前触发 | | 378 | | after-resize-column | 调整列宽之后触发 | | 379 | | before-resize-row | 调整行高之前触发 | | 380 | | after-resize-row | 调整行高之后触发 | | 381 | | before-autofill | 自动填充数据之前触发 | Array: 当前填充区域单元格集合所在行的数据 | 382 | | after-autofill | 自动填充数据之后触发 | Array: 当前填充区域单元格集合所在行的数据 | 383 | | before-copy | 复制数据之前触发 | Array: 当前复制区域单元格所在行的数据集合 | 384 | | after-copy | 复制数据之后触发 | Array: 当前复制区域单元格集合所在行的数据 | 385 | | before-paste | 粘贴数据之前触发 | Array: 当前粘贴区域单元格集合所在行的数据 | 386 | | after-paste | 粘贴数据之后触发 | Array: 当前粘贴区域单元格集合所在行的数据 | 387 | | after-clear | 清空单元格数据后触发 | Array: 当前清空区域单元格集合所在行的数据 | 388 | | on-load | 数据网格Dom加载完后触发 | | 389 | 390 | ## Development 391 | ``` 392 | npm install 393 | ``` 394 | 395 | ### Compiles and hot-reloads for development 396 | ``` 397 | npm run serve 398 | ``` 399 | 400 | ### Compiles and minifies for production 401 | ``` 402 | npm run build 403 | ``` -------------------------------------------------------------------------------- /assets/images/canvas-spreadsheet.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jakever/canvas-spreadsheet/ab7e3e7f94ca464610fdf4736641fd3bc8a53dc3/assets/images/canvas-spreadsheet.gif -------------------------------------------------------------------------------- /assets/images/complex-header.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jakever/canvas-spreadsheet/ab7e3e7f94ca464610fdf4736641fd3bc8a53dc3/assets/images/complex-header.png -------------------------------------------------------------------------------- /babel.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | presets: ["@vue/cli-plugin-babel/preset"], 3 | plugins: [ 4 | [ 5 | "component", 6 | { 7 | libraryName: "element-ui", 8 | styleLibraryName: "theme-chalk" 9 | } 10 | ] 11 | ] 12 | }; 13 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "canvas-spreadsheet", 3 | "version": "0.1.0", 4 | "private": true, 5 | "homepage": "https://harlen.cn/canvas-spreadsheet/", 6 | "scripts": { 7 | "serve": "vue-cli-service serve", 8 | "build": "vue-cli-service build", 9 | "test:unit": "vue-cli-service test:unit", 10 | "lint": "vue-cli-service lint", 11 | "predeploy": "npm run build", 12 | "deploy": "gh-pages -d dist" 13 | }, 14 | "dependencies": { 15 | "axios": "^0.21.0", 16 | "core-js": "^3.6.4", 17 | "element-ui": "^2.4.5", 18 | "loadsh": "^0.0.4", 19 | "vue": "^2.6.11" 20 | }, 21 | "devDependencies": { 22 | "@vue/cli-plugin-babel": "~4.2.0", 23 | "@vue/cli-plugin-eslint": "~4.2.0", 24 | "@vue/cli-plugin-unit-mocha": "~4.2.0", 25 | "@vue/cli-service": "~4.2.0", 26 | "@vue/eslint-config-prettier": "^6.0.0", 27 | "@vue/test-utils": "1.0.0-beta.31", 28 | "babel-eslint": "^10.0.3", 29 | "babel-plugin-component": "^1.1.1", 30 | "chai": "^4.1.2", 31 | "eslint": "^6.7.2", 32 | "eslint-plugin-prettier": "^3.1.1", 33 | "eslint-plugin-vue": "^6.1.2", 34 | "gh-pages": "^3.1.0", 35 | "lint-staged": "^9.5.0", 36 | "node-sass": "4.13.1", 37 | "prettier": "^1.19.1", 38 | "sass-loader": "^8.0.2", 39 | "vue-cli-plugin-element": "~1.0.1", 40 | "vue-template-compiler": "^2.6.11" 41 | }, 42 | "gitHooks": {}, 43 | "lint-staged": { 44 | "*.{js,jsx,vue}": [ 45 | "vue-cli-service lint", 46 | "git add" 47 | ] 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jakever/canvas-spreadsheet/ab7e3e7f94ca464610fdf4736641fd3bc8a53dc3/public/favicon.ico -------------------------------------------------------------------------------- /public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | <%= htmlWebpackPlugin.options.title %> 9 | 10 | 11 | 14 |
15 | 16 | 17 | 18 | -------------------------------------------------------------------------------- /src/App.vue: -------------------------------------------------------------------------------- 1 | 37 | 383 | 384 | 397 | -------------------------------------------------------------------------------- /src/assets/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jakever/canvas-spreadsheet/ab7e3e7f94ca464610fdf4736641fd3bc8a53dc3/src/assets/logo.png -------------------------------------------------------------------------------- /src/components/Icon/index.js: -------------------------------------------------------------------------------- 1 | import Vue from 'vue'; 2 | import Icon from './index.vue'; 3 | 4 | Vue.component('Icon', Icon); 5 | 6 | // 导入所有的svg(参照webpack文档: http://webpack.github.io/docs/context.html#dynamic-requires ) 7 | // ~function (requireContext) { 8 | // return requireContext.keys().map(requireContext) 9 | // }(require.context('./svg', false, /\.svg$/)) 10 | const requireAll = requireContext => requireContext.keys().map(requireContext); 11 | const req = require.context('./svg', false, /\.svg$/) 12 | requireAll(req); -------------------------------------------------------------------------------- /src/components/Icon/index.vue: -------------------------------------------------------------------------------- 1 | 7 | 37 | 38 | -------------------------------------------------------------------------------- /src/components/Icon/svg/arrow-back.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/components/Icon/svg/arrow-forward.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/components/Icon/svg/asce.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/components/Icon/svg/desc.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/components/Icon/svg/reset.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/components/Icon/svg/rotate-left.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 6 | 14 | 15 | -------------------------------------------------------------------------------- /src/components/Icon/svg/rotate-right.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/components/clickoutside.js: -------------------------------------------------------------------------------- 1 | export default { 2 | bind (el, binding, vnode) { 3 | function documentHandler (e) { 4 | if (el.contains(e.target)) { 5 | return false; 6 | } 7 | if (binding.expression) { 8 | binding.value(e); 9 | } 10 | } 11 | el.__vueClickOutside__ = documentHandler; 12 | document.addEventListener('click', documentHandler); 13 | }, 14 | update () { 15 | 16 | }, 17 | unbind (el, binding) { 18 | document.removeEventListener('click', el.__vueClickOutside__); 19 | delete el.__vueClickOutside__; 20 | } 21 | }; -------------------------------------------------------------------------------- /src/components/index.scss: -------------------------------------------------------------------------------- 1 | $css-prefix: xs-data-grid; 2 | .#{$css-prefix} { 3 | width: 100%; 4 | position: relative; 5 | } 6 | .#{$css-prefix}-main { 7 | width: 100%; 8 | position: relative; 9 | box-sizing: border-box; 10 | overflow: hidden; 11 | border-right: 1px solid #e1e6eb; 12 | border-bottom: 1px solid #e1e6eb; 13 | } 14 | .#{$css-prefix}-table { 15 | text-rendering: auto; 16 | user-select: none; 17 | position: relative; 18 | left: 0px; 19 | top: 0px; 20 | cursor: default; 21 | } 22 | .#{$css-prefix}-overlayer { 23 | position: absolute; 24 | top: 0; 25 | left: 0; 26 | width: 100%; 27 | height: 100%; 28 | pointer-events: none; 29 | } 30 | .#{$css-prefix}-editor { 31 | position: absolute; 32 | top: -10000px; 33 | left: -10000px; 34 | text-align: left; 35 | line-height: 0; 36 | z-index: 100; 37 | overflow: hidden; 38 | background-color: #fff; 39 | border: 2px solid rgb(82,146,247); 40 | box-sizing: border-box; 41 | box-shadow: rgba(0, 0, 0, 0.2) 0px 6px 16px; 42 | pointer-events: auto; 43 | 44 | div[contenteditable="true"] { 45 | box-sizing: border-box; 46 | // border: 2px solid #4b89ff; 47 | // padding: 5px; 48 | padding: 8px; 49 | outline: none; 50 | // resize: none; 51 | // text-align: start; 52 | // overflow-y: hidden; 53 | font-family: "Helvetica Neue", Helvetica, "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", "\5FAE\8F6F\96C5\9ED1", Arial, sans-serif; 54 | font-weight: 400; 55 | font-size: 12px; 56 | color: inherit; 57 | white-space: pre-wrap; 58 | word-wrap: break-word; 59 | word-break: break-all; 60 | line-height: 18px; 61 | margin: 0; 62 | background: #fff; 63 | cursor: text; 64 | } 65 | } 66 | .#{$css-prefix}-popup { 67 | input[type="text"] { 68 | border: none; 69 | outline: none; 70 | border-radius: 0; 71 | } 72 | } 73 | .#{$css-prefix}-fullscreen { 74 | position: fixed; 75 | top: 0; 76 | left: 0; 77 | bottom: 0; 78 | right: 0; 79 | width: 100%; 80 | min-height: 100vh; 81 | background: #fff; 82 | z-index: 9999; 83 | overflow: hidden; 84 | } 85 | .#{$css-prefix}-loading { 86 | position: fixed; 87 | top: 0; 88 | left: 0; 89 | bottom: 0; 90 | right: 0; 91 | width: 100%; 92 | min-height: 100vh; 93 | background: #fff; 94 | z-index: 9998; 95 | overflow: hidden; 96 | } 97 | .#{$css-prefix}-loading-dot { 98 | display: block; 99 | margin: 0 auto; 100 | background: #0bb27a; 101 | border-radius: 50%; 102 | animation: spin-dot 1s ease-in-out infinite; 103 | width: 20px; 104 | height: 20px; 105 | } 106 | @keyframes spin-dot { 107 | 0% { 108 | transform: scale(0); 109 | opacity: 1; 110 | } 111 | 112 | 100% { 113 | transform: scale(1); 114 | opacity: 0; 115 | } 116 | } -------------------------------------------------------------------------------- /src/components/index.vue: -------------------------------------------------------------------------------- 1 | 102 | 533 | -------------------------------------------------------------------------------- /src/core/Body.js: -------------------------------------------------------------------------------- 1 | import { CELL_HEIGHT } from "./constants.js"; 2 | import Row from "./Row.js"; 3 | 4 | class Body { 5 | constructor(grid, data) { 6 | this.grid = grid; 7 | 8 | this.paint(data); 9 | } 10 | paint(data) { 11 | this.data = data; 12 | this.rows = []; 13 | const len = data.length; 14 | let everyOffsetY = this.grid.tableHeaderHeight; 15 | for (let i = 0; i < len; i++) { 16 | const rowData = data[i]; 17 | 18 | this.rows.push( 19 | new Row(this.grid, i, 0, everyOffsetY, CELL_HEIGHT, rowData) 20 | ); 21 | everyOffsetY += CELL_HEIGHT; 22 | } 23 | 24 | this.height = this.rows.reduce((sum, item) => { 25 | return sum + item.height; 26 | }, this.grid.tableHeaderHeight); 27 | data.length > 0 && this.grid.onLoad() 28 | } 29 | /** 30 | * CTRL+V 粘贴 31 | * @param {Array} data 粘贴板中的数据 32 | */ 33 | pasteData(data) { 34 | const { xIndex, yIndex } = this.grid.editor; 35 | const xArr = [xIndex, xIndex + data[0].length - 1] 36 | const yArr = [yIndex, yIndex + data.length - 1] 37 | const { value: oldData } = this.getSelectedData({ xArr, yArr }) 38 | const after = { 39 | value: data, 40 | colIndex: xIndex, 41 | rowIndex: yIndex 42 | } 43 | // 推入历史堆栈 44 | this.grid.history.pushState({ 45 | before: { 46 | value: oldData, 47 | colIndex: xIndex, 48 | rowIndex: yIndex 49 | }, 50 | after, 51 | type: 'multiple' 52 | }) 53 | // 写入数据 54 | // 表格内部复制粘贴以对象{value: '', label: ''}为单位 55 | if (this.grid.clipboard.show) { 56 | const rowsData = this.getRangeData(this.grid.clipboard); 57 | this.batchSetData({ 58 | value: rowsData, 59 | colIndex: xIndex, 60 | rowIndex: yIndex 61 | }) 62 | } else { 63 | this.batchSetData(after) 64 | } 65 | 66 | // 粘贴后事件派发 67 | const startY = yIndex 68 | const endY = startY + data.length - 1 69 | let rowDatas = [] 70 | for (let i = startY; i <= endY; i++) { 71 | rowDatas.push(this.getRowData(i)) 72 | } 73 | this.grid.afterPaste(rowDatas) 74 | } 75 | batchSetData({ colIndex, rowIndex, value }) { 76 | for (let ri = 0; ri <= value.length - 1; ri++) { 77 | const len = value[ri].length; 78 | for (let ci = 0; ci <= len - 1; ci++) { 79 | const cells = this.rows[ri + rowIndex].allCells; 80 | const cell = cells[ci + colIndex]; 81 | cell.setData(value[ri][ci]); 82 | } 83 | } 84 | } 85 | /** 86 | * autofull自动填充 87 | */ 88 | autofillData() { 89 | const value = this.getRangeData(); 90 | const xStep = value[0].length; 91 | const yStep = value.length; 92 | const { xArr, yArr } = this.grid.autofill; 93 | const { yArr: syArr } = this.grid.selector 94 | 95 | if (yArr[1] < 0 || xArr[1] < 0) return; 96 | 97 | for (let ri = 0; ri <= yArr[1] - yArr[0]; ri++) { 98 | for (let ci = 0; ci <= xArr[1] - xArr[0]; ci++) { 99 | const colIndex = ci + xArr[0]; 100 | const rowIndex = ri + yArr[0]; 101 | const val = value[ri % yStep][ci % xStep]; 102 | const cell = this.rows[rowIndex].allCells[colIndex]; 103 | cell.setData(val); 104 | } 105 | } 106 | 107 | // 自动填充后事件派发 108 | let startY = syArr[0] 109 | let endY = syArr[1] 110 | let rowDatas = [] 111 | if (yArr[0] !== syArr[0]) { // 纵方向向上 112 | startY = yArr[0] 113 | } else { 114 | startY = syArr[1] + 1 115 | endY = yArr[1] 116 | } 117 | for (let i = startY; i <= endY; i++) { 118 | rowDatas.push(this.getRowData(i)) 119 | } 120 | this.grid.afterAutofill(rowDatas) 121 | 122 | this.grid.clearAuaofill(); 123 | } 124 | resizeColumn(colIndex, width) { 125 | for (let i = 0; i < this.rows.length; i++) { 126 | this.rows[i].resizeColumn(colIndex, width); 127 | } 128 | } 129 | resizeAllColumn(width) { 130 | for (let row of this.rows) { 131 | let everyOffsetX = this.grid.originFixedWidth; 132 | for (let i = 0; i < row.allCells.length; i++) { 133 | const cell = row.allCells[i]; 134 | cell.width += width; 135 | cell.x = everyOffsetX; 136 | everyOffsetX += cell.width; 137 | } 138 | } 139 | } 140 | resizeRow(rowIndex, height) { 141 | if (height < MIN_CELL_HEIGHT) return; 142 | 143 | for (let i = 0; i < this.rows.length; i++) { 144 | this.rows[i].resizeRow(rowIndex, height); 145 | } 146 | } 147 | // 表头勾选发生改变,body勾选需要改变 148 | handleCheckRow(y) { 149 | if (typeof y === "number") { 150 | this.rows[y].handleCheck(); 151 | } else { 152 | const isChecked = this.grid.header.checked 153 | for (let row of this.rows) { 154 | row.handleCheck(isChecked); 155 | } 156 | } 157 | } 158 | // body勾选发生改变,表头勾选需要改变 159 | handleCheckHeader() { 160 | const totalChecked = this.rows.reduce((sum, item) => { 161 | const num = +item.checked 162 | return sum + num 163 | }, 0) 164 | const checked = !!totalChecked 165 | const indeterminate = totalChecked && totalChecked < this.grid.data.length 166 | this.grid.header.handleCheck({ checked, indeterminate }) 167 | } 168 | mouseMove(x, y) { 169 | for (let i = 0; i < this.rows.length; i++) { 170 | if (this.rows[i].isInVerticalAutofill(x, y)) { 171 | this.rows[i].handleAutofill(x, y); 172 | } else if (this.rows[i].isInsideVerticaTableBoundary(x, y)) { 173 | this.rows[i].mouseMove(x, y); 174 | } 175 | } 176 | } 177 | mouseDown(x, y) { 178 | for (let i = 0; i < this.rows.length; i++) { 179 | if (this.rows[i].isInVerticalAutofill(x, y)) { 180 | this.rows[i].handleStartAutofill(x, y); 181 | } else if (this.rows[i].isInsideVerticaBodyBoundary(x, y)) { 182 | this.rows[i].mouseDown(x, y); 183 | } 184 | } 185 | } 186 | click(x, y) { 187 | for (let i = 0; i < this.rows.length; i++) { 188 | if (this.rows[i].isInsideVerticaBodyBoundary(x, y)) { 189 | this.rows[i].click(x, y); 190 | } 191 | } 192 | } 193 | dbClick(x, y) { 194 | for (let i = 0; i < this.rows.length; i++) { 195 | if (this.rows[i].isInsideVerticaBodyBoundary(x, y)) { 196 | this.rows[i].dbClick(x, y); 197 | } 198 | } 199 | } 200 | rePaintRow(rowIndex) { 201 | // 计算该行中所有单元格内容所需要的最大高度 202 | // const rowData = this.data[rowIndex] 203 | const row = this.getRow(rowIndex); 204 | const len = row.cells.length; 205 | let textWrap = null; 206 | let rowHeight = CELL_HEIGHT; 207 | for (let i = 0; i < len; i++) { 208 | const { value, width } = row.cells[i]; 209 | if (value || value === 0) { 210 | textWrap = this.grid.painter.getTextWrapping(value, width); 211 | let textWrapCount = 0; 212 | if (textWrap) { 213 | textWrapCount = textWrap.length; 214 | } 215 | if (textWrapCount > 1) { 216 | if (CELL_HEIGHT + (textWrapCount - 1) * 18 > rowHeight) { 217 | rowHeight = CELL_HEIGHT + (textWrapCount - 1) * 18; 218 | } 219 | } 220 | } 221 | } 222 | 223 | row.height = rowHeight; 224 | 225 | let everyOffsetY = this.grid.tableHeaderHeight; 226 | for (let j = 0; j < this.rows.length; j++) { 227 | const row = this.rows[j]; 228 | row.y = everyOffsetY; 229 | everyOffsetY += row.height; 230 | row.rePaint(); 231 | } 232 | } 233 | draw() { 234 | const len = this.data.length; 235 | for (let i = 0; i < len; i++) { 236 | const row = this.rows[i]; 237 | 238 | if (row.isVerticalVisibleOnBody()) { 239 | row.draw(); 240 | } 241 | } 242 | } 243 | // 根据坐标获取cell对象 244 | getCell(x, y) { 245 | const row = this.rows[y]; 246 | return row.allCells[x]; 247 | } 248 | getRow(y) { 249 | return this.rows[y]; 250 | } 251 | getRangeData({ xArr, yArr } = this.grid.selector) { 252 | const rowsData = []; 253 | for (let ri = 0; ri <= yArr[1] - yArr[0]; ri++) { 254 | const cellsData = []; 255 | for (let ci = 0; ci <= xArr[1] - xArr[0]; ci++) { 256 | const cell = this.rows[ri + yArr[0]].allCells[ci + xArr[0]] 257 | cellsData.push({ 258 | label: cell.label, 259 | value: cell.value 260 | }); 261 | } 262 | rowsData.push(cellsData); 263 | } 264 | return rowsData 265 | } 266 | getSelectedData({ xArr, yArr } = this.grid.selector) { 267 | const rowsData = []; 268 | let text = ""; 269 | for (let ri = 0; ri <= yArr[1] - yArr[0]; ri++) { 270 | const cellsData = []; 271 | for (let ci = 0; ci <= xArr[1] - xArr[0]; ci++) { 272 | cellsData.push(this.rows[ri + yArr[0]].allCells[ci + xArr[0]].label); 273 | } 274 | text += cellsData.join("\t") + "\r"; 275 | rowsData.push(cellsData); 276 | } 277 | text = text ? text.replace(/\r$/, "") : " "; // 去掉最后一个\n,否则会导致复制到excel里多一行空白 278 | if (!text) { 279 | text = " "; // 替换为' ',是为了防止复制空的内容导致document.execCommand命令无效 280 | } 281 | return { 282 | text, 283 | value: rowsData 284 | }; 285 | } 286 | clearSelectedData() { 287 | const { xArr, yArr } = this.grid.selector; 288 | for (let ri = 0; ri <= yArr[1] - yArr[0]; ri++) { 289 | for (let ci = 0; ci <= xArr[1] - xArr[0]; ci++) { 290 | const cell = this.rows[ri + yArr[0]].allCells[ci + xArr[0]] 291 | cell.setData("") 292 | } 293 | } 294 | let rowDatas = [] 295 | for (let i = yArr[0]; i <= yArr[1]; i++) { 296 | rowDatas.push(this.getRowData(i)) 297 | } 298 | this.grid.afterClear(rowDatas) 299 | } 300 | getData() { 301 | return this.rows.map(row => { 302 | const cells = row.allCells; 303 | let _o = {}; 304 | cells.forEach(cell => { 305 | _o[cell.key] = cell.value; 306 | if (cell.labelKey) { 307 | _o[cell.labelKey] = cell.label; 308 | } 309 | }); 310 | _o = Object.assign({}, row.data, _o) 311 | return _o; 312 | }); 313 | } 314 | getCheckedRows() { 315 | return this.rows 316 | .filter(item => item.checked) 317 | .map(row => { 318 | const cells = row.allCells; 319 | let _o = {}; 320 | cells.forEach(cell => { 321 | _o[cell.key] = cell.value; 322 | if (cell.labelKey) { 323 | _o[cell.labelKey] = cell.label; 324 | } 325 | }); 326 | _o = Object.assign({}, row.data, _o) 327 | return _o; 328 | }); 329 | } 330 | getChangedRows() { 331 | let arr = new Set(); 332 | let rows = []; 333 | Object.keys(this.grid.hashChange).forEach(key => { 334 | arr.add(Number(key.split("-")[1])); 335 | }); 336 | Array.from(arr).sort().forEach(item => { 337 | rows.push(this.rows[item]); 338 | }); 339 | return rows.map(row => { 340 | const cells = row.allCells; 341 | let _o = {}; 342 | cells.forEach(cell => { 343 | _o[cell.key] = cell.value; 344 | if (cell.labelKey) { 345 | _o[cell.labelKey] = cell.label; 346 | } 347 | }); 348 | _o = Object.assign({}, row.data, _o) 349 | return _o; 350 | }); 351 | } 352 | validate(callback) { 353 | this.rows.forEach(row => { 354 | const cells = row.allCells; 355 | cells.forEach(cell => { 356 | const rowData = this.getRowData(cell.rowIndex) 357 | cell.validate(rowData) 358 | }); 359 | }) 360 | setTimeout(() => { 361 | const errors = this.getValidations() 362 | typeof callback === 'function' && callback(!errors.length) 363 | }, 0) 364 | } 365 | validateChanged(callback) { 366 | let arr = new Set(); 367 | let rows = []; 368 | Object.keys(this.grid.hashChange).forEach(key => { 369 | arr.add(Number(key.split("-")[1])); 370 | }); 371 | Array.from(arr).sort().forEach(item => { 372 | rows.push(this.rows[item]); 373 | }); 374 | rows.map(row => { 375 | const cells = row.allCells; 376 | cells.forEach(cell => { 377 | const rowData = this.getRowData(cell.rowIndex) 378 | cell.validate(rowData) 379 | }); 380 | }); 381 | setTimeout(() => { 382 | const errors = this.getValidations() 383 | typeof callback === 'function' && callback(!errors.length) 384 | }, 0) 385 | } 386 | validateField(ci, ri) { 387 | if (typeof ri === 'number' && typeof ci === 'number') { // 校验指定某个数据单元格 388 | const cell = this.getCell(ci, ri) 389 | if (cell) { 390 | const rowData = this.getRowData(cell.rowIndex) 391 | cell.validate(rowData) 392 | } 393 | } 394 | } 395 | getValidations() { 396 | const validFaildRows = [] 397 | this.rows.forEach(row => { 398 | const validFaildCells = [] 399 | const cells = row.allCells; 400 | cells.forEach(cell => { 401 | !cell.valid && validFaildCells.push({ 402 | title: cell.title, 403 | key: cell.key, 404 | value: cell.value, 405 | message: cell.message 406 | }) 407 | }); 408 | validFaildCells.length && validFaildRows.push(validFaildCells) 409 | }) 410 | return validFaildRows 411 | } 412 | setValidations(errors) { 413 | if (errors && Array.isArray(errors)) { 414 | errors.forEach(item => { 415 | this.rows.forEach(row => { 416 | if (row.data[this.grid.rowKey] === item[this.grid.rowKey]) { 417 | const cells = row.allCells; 418 | cells.forEach(cell => { 419 | const valid = !item[cell.key] 420 | if (item.hasOwnProperty(cell.key)) { 421 | cell.resetValidate(valid, item[cell.key]) 422 | } 423 | }); 424 | } 425 | }); 426 | }) 427 | } 428 | } 429 | clearValidations() { 430 | this.rows.forEach(row => { 431 | const cells = row.allCells; 432 | cells.forEach(cell => { 433 | cell.resetValidate() 434 | }); 435 | }) 436 | } 437 | getRowData(y) { 438 | const row = this.getRow(y); 439 | let _o = {}; 440 | row.allCells.forEach(cell => { 441 | _o[cell.key] = cell.value; 442 | if (cell.labelKey) { 443 | _o[cell.labelKey] = cell.label; 444 | } 445 | }); 446 | return Object.assign({}, row.data, _o) 447 | } 448 | getCellData(x, y) { 449 | const cell = this.getCell(x, y) 450 | return { 451 | title: cell.title, 452 | key: cell.key, 453 | value: cell.value 454 | } 455 | } 456 | // 批量更新表格数据 457 | updateData(data) { 458 | if (data && Array.isArray(data)) { 459 | data.forEach(item => { 460 | this.rows.forEach(row => { 461 | if (row.data[this.grid.rowKey] === item[this.grid.rowKey]) { 462 | const cells = row.allCells; 463 | cells.forEach(cell => { 464 | if (item.hasOwnProperty(cell.key)) { 465 | cell.setData(item[cell.key], true) 466 | } 467 | }); 468 | } 469 | }); 470 | }) 471 | } 472 | } 473 | updateCellData(colIndex) {} 474 | } 475 | 476 | export default Body; 477 | -------------------------------------------------------------------------------- /src/core/Cell.js: -------------------------------------------------------------------------------- 1 | import Context from "./Context.js"; 2 | import Validator from "./Validator.js"; 3 | import { 4 | SELECT_BORDER_COLOR, 5 | SELECT_AREA_COLOR, 6 | READONLY_COLOR, 7 | READONLY_TEXT_COLOR, 8 | ERROR_TIP_COLOR 9 | } from "./constants.js"; 10 | const dateIcon = new Image(); 11 | const timeIcon = new Image(); 12 | dateIcon.src = require("./images/date.png"); 13 | timeIcon.src = require("./images/time.png"); 14 | 15 | class Cell extends Context { 16 | constructor( 17 | value, 18 | label, 19 | grid, 20 | colIndex, 21 | rowIndex, 22 | x, 23 | y, 24 | width, 25 | height, 26 | column, 27 | rowData, 28 | options 29 | ) { 30 | super(grid, x, y, width, height, column.fixed); 31 | this.colIndex = colIndex; 32 | this.rowIndex = rowIndex; 33 | this.rowData = rowData 34 | 35 | this.title = column.title; 36 | this.key = column.key; 37 | this.labelKey = column.label 38 | this.fixed = column.fixed; 39 | this.readonly = column.readonly; 40 | this.textAlign = column.align || "left"; 41 | this.textBaseline = column.baseline || "middle"; 42 | this.dataType = column.type || "text"; 43 | this.options = column.options; 44 | this.render = column.render; 45 | 46 | this.value = value === null || value === undefined ? "" : value; 47 | this.label = label 48 | this.originalValue = value; 49 | 50 | this.validator = new Validator(column); 51 | this.valid = true; 52 | this.message = null; 53 | 54 | Object.assign(this, options); 55 | this.setLabel(label === null || label === undefined ? this.value : label); 56 | if (column.rule && column.rule.immediate === false) return; // 编辑器初始化不需要校验 57 | this.validate(); 58 | } 59 | // 鼠标横坐标是否位于【焦点单元格】所在的autofill触点范围内 60 | isInHorizontalAutofill(mouseX, mouseY) { 61 | return ( 62 | mouseX > this.x + this.grid.scrollX + this.width - 4 && 63 | mouseX < this.x + this.grid.scrollX + this.width + 4 && 64 | mouseX > this.grid.fixedLeftWidth && 65 | mouseX < this.grid.width - this.grid.fixedRightWidth + 4 // 兼容最右侧autofill触点由于是渲染在scroller上会导致无效 66 | ); 67 | } 68 | // 鼠标横坐标是否位于处于【冻结列中焦点单元格】所在的autofill触点范围内 69 | isInsideFixedHorizontalAutofill(mouseX, mouseY) { 70 | const x = 71 | this.grid.width - 72 | (this.grid.tableWidth - this.x - this.width) - 73 | this.width - 74 | this.grid.verticalScrollerSize; 75 | return ( 76 | (mouseX >= x + this.width - 4 && 77 | mouseX < x + this.width + 4 && 78 | this.fixed === "right") || 79 | (mouseX > this.x + this.width - 4 && 80 | mouseX < this.x + this.width + 4 && 81 | this.fixed === "left") 82 | ); 83 | } 84 | async validate(data) { 85 | const { flag, message } = await this.validator.validate(this.value, data || this.rowData); 86 | this.valid = flag; 87 | this.message = message; 88 | } 89 | resetValidate(flag = true, message = null) { 90 | this.valid = flag; 91 | this.message = message; 92 | } 93 | handleNumber(val) { 94 | if (val === 0 || (val && !isNaN(Number(val)))) { 95 | return Number(val) 96 | } else { 97 | return val || null // number类型的空字符串处理为null 98 | } 99 | } 100 | /** 101 | * @param {String|Number} val 需要设置的值 102 | * @param {Boolean} ignore 是否忽略readonly属性可以修改 103 | */ 104 | setData(data, ignore) { 105 | if (!ignore && this.readonly) return; 106 | let v = data 107 | if (typeof v === 'string') { 108 | v = data.trim() 109 | } 110 | if (this.dataType === 'number') { 111 | v = this.handleNumber(v) 112 | } 113 | if (Object.prototype.toString.call(data) === '[object Object]') { 114 | this.value = data.value 115 | this.setLabel(data.label) 116 | } else { 117 | if (this.grid.clipboard.isPaste || this.grid.autofill.enable) { 118 | this.value = this.getMapValue(v) 119 | } else { 120 | this.value = v; 121 | } 122 | this.setLabel(v); 123 | } 124 | 125 | const rowData = this.grid.body.getRowData(this.rowIndex) 126 | this.validate(rowData) 127 | 128 | // changed diff 129 | if (this.value !== this.originalValue) { 130 | this.grid.hashChange[`${this.colIndex}-${this.rowIndex}`] = true; 131 | } else { 132 | delete this.grid.hashChange[`${this.colIndex}-${this.rowIndex}`]; 133 | } 134 | } 135 | setLabel(val) { 136 | let label; 137 | if (typeof this.render === "function") { 138 | label = this.render(val); 139 | } else { 140 | label = this.getMapLabel(val); 141 | } 142 | this.label = label === null || label === undefined ? "" : label; 143 | } 144 | // 对于下拉类型的数据,对外展示的是label,实际存的是value,所以在更新这类数据的时候需要做一个转换 145 | getMapValue(label) { // label => value 146 | let value = label; 147 | if (this.dataType === "select" && Array.isArray(this.options)) { 148 | for (let item of this.options) { 149 | if (label === item.label) { 150 | value = item.value; 151 | break; 152 | } 153 | } 154 | } 155 | return value; 156 | } 157 | getMapLabel(value) { // value => label 158 | let label = value; 159 | if (this.dataType === "select" && Array.isArray(this.options)) { 160 | for (let item of this.options) { 161 | if (value === item.value) { 162 | label = item.label; 163 | break; 164 | } 165 | } 166 | } 167 | return label; 168 | } 169 | draw() { 170 | const { 171 | painter, 172 | editor, 173 | selector, 174 | autofill, 175 | clipboard, 176 | width, 177 | tableWidth, 178 | verticalScrollerSize, 179 | scrollX, 180 | scrollY, 181 | range, 182 | fillColor 183 | } = this.grid; 184 | const x = 185 | this.fixed === "right" 186 | ? width - 187 | (tableWidth - this.x - this.width) - 188 | this.width - 189 | verticalScrollerSize 190 | : this.fixed === "left" 191 | ? this.x 192 | : this.x + scrollX; 193 | const y = this.y + scrollY; 194 | const fillLineSty = { 195 | borderColor: SELECT_BORDER_COLOR, 196 | borderWidth: 1, 197 | lineDash: [4, 4] 198 | } 199 | 200 | /** 201 | * 绘制单元格边框 202 | */ 203 | painter.ctx.save() 204 | painter.drawRect(x, y, this.width, this.height, { 205 | fillColor: this.readonly ? READONLY_COLOR : fillColor, 206 | borderColor: this.borderColor, 207 | borderWidth: 1 208 | }); 209 | painter.ctx.clip() 210 | 211 | /** 212 | * 绘制单元格内容 213 | */ 214 | // const iconEl = this.dataType === 'datetime' ? timeIcon : (['month', 'date'].includes(this.dataType) ? dateIcon : null) 215 | painter.drawCellText(this.label, x, y, this.width, this.height, 10, { 216 | color: this.readonly ? READONLY_TEXT_COLOR : this.color, 217 | align: this.textAlign, 218 | baseLine: this.textBaseline, 219 | // icon: iconEl, 220 | iconOffsetX: 12, 221 | iconOffsetY: 1, 222 | iconWidth: 12, 223 | iconHeight: 12 224 | }); 225 | if (this.dataType === 'select') { 226 | painter.drawCellAffixIcon('arrow', x, y, this.width, this.height-2, { 227 | color: '#bbbec4', 228 | fillColor: this.readonly ? READONLY_COLOR : fillColor 229 | }) 230 | } 231 | 232 | /** 233 | * 绘制错误提示 234 | */ 235 | if (!this.valid) { 236 | const points = [ 237 | [x + this.width - 8, y], 238 | [x + this.width, y], 239 | [x + this.width, y + 8] 240 | ]; 241 | painter.drawLine(points, { 242 | fillColor: ERROR_TIP_COLOR 243 | }); 244 | } 245 | 246 | /** 247 | * 绘制选区 248 | */ 249 | if (selector.show) { 250 | const minX = selector.xArr[0]; 251 | const maxX = selector.xArr[1]; 252 | const minY = selector.yArr[0]; 253 | const maxY = selector.yArr[1]; 254 | 255 | // background color 256 | if ( 257 | this.colIndex >= minX && 258 | this.colIndex <= maxX && 259 | this.rowIndex >= minY && 260 | this.rowIndex <= maxY 261 | ) { 262 | painter.drawRect(x, y, this.width, this.height, { 263 | fillColor: SELECT_AREA_COLOR 264 | }); 265 | } 266 | // top/bottom border 267 | if (this.colIndex >= minX && 268 | this.colIndex <= maxX && 269 | this.rowIndex + 1 === minY) { 270 | const points = [ 271 | [x, y + this.height - 1], 272 | [x + this.width, y + this.height - 1] 273 | ]; 274 | painter.drawLine(points, { 275 | borderColor: SELECT_BORDER_COLOR, 276 | borderWidth: 1 277 | }); 278 | } 279 | if (this.colIndex >= minX && 280 | this.colIndex <= maxX && 281 | this.rowIndex === maxY) { 282 | if (this.rowIndex === range.maxY) { 283 | const points = [ 284 | [x, y + this.height], 285 | [x + this.width, y + this.height] 286 | ]; 287 | painter.drawLine(points, { 288 | borderColor: SELECT_BORDER_COLOR, 289 | borderWidth: 2 290 | }); 291 | } else { 292 | const points = [ 293 | [x, y + this.height - 1], 294 | [x + this.width, y + this.height - 1] 295 | ]; 296 | painter.drawLine(points, { 297 | borderColor: SELECT_BORDER_COLOR, 298 | borderWidth: 1 299 | }); 300 | } 301 | } 302 | if ( 303 | (this.colIndex >= minX && 304 | this.colIndex <= maxX && 305 | this.rowIndex === minY) || 306 | (this.colIndex >= minX && 307 | this.colIndex <= maxX && 308 | this.rowIndex - 1 === maxY) 309 | ) { 310 | const points = [ 311 | [x, y], 312 | [x + this.width, y] 313 | ]; 314 | painter.drawLine(points, { 315 | borderColor: SELECT_BORDER_COLOR, 316 | borderWidth: 1 317 | }); 318 | } 319 | // left/right border 320 | if ( 321 | (this.colIndex === minX && 322 | this.rowIndex >= minY && 323 | this.rowIndex <= maxY) || 324 | (this.colIndex - 1 === maxX && 325 | this.rowIndex >= minY && 326 | this.rowIndex <= maxY) 327 | ) { 328 | const points = [ 329 | [x, y], 330 | [x, y + this.height] 331 | ]; 332 | painter.drawLine(points, { 333 | borderColor: SELECT_BORDER_COLOR, 334 | borderWidth: 2 335 | }); 336 | } 337 | if ( 338 | (this.colIndex + 1 === minX && 339 | this.rowIndex >= minY && 340 | this.rowIndex <= maxY) || 341 | (this.colIndex === maxX && 342 | this.rowIndex >= minY && 343 | this.rowIndex <= maxY) 344 | ) { 345 | const points = [ 346 | [x + this.width, y], 347 | [x + this.width, y + this.height] 348 | ]; 349 | painter.drawLine(points, { 350 | borderColor: SELECT_BORDER_COLOR, 351 | borderWidth: 2 352 | }); 353 | } 354 | // autofill 355 | // autofill触点 356 | if (!editor.show) { 357 | const autofill_width = 6; 358 | const autofillSty = { 359 | borderColor: "#fff", 360 | borderWidth: 2, 361 | fillColor: SELECT_BORDER_COLOR 362 | } 363 | // left-top 364 | if ( 365 | this.colIndex === autofill.xIndex && 366 | this.rowIndex === autofill.yIndex 367 | ) { 368 | // -3让触点覆盖于边框之上 369 | painter.drawRect( 370 | x + this.width - 3, 371 | y + this.height - 3, 372 | autofill_width, 373 | autofill_width, 374 | autofillSty 375 | ); 376 | } 377 | // right-top 378 | if ( 379 | this.colIndex === autofill.xIndex && 380 | this.rowIndex - 1 === autofill.yIndex 381 | ) { 382 | painter.drawRect( 383 | x + this.width - 3, 384 | y - 3, 385 | autofill_width, 386 | autofill_width, 387 | autofillSty 388 | ); 389 | } 390 | // left-bottom 391 | if ( 392 | this.colIndex - 1 === autofill.xIndex && 393 | this.rowIndex === autofill.yIndex 394 | ) { 395 | painter.drawRect( 396 | x - 3, 397 | y + this.height - 3, 398 | autofill_width, 399 | autofill_width, 400 | autofillSty 401 | ); 402 | } 403 | // right-bottom 404 | if ( 405 | this.colIndex - 1 === autofill.xIndex && 406 | this.rowIndex - 1 === autofill.yIndex && 407 | this.colIndex !== this.grid.fixedLeft 408 | ) { 409 | painter.drawRect( 410 | x - 3, 411 | y - 3, 412 | autofill_width, 413 | autofill_width, 414 | autofillSty 415 | ); 416 | } 417 | } 418 | // autofill选区 419 | if (autofill.enable) { 420 | const minX = autofill.xArr[0]; 421 | const maxX = autofill.xArr[1]; 422 | const minY = autofill.yArr[0]; 423 | const maxY = autofill.yArr[1]; 424 | 425 | // top/bottom border 426 | if ( 427 | this.colIndex >= minX && 428 | this.colIndex <= maxX && 429 | this.rowIndex === minY 430 | ) { 431 | const points = [ 432 | [x, y + 1], 433 | [x + this.width, y + 1] 434 | ]; 435 | painter.drawLine(points, fillLineSty); 436 | } 437 | if ( 438 | this.colIndex >= minX && 439 | this.colIndex <= maxX && 440 | this.rowIndex === maxY 441 | ) { 442 | const points = [ 443 | [x, y + this.height - 1], 444 | [x + this.width, y + this.height - 1] 445 | ]; 446 | painter.drawLine(points, fillLineSty); 447 | } 448 | // left/right border 449 | if ( 450 | this.colIndex === minX && 451 | this.rowIndex >= minY && 452 | this.rowIndex <= maxY 453 | ) { 454 | const points = [ 455 | [x + 1, y], 456 | [x + 1, y + this.height] 457 | ]; 458 | painter.drawLine(points, fillLineSty); 459 | } 460 | if ( 461 | this.colIndex === maxX && 462 | this.rowIndex >= minY && 463 | this.rowIndex <= maxY 464 | ) { 465 | const points = [ 466 | [x + this.width - 1, y], 467 | [x + this.width - 1, y + this.height] 468 | ]; 469 | painter.drawLine(points, fillLineSty); 470 | } 471 | } 472 | } 473 | // copy line 474 | if (clipboard.show) { 475 | const minX = clipboard.xArr[0]; 476 | const maxX = clipboard.xArr[1]; 477 | const minY = clipboard.yArr[0]; 478 | const maxY = clipboard.yArr[1]; 479 | // top/bottom border 480 | if ( 481 | this.colIndex >= minX && 482 | this.colIndex <= maxX && 483 | this.rowIndex === minY 484 | ) { 485 | const points = [ 486 | [x, y + 1], 487 | [x + this.width, y + 1] 488 | ]; 489 | painter.drawLine(points, fillLineSty); 490 | } 491 | if ( 492 | this.colIndex >= minX && 493 | this.colIndex <= maxX && 494 | this.rowIndex === maxY 495 | ) { 496 | const points = [ 497 | [x, y + this.height - 2], 498 | [x + this.width, y + this.height - 2] 499 | ]; 500 | painter.drawLine(points, fillLineSty); 501 | } 502 | // left/right border 503 | if ( 504 | this.colIndex === minX && 505 | this.rowIndex >= minY && 506 | this.rowIndex <= maxY 507 | ) { 508 | const points = [ 509 | [x + 1, y], 510 | [x + 1, y + this.height] 511 | ]; 512 | painter.drawLine(points, fillLineSty); 513 | } 514 | if ( 515 | this.colIndex === maxX && 516 | this.rowIndex >= minY && 517 | this.rowIndex <= maxY 518 | ) { 519 | const points = [ 520 | [x + this.width - 1, y], 521 | [x + this.width - 1, y + this.height] 522 | ]; 523 | painter.drawLine(points, fillLineSty); 524 | } 525 | } 526 | } 527 | } 528 | 529 | export default Cell; 530 | -------------------------------------------------------------------------------- /src/core/Clipboard.js: -------------------------------------------------------------------------------- 1 | import { h } from "./element.js"; 2 | 3 | class Clipboard { 4 | constructor(grid) { 5 | this.grid = grid; 6 | this.show = false; 7 | this.isPaste = false 8 | this.xArr = [-1, -1]; 9 | this.yArr = [-1, -1]; 10 | // this.init(); 11 | } 12 | init() { 13 | const clipboardEl = h("textarea", "").on("paste", e => this.paste(e)); 14 | clipboardEl.css({ 15 | position: "absolute", 16 | left: "-10000px", 17 | top: "-10000px" 18 | }); 19 | this.el = clipboardEl.el; 20 | document.body.appendChild(this.el); 21 | } 22 | copy() { 23 | const { selector, body } = this.grid; 24 | const { text } = body.getSelectedData(); 25 | const textArea = document.createElement("textarea"); 26 | textArea.value = text; 27 | document.body.appendChild(textArea); 28 | textArea.select(); 29 | document.execCommand("copy", false); // copy到剪切板 30 | document.body.removeChild(textArea); 31 | this.show = true; 32 | this.xArr = selector.xArr.slice(); 33 | this.yArr = selector.yArr.slice(); 34 | } 35 | paste() { 36 | this.isPaste = true 37 | } 38 | // paste2(e) { 39 | // const { editor, selector, autofill, body } = this.grid; 40 | // let textArr; 41 | // let rawText = e.clipboardData.getData("text/plain"); 42 | // // let arr = isMac ? rawText.split('\r').map(item => item.split('\t')) : rawText.split('\r').map(item => item.split('\t')).slice(0, -1) // windows系统截取掉最后一个空白字符 43 | // let arr = rawText.split("\r"); 44 | // if (arr.length === 1) { 45 | // let _arr = arr[0].split("\n"); 46 | // textArr = _arr.map(item => item.split("\t")); 47 | // } else { 48 | // textArr = arr.map(item => item.split("\t")); 49 | // } 50 | // if (textArr.length) { 51 | // body.pasteData(textArr); 52 | // } 53 | // } 54 | select(textArr) { 55 | this.isPaste = false 56 | // 复制完把被填充的区域选中,并把激活单元格定位到填充区域的第一个 57 | const { editor, selector, autofill } = this.grid; 58 | selector.xArr.splice(1, 1, editor.xIndex + textArr[0].length - 1); 59 | selector.yArr.splice(1, 1, editor.yIndex + textArr.length - 1); 60 | autofill.xIndex = selector.xArr[1]; 61 | autofill.yIndex = selector.yArr[1]; 62 | this.clear(); 63 | } 64 | clear() { 65 | this.show = false; 66 | this.xArr = [-1, -1]; 67 | this.yArr = [-1, -1]; 68 | } 69 | } 70 | 71 | export default Clipboard; 72 | -------------------------------------------------------------------------------- /src/core/ColumnHeader.js: -------------------------------------------------------------------------------- 1 | import Context from "./Context.js"; 2 | import { 3 | SELECT_BORDER_COLOR, 4 | SELECT_BG_COLOR, 5 | ERROR_TIP_COLOR 6 | } from "./constants.js"; 7 | 8 | class ColumnHeader extends Context { 9 | constructor(grid, index, x, y, width, height, column) { 10 | super(grid, x, y, width, height); 11 | 12 | this.fixed = column.fixed; 13 | this.level = column.level 14 | this.text = column.title; 15 | this.colspan = column.colspan 16 | this.rowspan = column.rowspan 17 | this.required = column.rule ? column.rule.required : null 18 | this.index = index; 19 | } 20 | // 表头是否超过了右侧可视区的边界 21 | isVisibleOnScreen() { 22 | return !( 23 | this.x + this.width - this.grid.fixedLeftWidth + this.grid.scrollX <= 0 || 24 | this.x + this.grid.scrollX >= this.grid.width - this.grid.fixedRightWidth 25 | ); 26 | } 27 | draw() { 28 | // 绘制表头每个单元格框 29 | const { 30 | width, 31 | tableWidth, 32 | verticalScrollerSize, 33 | scrollX, 34 | editor, 35 | selector, 36 | painter, 37 | fillColor, 38 | borderColor, 39 | borderWidth 40 | } = this.grid 41 | const x = 42 | this.fixed === "right" 43 | ? width - 44 | (tableWidth - this.x - this.width) - 45 | this.width - 46 | verticalScrollerSize 47 | : this.fixed === "left" 48 | ? this.x 49 | : this.x + scrollX; 50 | painter.drawRect(x, this.y, this.width, this.height, { 51 | fillColor, 52 | borderColor, 53 | borderWidth 54 | }); 55 | 56 | /** 57 | * 焦点高亮,colspan>=2表示复合表头,不需要高亮 58 | */ 59 | if ((selector.show || editor.show ) && this.colspan <= 1) { 60 | const minX = selector.xArr[0]; 61 | const maxX = selector.xArr[1]; 62 | 63 | // 背景 64 | if (this.index >= minX && this.index <= maxX) { 65 | this.grid.painter.drawRect(x, this.y, this.width, this.height, { 66 | fillColor: SELECT_BG_COLOR 67 | }); 68 | } 69 | 70 | // 线 71 | if (this.index >= minX && this.index <= maxX) { 72 | const points = [ 73 | [x, this.y + this.height], 74 | [x + this.width, this.y + this.height] 75 | ]; 76 | this.grid.painter.drawLine(points, { 77 | borderColor: SELECT_BORDER_COLOR, 78 | borderWidth: 2 79 | }); 80 | } 81 | 82 | // 弥补相邻单元格线条覆盖的问题 83 | // if (this.index - 1 === maxX) { 84 | // const points = [ 85 | // [x - 1, this.y + this.height], 86 | // [x, this.y + this.height] 87 | // ]; 88 | // this.grid.painter.drawLine(points, { 89 | // borderColor: SELECT_BORDER_COLOR, 90 | // borderWidth: 2 91 | // }); 92 | // } 93 | } 94 | 95 | // required必填星号标识 96 | if (this.required) { 97 | this.grid.painter.drawIcon( 98 | '*', 99 | this.text, 100 | x, 101 | this.y + this.height / 2, 102 | this.width, 103 | 10, 104 | 6, 105 | 4, 106 | { 107 | font: 108 | 'normal 20px "Helvetica Neue", Helvetica, "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", Arial, sans-serif', 109 | color: ERROR_TIP_COLOR 110 | } 111 | ); 112 | } 113 | // 绘制表头每个单元格文本 114 | this.grid.painter.drawCellText( 115 | this.text, 116 | x, 117 | this.y, 118 | this.width, 119 | this.height, 120 | 10, 121 | { 122 | color: this.grid.color, 123 | align: 'center' 124 | } 125 | ); 126 | } 127 | } 128 | 129 | export default ColumnHeader; 130 | -------------------------------------------------------------------------------- /src/core/Context.js: -------------------------------------------------------------------------------- 1 | import { ROW_INDEX_WIDTH } from "./constants.js"; 2 | 3 | class Context { 4 | constructor(grid, x, y, width, height, fixed) { 5 | this.grid = grid; 6 | this.x = x; 7 | this.y = y; 8 | this.width = width; 9 | this.height = height; 10 | this.fixed = fixed; 11 | } 12 | isHorizontalVisibleOnBody() { 13 | return !( 14 | this.x + this.width - this.grid.fixedLeftWidth + this.grid.scrollX <= 0 || 15 | this.x + this.grid.scrollX >= this.grid.width - this.grid.fixedRightWidth 16 | ); 17 | } 18 | isVerticalVisibleOnBody() { 19 | return !( 20 | this.y + this.height - this.grid.tableHeaderHeight + this.grid.scrollY <= 0 || 21 | this.y + this.grid.scrollY >= 22 | this.grid.height - this.grid.horizontalScrollerSize 23 | ); 24 | } 25 | // 鼠标坐标是否在body内 26 | isInsideHorizontalBodyBoundary(mouseX, mouseY) { 27 | return ( 28 | mouseX > this.x + this.grid.scrollX && 29 | mouseX < this.x + this.grid.scrollX + this.width && 30 | mouseX > this.grid.fixedLeftWidth && // 避免冻结列点击穿透了 31 | mouseX < this.grid.width - this.grid.fixedRightWidth 32 | ); // 避免冻结列点击穿透了 33 | } 34 | isInsideHorizontalTableBoundary(mouseX, mouseY) { 35 | return ( 36 | mouseX > this.x + this.grid.scrollX && 37 | mouseX < this.x + this.grid.scrollX + this.width && 38 | mouseX > this.grid.fixedLeftWidth 39 | ); 40 | } 41 | isInsideFixedHorizontalBodyBoundary(mouseX, mouseY) { 42 | const x = 43 | this.grid.width - 44 | (this.grid.tableWidth - this.x - this.width) - 45 | this.width - 46 | this.grid.verticalScrollerSize; 47 | return ( 48 | (mouseX >= x && mouseX < x + this.width && this.fixed === "right") || 49 | (mouseX > this.x && mouseX < this.x + this.width && this.fixed === "left") 50 | ); 51 | } 52 | // 鼠标纵坐标在视窗body区域内(不包括横坐标滚动条) 53 | isInsideVerticaBodyBoundary(mouseX, mouseY) { 54 | return ( 55 | mouseY > this.y + this.grid.scrollY && 56 | mouseY < this.y + this.height + this.grid.scrollY && 57 | mouseY > this.grid.tableHeaderHeight && 58 | mouseY < this.grid.height - this.grid.horizontalScrollerSize 59 | ); 60 | } 61 | // 鼠标纵坐标在视窗body区域内 62 | isInsideVerticaTableBoundary(mouseX, mouseY) { 63 | return ( 64 | mouseY > this.y + this.grid.scrollY && 65 | mouseY < this.y + this.height + this.grid.scrollY && 66 | mouseY > this.grid.tableHeaderHeight 67 | ); 68 | } 69 | // 鼠标坐标是否在单元格后缀图标区域内 70 | isInsideAffixIcon(mouseX, mouseY) { 71 | return ( 72 | mouseX > this.x + this.width + this.grid.scrollX - 25 && 73 | mouseX < this.x + this.width + this.grid.scrollX && 74 | mouseY > this.y + this.grid.scrollY + 10 && 75 | mouseY < this.y + this.height + this.grid.scrollY - 10 76 | ) 77 | } 78 | // 鼠标坐标是否在表头范围内 79 | isInsideHeader(mouseX, mouseY) { 80 | return mouseY > this.y && mouseY < this.y + this.grid.tableHeaderHeight; 81 | } 82 | // 鼠标坐标是否在checkbox部分内 83 | isInsideCheckboxBoundary(mouseX, mouseY) { 84 | return ( 85 | mouseX > this.x + ROW_INDEX_WIDTH && 86 | mouseX < this.x + this.grid.originFixedWidth 87 | ); 88 | } 89 | // 鼠标坐标位于勾选框区域内 90 | isInsideHeaderCheckboxBoundary(mouseX, mouseY) { 91 | return ( 92 | this.isInsideHeader(mouseX, mouseY) && 93 | this.isInsideCheckboxBoundary(mouseX, mouseY) 94 | ); 95 | } 96 | // 鼠标坐标位于索引框区域内 97 | isInsideIndexBoundary(mouseX, mouseY) { 98 | return ( 99 | mouseX > this.x && 100 | mouseX < this.x + ROW_INDEX_WIDTH 101 | ); 102 | } 103 | } 104 | 105 | export default Context; 106 | -------------------------------------------------------------------------------- /src/core/DataGrid.js: -------------------------------------------------------------------------------- 1 | /** 2 | * 程序入口 3 | */ 4 | import { h } from "./element.js"; 5 | import Paint from "./Paint.js"; 6 | import Body from "./Body.js"; 7 | import Header from "./Header.js"; 8 | import Editor from "./Editor.js"; 9 | import Scroller from "./Scroller.js"; 10 | import Events from "./Events.js"; 11 | import Tooltip from "./Tooltip.js"; 12 | import Clipboard from "./Clipboard.js"; 13 | import Histories from "./History.js"; 14 | import { dpr } from "./config.js"; 15 | import { 16 | toLeaf, 17 | getMaxRow, 18 | calCrossSpan 19 | } from './util.js' 20 | import { 21 | CSS_PREFIX, 22 | ROW_INDEX_WIDTH, 23 | CHECK_BOX_WIDTH, 24 | SCROLLER_TRACK_SIZE, 25 | HEADER_HEIGHT 26 | } from "./constants.js"; 27 | 28 | class DataGrid { 29 | constructor(target, options) { 30 | this.target = target; 31 | this.scrollY = 0; 32 | this.scrollX = 0; 33 | 34 | this.checkboxWidth = CHECK_BOX_WIDTH; 35 | this.horizontalScrollerSize = SCROLLER_TRACK_SIZE; 36 | this.verticalScrollerSize = SCROLLER_TRACK_SIZE; 37 | 38 | this.tempValue = ''; // 存储用户输入的临时值,当执行doneEdit后才去setData() 39 | this.focusCell = null; // 当前焦点所在的cell 40 | this.enterShift = false; // 是否按下shift 41 | 42 | this.hashChange = {}; // diff changed 43 | 44 | // 选择区域 45 | this.selector = { 46 | show: false, // 是否显示 47 | isSelected: false, // 单击鼠标按下代表即将要开始范围选择 48 | xArr: [-1, -1], // 选中区域 49 | yArr: [-1, -1] 50 | }; 51 | this.editor = { 52 | show: false, 53 | xIndex: 0, 54 | yIndex: 0 55 | }; 56 | // 自动填充 57 | this.autofill = { 58 | enable: false, // 为true代表要开始下拉数据填充 59 | xIndex: 0, // 数据填充触点的坐标 60 | yIndex: 0, 61 | xArr: [-1, -1], // 数据填充的范围 62 | yArr: [-1, -1] 63 | }; 64 | 65 | // 生成主画笔 66 | this.painter = new Paint(target); 67 | 68 | this.initConfig(options); 69 | 70 | // this.createContainer() 71 | 72 | this.clipboard = new Clipboard(this); 73 | 74 | // Headers 表头对象 75 | this.header = new Header(this, 0, 0); 76 | 77 | // Body 主体 78 | this.body = new Body(this, this.data); 79 | 80 | // 滚动条 81 | this.scroller = new Scroller(this); 82 | 83 | this.setLayoutSize(options); // 设置容器宽高 84 | 85 | this.initTableSize(); 86 | 87 | this.tooltip = new Tooltip(this, 0, 0); 88 | 89 | this.events = new Events(this, target); 90 | 91 | this.history = new Histories(this) 92 | 93 | this.initPaint(); 94 | } 95 | /** 96 | * 容器初始化相关 97 | */ 98 | initConfig(options) { 99 | Object.assign( 100 | this, 101 | { 102 | data: [], 103 | color: "#495060", 104 | borderColor: "#e1e6eb", 105 | fillColor: "#fff", 106 | borderWidth: 1, 107 | fixedLeft: 0, 108 | fixedRight: 0, 109 | showCheckbox: true, 110 | headerHeight: HEADER_HEIGHT, 111 | beforeSelectCell: cell => {}, // 选中单元格之前触发 112 | afterSelectCell: cell => {}, 113 | beforeMultiSelectCell: cells => {}, // 批量选中单元格之前触发 114 | afterMultiSelectCell: cells => {}, 115 | beforeEditCell: cell => {}, // 编辑单元格 116 | afterEditCell: cell => {}, 117 | beforeSelectRow: row => {}, // 选中行触发 118 | afterSelectRow: row => {}, 119 | beforeResizeColumn: () => {}, // 调整列宽 120 | afterResizeColumn: () => {}, 121 | beforeResizeRow: () => {}, //调整行高 122 | afterResizeRow: () => {}, 123 | beforeAutofill: () => {}, // 自动填充 124 | afterAutofill: () => {}, 125 | beforeCopy: () => {}, // 复制 126 | afterCopy: () => {}, 127 | beforePaste: () => {}, // 粘贴 128 | afterPaste: () => {}, 129 | afterClear: () => {}, // 清空数据 130 | onLoad: () => {} // 表格加载完成 131 | }, 132 | options 133 | ); 134 | 135 | // 编辑器边界范围 136 | this.range = { 137 | minX: 0, 138 | minY: 0, 139 | maxY: this.data.length - 1 140 | }; 141 | this.updateColumns(options.columns) 142 | 143 | if (!this.showCheckbox) { 144 | this.checkboxWidth = 0; 145 | } 146 | this.originFixedWidth = ROW_INDEX_WIDTH + this.checkboxWidth; 147 | } 148 | /** 149 | * 获取容器可是区域宽、高,设置canvas容器样式尺寸、画布尺寸 150 | */ 151 | setLayoutSize(options = {}) { 152 | const el = this.target.parentElement; 153 | const rootEl = el.parentElement; 154 | const { width, left, top } = rootEl.getBoundingClientRect(); 155 | this.containerOriginX = left; 156 | this.containerOriginY = top; 157 | this.width = options.width || width; // 容器宽 158 | this.height = options.height || window.innerHeight - top; // 容器高 159 | 160 | this.target.width = this.width * dpr; 161 | this.target.height = this.height * dpr; 162 | this.target.style.width = this.width + "px"; 163 | this.target.style.height = this.height + "px"; 164 | el.style.width = this.width + "px"; 165 | el.style.height = this.height + "px"; 166 | this.painter.scaleCanvas(dpr); 167 | } 168 | /** 169 | * 获取表格左、右冻结列宽,表格实际的宽、高 170 | */ 171 | getTableSize() { 172 | let fixedLeftWidth = this.originFixedWidth; 173 | let fixedRightWidth = SCROLLER_TRACK_SIZE; 174 | this.header.fixedColumnHeaders.forEach(item => { 175 | if (item.index < this.fixedLeft) { 176 | fixedLeftWidth += item.width; 177 | } 178 | if (item.index > this.columnsLength - 1 - this.fixedRight) { 179 | fixedRightWidth += item.width; 180 | } 181 | }); 182 | this.fixedLeftWidth = fixedLeftWidth; 183 | this.fixedRightWidth = fixedRightWidth; 184 | this.tableWidth = this.header.allColumnHeaders.filter(item => !item.level).reduce((sum, item) => { 185 | return sum + item.width; 186 | }, this.originFixedWidth); 187 | this.tableHeight = this.body.height; 188 | 189 | this.scroller.reset(); 190 | } 191 | initTableSize() { 192 | this.getTableSize(); 193 | this.fillTableWidth(); 194 | } 195 | /** 196 | * 列总宽小于可视区域宽度时,需要补余 197 | */ 198 | fillTableWidth() { 199 | if (this.tableWidth <= this.width - SCROLLER_TRACK_SIZE) { // 没有横向滚动条 200 | const fillCellWidth = 201 | (this.width - SCROLLER_TRACK_SIZE - this.tableWidth) / 202 | this.columnsLength; 203 | fillCellWidth && this.body.resizeAllColumn(fillCellWidth); 204 | fillCellWidth && this.header.resizeAllColumn(fillCellWidth); 205 | this.tableWidth = this.width - SCROLLER_TRACK_SIZE; 206 | this.fixedLeftWidth = 0; 207 | this.fixedRightWidth = SCROLLER_TRACK_SIZE; 208 | } 209 | 210 | this.scroller.reset(); 211 | } 212 | resize() { 213 | const diffX = this.tableWidth - this.width + this.scrollX; 214 | 215 | this.setLayoutSize(); 216 | this.fillTableWidth(); 217 | 218 | if ( 219 | this.tableWidth - (this.width - SCROLLER_TRACK_SIZE) + this.scrollX < 220 | 0 221 | ) { 222 | // 小屏滚动到最右侧再调大屏幕断开的问题 223 | this.scrollX = this.width - this.tableWidth + diffX; 224 | } 225 | } 226 | createContainer() { 227 | // 顶层容器 228 | this.rootEl = h("div", `${CSS_PREFIX}`); 229 | 230 | // this.loadingEl = h('div', `${CSS_PREFIX}-loading`) 231 | // .children( 232 | // this.loadingDot = h('div', `${CSS_PREFIX}-loading-dot`) 233 | // ) 234 | // 画布外层容器 235 | this.wrapEl = h("div", `${CSS_PREFIX}-main`); 236 | this.wrapEl.offset({ 237 | width: this.width, 238 | height: this.height 239 | }); 240 | this.rootEl.children(this.wrapEl); 241 | 242 | // 画布 243 | this.tableEl = h("canvas", `${CSS_PREFIX}-table`); 244 | 245 | // 编辑器 246 | this.editor = new Editor(this); 247 | // this.selector = new Selector() 248 | 249 | // 编辑器、选区容器 250 | this.overlayerEl = h("div", `${CSS_PREFIX}-overlayer`).children( 251 | this.editor.el 252 | // this.selector.el 253 | ); 254 | 255 | this.wrapEl.children(this.tableEl, this.overlayerEl); 256 | 257 | this.target.appendChild(this.rootEl.el); 258 | } 259 | /** 260 | * 选择、编辑相关-----------------------------------------------------> 261 | */ 262 | getCell(x, y) { 263 | return this.body.getCell(x, y); 264 | } 265 | // mousedown事件 -> 开始拖拽批量选取 266 | selectCell({ colIndex, rowIndex }) { 267 | this.clearMultiSelect(); 268 | this.selector.show = true; 269 | this.selector.isSelected = true; 270 | 271 | // shift批量选择 272 | if (this.enterShift && this.focusCell) { 273 | const { colIndex: oldX, rowIndex: oldY } = this.focusCell 274 | 275 | const minX = Math.min(oldX, colIndex) 276 | const maxX = Math.max(oldX, colIndex) 277 | const minY = Math.min(oldY, rowIndex); 278 | const maxY = Math.max(oldY, rowIndex); 279 | this.autofill.xIndex = maxX; 280 | this.autofill.yIndex = maxY; 281 | this.selector.xArr = [minX, maxX]; 282 | this.selector.yArr = [minY, maxY]; 283 | } else { 284 | this.editor.xIndex = colIndex; 285 | this.editor.yIndex = rowIndex; 286 | this.adjustBoundaryPosition(); 287 | } 288 | 289 | this.putCell() 290 | } 291 | // 将选区和autofill置为编辑框所在位置 292 | resetCellPosition() { 293 | this.selector.xArr = [this.editor.xIndex, this.editor.xIndex]; 294 | this.selector.yArr = [this.editor.yIndex, this.editor.yIndex]; 295 | this.autofill.xIndex = this.editor.xIndex; 296 | this.autofill.yIndex = this.editor.yIndex; 297 | } 298 | /** 299 | * 将画布单元格中的数据传递到编辑器中 300 | */ 301 | putCell() { 302 | const { 303 | x, 304 | y, 305 | width, 306 | height, 307 | value, 308 | fixed, 309 | dataType, 310 | options 311 | } = this.focusCell; 312 | const _x = 313 | fixed === "right" 314 | ? this.width - 315 | (this.tableWidth - x - width) - 316 | width - 317 | SCROLLER_TRACK_SIZE 318 | : fixed === "left" 319 | ? x 320 | : x + this.scrollX; 321 | const _y = y + this.scrollY; 322 | this.afterSelectCell({ 323 | value, 324 | x: _x, 325 | y: _y, 326 | width, 327 | height, 328 | dataType, 329 | options 330 | }); 331 | } 332 | // mousemove事件 -> 更新选取范围 333 | multiSelectCell(x, y, mouseX, mouseY) { 334 | const selector = this.selector; 335 | if (selector.isSelected) { 336 | const minX = Math.min(x, this.editor.xIndex); 337 | const maxX = Math.max(x, this.editor.xIndex); 338 | const minY = Math.min(y, this.editor.yIndex); 339 | const maxY = Math.max(y, this.editor.yIndex); 340 | this.autofill.xIndex = maxX; 341 | this.autofill.yIndex = maxY; 342 | selector.xArr = [minX, maxX]; 343 | selector.yArr = [minY, maxY]; 344 | this.adjustPosition(x, y, mouseX, mouseY) 345 | } 346 | // 设置autofill填充区域 347 | if (this.autofill.enable) { 348 | this.adjustPosition(x, y, mouseX, mouseY) 349 | this.autofill.xArr = selector.xArr.slice(); 350 | this.autofill.yArr = selector.yArr.slice(); 351 | if (y >= selector.yArr[0] && y <= selector.yArr[1]) { 352 | if (x > selector.xArr[1]) { 353 | this.autofill.xArr.splice(1, 1, x); 354 | } else if (x < selector.xArr[0]) { 355 | this.autofill.xArr.splice(0, 1, x); 356 | } 357 | } else { 358 | if (y > selector.yArr[1]) { 359 | this.autofill.yArr.splice(1, 1, y); 360 | } else if (y < selector.yArr[0]) { 361 | this.autofill.yArr.splice(0, 1, y); 362 | } 363 | } 364 | } 365 | } 366 | // mouseup事件 367 | endMultiSelect() { 368 | this.selector.isSelected = false; 369 | if (this.selector.show && this.autofill.enable) { 370 | this.body.autofillData(); 371 | this.autofill.enable = false; 372 | } 373 | } 374 | // 清空批量选取 375 | clearMultiSelect() { 376 | this.selector.xArr = [-1, -1]; 377 | this.selector.yArr = [-1, -1]; 378 | } 379 | // 选中整列 380 | selectCols(col) { 381 | const { index, colspan } = col 382 | // 复合表头情况下,需要处理跨级 383 | const colspanIndx = index + colspan - 1 384 | this.selector.show = true 385 | if (this.enterShift && this.focusCell) { 386 | const { colIndex: oldX } = this.focusCell 387 | const minX = Math.min(oldX, index) 388 | const maxX = Math.max(oldX, colspanIndx) 389 | this.selector.xArr = [minX, maxX]; 390 | this.selector.yArr = [this.range.minY, this.range.maxY]; 391 | this.editor.xIndex = oldX; // 将编辑器坐标置为第一次按下鼠标的位置 392 | this.editor.yIndex = this.range.minY; 393 | this.autofill.xIndex = maxX 394 | this.autofill.yIndex = this.range.maxY 395 | } else { 396 | this.selector.xArr = [index, colspanIndx]; 397 | this.selector.yArr = [this.range.minY, this.range.maxY]; 398 | this.editor.xIndex = index; 399 | this.editor.yIndex = this.range.minY; 400 | this.autofill.xIndex = colspanIndx 401 | this.autofill.yIndex = this.range.maxY 402 | } 403 | 404 | this.focusCell = this.body.getCell(this.editor.xIndex, this.editor.yIndex); 405 | this.putCell() 406 | } 407 | // 选中整行 408 | selectRows({ rowIndex }) { 409 | this.selector.show = true 410 | if (this.enterShift && this.focusCell) { 411 | const { rowIndex: oldY } = this.focusCell 412 | const minY = Math.min(oldY, rowIndex) 413 | const maxY = Math.max(oldY, rowIndex) 414 | this.selector.xArr = [this.range.minX, this.range.maxX]; 415 | this.selector.yArr = [minY, maxY]; 416 | this.editor.xIndex = this.range.minX; 417 | this.editor.yIndex = oldY; 418 | this.autofill.xIndex = this.range.maxX 419 | this.autofill.yIndex = maxY 420 | } else { 421 | this.selector.xArr = [this.range.minX, this.range.maxX]; 422 | this.selector.yArr = [rowIndex, rowIndex]; 423 | this.editor.xIndex = this.range.minX; 424 | this.editor.yIndex = rowIndex; 425 | this.autofill.xIndex = this.range.maxX 426 | this.autofill.yIndex = rowIndex 427 | } 428 | 429 | this.focusCell = this.body.getCell(this.editor.xIndex, this.editor.yIndex); 430 | this.putCell() 431 | } 432 | startAutofill() { 433 | this.autofill.enable = true; 434 | } 435 | clearAuaofill() { 436 | this.selector.xArr.splice(0, 1, this.autofill.xArr[0]); 437 | this.selector.xArr.splice(1, 1, this.autofill.xArr[1]); 438 | this.selector.yArr.splice(0, 1, this.autofill.yArr[0]); 439 | this.selector.yArr.splice(1, 1, this.autofill.yArr[1]); 440 | this.autofill.xIndex = this.selector.xArr[1]; 441 | this.autofill.yIndex = this.selector.yArr[1]; 442 | // 填充完数据清空 443 | this.autofill.xArr = [-1, -1]; 444 | this.autofill.yArr = [-1, -1]; 445 | } 446 | // 开始编辑 447 | startEdit() { 448 | if (this.focusCell && !this.focusCell.readonly) { 449 | this.editor.show = true; 450 | // this.selector.show = false; 451 | this.resetCellPosition() 452 | this.putCell() // BackSpace/delede删除再直接enter进入编辑模式,不会再次更新编辑器的焦点cell 453 | this.beforeEditCell(); 454 | } 455 | } 456 | // 完成编辑 457 | doneEdit() { 458 | if (this.editor.show && this.focusCell) { 459 | this.editor.show = false; 460 | this.selector.show = true; // 编辑完再选中该单元格 461 | if (this.focusCell.value !== this.tempValue) { 462 | // handle history 463 | this.history.pushState({ 464 | before: { 465 | colIndex: this.focusCell.colIndex, 466 | rowIndex: this.focusCell.rowIndex, 467 | value: this.focusCell.value 468 | }, 469 | after: { 470 | colIndex: this.focusCell.colIndex, 471 | rowIndex: this.focusCell.rowIndex, 472 | value: this.tempValue 473 | }, 474 | type: 'single' 475 | }) 476 | this.focusCell.setData(this.tempValue) 477 | const rowData = this.body.getRowData(this.focusCell.rowIndex) 478 | this.afterEditCell(rowData) 479 | } 480 | this.putCell() 481 | this.clipboard.clear(); 482 | } 483 | } 484 | /** 485 | * 单个写入数据 486 | * @param {Number} colIndex 需要写入数据的单元格X轴坐标 表格最左上角为<0,0> 487 | * @param {Number} rowIndex 需要写入数据的单元格Y轴坐标 488 | * @param {*} value 需要写入的数据: 简单数据类型 489 | */ 490 | setData(value, { colIndex, rowIndex } = this.focusCell) { 491 | const focusCell = this.body.getCell(colIndex, rowIndex); 492 | focusCell && focusCell.setData(value); 493 | this.focusCellByCoord(colIndex, rowIndex) 494 | } 495 | /** 496 | * 批量写入数据 497 | * @param {Number} colIndex 需要写入数据的单元格范围起始X轴坐标 表格最左上角为<0,0> 498 | * @param {Number} rowIndex 需要写入数据的单元格范围起始Y轴坐标 499 | * @param {Array} value 需要写入的数据: 二维数组[] 500 | */ 501 | batchSetData({ colIndex, rowIndex, value }) { 502 | this.body.batchSetData({ colIndex, rowIndex, value }) 503 | this.focusCellByCoord( 504 | colIndex, 505 | rowIndex, 506 | colIndex + value[0].length - 1, 507 | rowIndex + value.length - 1 508 | ) 509 | } 510 | /** 511 | * 将用户通过编辑器输入的值存储为一个临时变量,执行doneEdit()后再去setData() 512 | */ 513 | setTempData(value) { 514 | this.editor.show = true; 515 | // this.selector.show = false; 516 | this.tempValue = value 517 | } 518 | pasteData(arr) { 519 | if (arr.length > 0) { 520 | this.body.pasteData(arr); 521 | this.clipboard.select(arr) 522 | } 523 | } 524 | clearSelectedData() { 525 | this.body.clearSelectedData() 526 | } 527 | focusCellByCoord(minX, minY, maxX, maxY) { 528 | this.selector.xArr = [minX, maxX || minX]; 529 | this.selector.yArr = [minY, maxY || minY]; 530 | this.editor.xIndex = minX; 531 | this.editor.yIndex = minY; 532 | this.autofill.xIndex = maxX || minX 533 | this.autofill.yIndex = maxY || minY 534 | this.focusCell = this.body.getCell(this.editor.xIndex, this.editor.yIndex); 535 | } 536 | /** 537 | * 调整列宽、行宽 538 | */ 539 | resizeColumn(colIndex, newWidth) { 540 | this.header.resizeColumn(colIndex, newWidth); 541 | 542 | this.body.resizeColumn(colIndex, newWidth); 543 | 544 | this.getTableSize(); 545 | } 546 | resizeRow(rowIndex, height) { 547 | this.body.resizeRow(rowIndex, height); 548 | this.getTableSize(); 549 | } 550 | handleCheckRow(y) { 551 | this.body.handleCheckRow(y); 552 | } 553 | handleCheckHeader() { 554 | this.body.handleCheckHeader(); 555 | } 556 | // 键盘上下左右切换 557 | moveFocus(dir) { 558 | switch (dir) { 559 | case "LEFT": 560 | if (this.editor.xIndex > this.range.minX) { 561 | this.editor.xIndex--; 562 | } 563 | break; 564 | case "TOP": 565 | if (this.editor.yIndex > this.range.minY) { 566 | this.editor.yIndex--; 567 | } 568 | break; 569 | case "RIGHT": 570 | if (this.editor.xIndex < this.range.maxX) { 571 | this.editor.xIndex++; 572 | } 573 | break; 574 | case "BOTTOM": 575 | if (this.editor.yIndex < this.range.maxY) { 576 | this.editor.yIndex++; 577 | } 578 | break; 579 | default: 580 | // 581 | } 582 | this.adjustBoundaryPosition(); 583 | this.putCell() 584 | } 585 | adjustPosition(x, y, mouseX, mouseY) { 586 | const diffX = mouseX - this.width + SCROLLER_TRACK_SIZE 587 | const diffY = mouseY - this.height + SCROLLER_TRACK_SIZE 588 | if (diffX > 0) { 589 | this.scroller.update(-12, "HORIZONTAL"); 590 | } 591 | if (diffY > 0) { 592 | this.scroller.update(-12, "VERTICAL"); 593 | } 594 | } 595 | // 调整滚动条位置,让焦点单元格始终出现在可视区域内 596 | adjustBoundaryPosition() { 597 | this.resetCellPosition() 598 | this.focusCell = this.body.getCell(this.editor.xIndex, this.editor.yIndex); 599 | if (this.focusCell.fixed) return; 600 | 601 | const cellTotalViewWidth = 602 | this.focusCell.x + this.focusCell.width + this.scrollX; 603 | const cellTotalViewHeight = 604 | this.focusCell.y + this.focusCell.height + this.scrollY; 605 | const viewWidth = this.width - this.fixedRightWidth; 606 | const viewHeight = this.height - SCROLLER_TRACK_SIZE; 607 | const diffLeft = this.focusCell.x + this.scrollX - this.fixedLeftWidth; 608 | const diffRight = viewWidth - cellTotalViewWidth; 609 | const diffTop = this.focusCell.y + this.scrollY - this.tableHeaderHeight; 610 | const diffBottom = viewHeight - cellTotalViewHeight; 611 | // const fillWidth = this.focusCell.colIndex < this.columnsLength - 1 - this.fixedRight ? 612 | // this.focusCell.x + this.scrollX - viewWidth 613 | // : 0 614 | if (diffRight < 0) { 615 | this.scroller.update(diffRight, "HORIZONTAL"); 616 | } else if (diffLeft < 0) { 617 | this.scroller.update(-diffLeft, "HORIZONTAL"); 618 | } 619 | if (diffTop < 0) { 620 | this.scroller.update(-diffTop, "VERTICAL"); 621 | } else if (diffBottom < 0) { 622 | this.scroller.update(diffBottom, "VERTICAL"); 623 | } 624 | } 625 | /** 626 | * 画布绘制相关-------------------------------------------------------> 627 | */ 628 | initPaint() { 629 | this.draw(); 630 | window.requestAnimationFrame(this.initPaint.bind(this)); 631 | } 632 | drawContainer() { 633 | this.painter.drawRect(0, 0, this.width, this.height, { 634 | borderColor: this.borderColor, 635 | fillColor: '#fff', 636 | borderWidth: this.borderWidth 637 | }); 638 | } 639 | draw() { 640 | this.painter.clearCanvas(); 641 | 642 | // 绘制外层容器 643 | this.drawContainer(); 644 | 645 | if (!this.columnsLength) return; 646 | 647 | // body 648 | this.body.draw(); 649 | 650 | // 数据校验错误提示 651 | this.tooltip.draw(); 652 | 653 | // 绘制表头 654 | this.header.draw(); 655 | 656 | // 绘制滚动条 657 | this.scroller.draw(); 658 | } 659 | /** 660 | * 事件相关-------------------------------------------------------> 661 | */ 662 | updateColumns(columns) { 663 | const maxHeaderRow = getMaxRow(columns) 664 | // 有复合表头的情况下,高度强制改为24 665 | if (maxHeaderRow > 1) { 666 | this.headerHeight = 24 667 | } 668 | this.tableHeaderHeight = this.headerHeight * maxHeaderRow 669 | // 计算复合表头的跨行colspan、跨列数rowspan,用作表头渲染 670 | this.headers = calCrossSpan(columns, maxHeaderRow) 671 | // 获取叶子节点表头,用作数据渲染 672 | this.columns = toLeaf(columns) 673 | this.columnsLength = this.columns.length; 674 | this.range.maxX = this.columnsLength - 1; 675 | } 676 | loadColumns(columns) { 677 | this.updateColumns(columns) 678 | 679 | this.header.paint() 680 | this.getTableSize() 681 | } 682 | loadData(data) { 683 | this.data = data; 684 | this.range.maxY = data.length - 1; 685 | this.body.paint(data); 686 | this.initTableSize(); 687 | } 688 | getData() { 689 | this.doneEdit() 690 | return this.body.getData(); 691 | } 692 | getRowData(y) { 693 | this.doneEdit() 694 | return this.body.getRowData(y) 695 | } 696 | getCheckedRows() { 697 | this.doneEdit() 698 | return this.body.getCheckedRows(); 699 | } 700 | getChangedRows() { 701 | this.doneEdit() 702 | return this.body.getChangedRows(); 703 | } 704 | validate(callback) { 705 | this.doneEdit() 706 | return this.body.validate(callback); 707 | } 708 | validateChanged(callback) { 709 | this.doneEdit() 710 | return this.body.validateChanged(callback); 711 | } 712 | validateField(ci, ri) { 713 | this.doneEdit() 714 | return this.body.validateField(ci, ri); 715 | } 716 | getValidations() { 717 | this.doneEdit() 718 | return this.body.getValidations() 719 | } 720 | setValidations(errors) { 721 | return this.body.setValidations(errors) 722 | } 723 | clearValidations() { 724 | return this.body.clearValidations() 725 | } 726 | updateData(data) { 727 | return this.body.updateData(data) 728 | } 729 | } 730 | export default DataGrid; 731 | -------------------------------------------------------------------------------- /src/core/Editor.js: -------------------------------------------------------------------------------- 1 | import { h } from "./element.js"; 2 | import { CSS_PREFIX } from "./constants.js"; 3 | 4 | function inputEventHandler(evt) { 5 | const v = evt.target.innerHTML; 6 | // console.log(evt, 'v:', v); 7 | const { suggest, validator } = this; 8 | const { cell } = this; 9 | if (cell !== null) { 10 | if ( 11 | ("editable" in cell && cell.editable === true) || 12 | cell.editable === undefined 13 | ) { 14 | this.value = v; 15 | if (validator) { 16 | if (validator.type === "list") { 17 | suggest.search(v); 18 | } else { 19 | suggest.hide(); 20 | } 21 | } 22 | } else { 23 | evt.target.value = ""; 24 | } 25 | } else { 26 | this.value = v; 27 | } 28 | } 29 | function keydownHander(e) { 30 | // // 撤销 31 | // if ((e.ctrlKey && e.keyCode === 90) || e.metaKey && !e.shiftKey && e.keyCode === 90) { 32 | // return console.log('undo') 33 | // } 34 | // // 恢复 35 | // if ((e.ctrlKey && e.keyCode === 89) || (e.metaKey && e.shiftKey && e.keyCode === 90)) { 36 | // return console.log('recovery') 37 | // } 38 | // 编辑模式下按Enter/ESC 39 | if (e.keyCode === 13 || e.keyCode === 27) { 40 | return this.grid.finishedEdit(); 41 | } 42 | // // 未选中或编辑模式下可以撤销、恢复和enter/ESC退出编辑模式,除此之外阻止键盘事件 43 | // if (!this.selector.show || this.editor.show) { 44 | // return 45 | // } 46 | // // CTRL+C/Command+C 47 | // if ((e.ctrlKey && e.keyCode === 67) || (e.metaKey && e.keyCode === 67)) { 48 | // return this.copy() 49 | // } 50 | // // CTRL+V/Command+V 51 | // if ((e.ctrlKey && e.keyCode === 86) || (e.metaKey && e.keyCode === 86)) { 52 | // return this.$emit('focus', 'clipboard') 53 | // } 54 | // // CTRL+A/Command+A 55 | // if ((e.ctrlKey && e.keyCode) === 65 || (e.metaKey && e.keyCode === 65)) { 56 | // return this.selectAll(e) 57 | // } 58 | if (e.metaKey || e.ctrlKey) { 59 | // 阻止CTRL+类型的事件 60 | return; 61 | } 62 | // e.preventDefault() 63 | // const keyHandler = (k) => { 64 | // if ((k >= 65 && k <= 90) || (k >= 48 && k <= 57) || (k >= 96 && k <= 107) || (k >= 109 && k <= 111) || k === 32 || (k >= 186 && k <= 222)) { 65 | // return true 66 | // } else { 67 | // return false 68 | // } 69 | // } 70 | // if (keyHandler(e.keyCode)) { 71 | // return this.doEdit(e.key) 72 | // } 73 | // switch (e.keyCode) { 74 | // // 左 75 | // case 37: 76 | // if (this.editor.editorXIndex > this.editor.range.minX) { 77 | // this.editor.editorXIndex-- 78 | // } 79 | // this.adjustPosition() 80 | // break 81 | // // 上 82 | // case 38: 83 | // if (this.editor.editorYIndex > this.editor.range.minY) { 84 | // this.editor.editorYIndex-- 85 | // } 86 | // this.adjustPosition() 87 | // break 88 | // // 右 或 Tab 89 | // case 9: 90 | // case 39: 91 | // if (this.editor.editorXIndex < this.editor.range.maxX) { 92 | // this.editor.editorXIndex++ 93 | // } 94 | // this.adjustPosition() 95 | // break 96 | // // 下 97 | // case 40: 98 | // if (this.editor.editorYIndex < this.editor.range.maxY) { 99 | // this.editor.editorYIndex++ 100 | // } 101 | // this.adjustPosition() 102 | // break 103 | // // BackSpace/delede 104 | // case 8: 105 | // this.clearSelected() 106 | // break 107 | // // Enter 108 | // case 13: 109 | // this.doEdit() 110 | // break 111 | // default: 112 | // console.log(e, 'event') 113 | // } 114 | } 115 | // 改用div(contenteditable = true) 116 | function resetTextareaSize() { 117 | const { value } = this; 118 | if (!/^\s*$/.test(value)) { 119 | const { textlineEl, textEl, areaOffset } = this; 120 | const txts = value.split("\n"); 121 | const maxTxtSize = Math.max(...txts.map(it => it.length)); 122 | const tlOffset = textlineEl.offset(); 123 | const fontWidth = tlOffset.width / value.length; 124 | const tlineWidth = (maxTxtSize + 1) * fontWidth + 5; 125 | const maxWidth = this.viewFn().width - areaOffset.left - fontWidth; 126 | let h1 = txts.length; 127 | if (tlineWidth > areaOffset.width) { 128 | let twidth = tlineWidth; 129 | if (tlineWidth > maxWidth) { 130 | twidth = maxWidth; 131 | h1 += parseInt(tlineWidth / maxWidth, 10); 132 | h1 += tlineWidth % maxWidth > 0 ? 1 : 0; 133 | } 134 | textEl.css("width", `${twidth}px`); 135 | } 136 | h1 *= this.rowHeight; 137 | if (h1 > areaOffset.height) { 138 | textEl.css("height", `${h1}px`); 139 | } 140 | } 141 | } 142 | 143 | class Editor { 144 | constructor(grid) { 145 | this.grid = grid; 146 | this.areaEl = h("div", `${CSS_PREFIX}-editor-area`).children( 147 | (this.textEl = h("div", "") 148 | .on("input", evt => inputEventHandler.call(this, evt)) 149 | .on("keydown", evt => keydownHander.call(this, evt))) 150 | ); 151 | this.textEl.attr("contenteditable", true); 152 | this.el = h("div", `${CSS_PREFIX}-editor`) 153 | .child(this.areaEl) 154 | .hide(); 155 | 156 | this.type = "text"; // 数据类型 157 | this.fixed = false; // 当前选中单元格是否属于冻结列 158 | this.selectOptions = []; // 下拉数据源 159 | this.editorXIndex = 0; // 编辑器所在x,y轴坐标 160 | this.editorYIndex = 0; 161 | this.show = false; 162 | this.value = ""; 163 | } 164 | fire(cell) { 165 | this.cell = cell; 166 | this.setoffset(); 167 | this.show = true; 168 | this.el.show(); 169 | this.textEl.focus(); 170 | } 171 | hide() { 172 | this.cell = null; 173 | this.el.hide(); 174 | this.show = false; 175 | this.value = ""; 176 | this.textEl.html(""); 177 | } 178 | setData(val) { 179 | this.value = val; 180 | this.textEl.html(val); 181 | // console.log('text>>:', text); 182 | // setText.call(this, text, text.length); 183 | // resetTextareaSize.call(this); 184 | } 185 | setoffset() { 186 | if (this.cell) { 187 | const { x, y, width, height } = this.cell; 188 | this.areaEl.offset({ 189 | left: x + this.grid.scrollX - 1, 190 | top: y + this.grid.scrollY - 1 191 | }); 192 | this.textEl.offset({ 193 | "min-width": width + 2, 194 | "min-height": height 195 | }); 196 | } 197 | } 198 | } 199 | 200 | export default Editor; 201 | -------------------------------------------------------------------------------- /src/core/Events.js: -------------------------------------------------------------------------------- 1 | function bind(target, name, fn, useCapture) { 2 | target.addEventListener(name, fn, useCapture); 3 | } 4 | function unbind(target, name, fn, useCapture) { 5 | target.removeEventListener(name, fn, useCapture); 6 | } 7 | function unbindClickoutside(el) { 8 | if (el.xclickoutside) { 9 | unbind(window.document.body, 'mousedown', el.xclickoutside); 10 | delete el.xclickoutside; 11 | } 12 | } 13 | 14 | // the left mouse button: mousedown → mouseup → click 15 | // the right mouse button: mousedown → contenxtmenu → mouseup 16 | // the right mouse button in firefox(>65.0): mousedown → contenxtmenu → mouseup → click on window 17 | function bindClickoutside(el, cb) { 18 | const self = this 19 | el.xclickoutside = (evt) => { 20 | // ignore double click 21 | // console.log('evt:', evt); 22 | const pointX = evt.clientX - self.containerOriginX 23 | const pointY = evt.clientY - self.containerOriginY 24 | const isInTable = pointX > 0 && pointX < self.width && pointY > 0 && pointY < self.height 25 | if (evt.detail === 2 || el.contains(evt.target) || isInTable) return; 26 | if (cb) cb(el); 27 | else { 28 | el.style.display = 'none' 29 | unbindClickoutside(el); 30 | } 31 | }; 32 | bind(window.document.body, 'mousedown', el.xclickoutside); 33 | } 34 | function throttle( 35 | func, 36 | time = 17, 37 | options = { 38 | // leading 和 trailing 无法同时为 false 39 | leading: true, 40 | trailing: false, 41 | context: null 42 | } 43 | ) { 44 | let previous = new Date(0).getTime(); 45 | let timer; 46 | const _throttle = function(...args) { 47 | let now = new Date().getTime(); 48 | 49 | if (!options.leading) { 50 | if (timer) return; 51 | timer = setTimeout(() => { 52 | timer = null; 53 | func.apply(options.context, args); 54 | }, time); 55 | } else if (now - previous > time) { 56 | func.apply(options.context, args); 57 | previous = now; 58 | } else if (options.trailing) { 59 | clearTimeout(timer); 60 | timer = setTimeout(() => { 61 | func.apply(options.context, args); 62 | }, time); 63 | } 64 | }; 65 | // 闭包返回取消函数 66 | _throttle.cancel = () => { 67 | previous = 0; 68 | clearTimeout(timer); 69 | timer = null; 70 | }; 71 | return _throttle; 72 | } 73 | function handleMouseDown(e) { 74 | e.preventDefault(); 75 | this.enterShift = e.shiftKey 76 | // 点击画布的任何区域都需要将编辑器变为非编辑模式 77 | this.doneEdit(); 78 | const rect = e.target.getBoundingClientRect(); 79 | const x = e.clientX - rect.left; 80 | const y = e.clientY - rect.top; 81 | if (this.header.isInsideHeader(x, y)) { 82 | this.header.mouseDown(x, y); 83 | } 84 | this.body.mouseDown(x, y); 85 | this.scroller.mouseDown(x, y); 86 | } 87 | function handleMouseMove(e) { 88 | if (e.target.tagName.toLowerCase() === "canvas") { 89 | e.preventDefault(); 90 | } 91 | const rect = e.target.getBoundingClientRect(); 92 | const x = e.clientX - rect.left; 93 | const y = e.clientY - rect.top; 94 | this.target.style.cursor = "default"; 95 | if (this.header.isInsideHeader(x, y) || this.header.isResizing) { 96 | this.header.resizing(x, y); 97 | } 98 | this.body.mouseMove(x, y); 99 | this.scroller.mouseMove(x, y); 100 | } 101 | function handleMouseUp(e) { 102 | if (e.target.tagName.toLowerCase() === "canvas") { 103 | e.preventDefault(); 104 | } 105 | const rect = e.target.getBoundingClientRect(); 106 | const x = e.clientX - rect.left; 107 | const y = e.clientY - rect.top; 108 | if (this.header.isInsideHeader(x, y) || this.header.isResizing) { 109 | this.header.endResize(x, y); 110 | } 111 | this.endMultiSelect(); 112 | this.scroller.mouseUp(x, y); 113 | } 114 | function handleClick(e) { 115 | e.preventDefault(); 116 | const rect = e.target.getBoundingClientRect(); 117 | const x = e.clientX - rect.left; 118 | const y = e.clientY - rect.top; 119 | this.body.click(x, y); 120 | if (this.header.isInsideHeaderCheckboxBoundary(x, y)) { 121 | this.header.handleCheck(); 122 | this.body.handleCheckRow(); // 表头勾选需要影响body的勾选框状态 123 | } 124 | } 125 | function handleDbClick(e) { 126 | e.preventDefault(); 127 | const rect = e.target.getBoundingClientRect(); 128 | const x = e.clientX - rect.left; 129 | const y = e.clientY - rect.top; 130 | this.body.dbClick(x, y); 131 | } 132 | function handleClickoutside() { 133 | this.doneEdit(); 134 | } 135 | function handleKeydown(e) { 136 | if (this.editor.show) { 137 | // 编辑模式按下按Enter/ESC退出编辑模式 138 | if (e.keyCode === 13 || e.keyCode === 27) { 139 | if (e.metaKey || e.ctrlKey) return; 140 | e.preventDefault(); 141 | this.doneEdit(); 142 | } 143 | return; 144 | } 145 | // 未选中 146 | if (!this.selector.show) { 147 | return; 148 | } 149 | // 撤销 150 | if ( 151 | (e.ctrlKey && e.keyCode === 90) || 152 | (e.metaKey && !e.shiftKey && e.keyCode === 90) 153 | ) { 154 | e.preventDefault() 155 | this.history.backState() 156 | } 157 | // 恢复 158 | if ( 159 | (e.ctrlKey && e.keyCode === 89) || 160 | (e.metaKey && e.shiftKey && e.keyCode === 90) 161 | ) { 162 | e.preventDefault() 163 | this.history.forwardState() 164 | } 165 | // CTRL+C/Command+C 166 | if ((e.ctrlKey && e.keyCode === 67) || (e.metaKey && e.keyCode === 67)) { 167 | e.preventDefault() 168 | this.clipboard.copy(); 169 | } 170 | // CTRL+V/Command+V 171 | if ((e.ctrlKey && e.keyCode === 86) || (e.metaKey && e.keyCode === 86)) { 172 | // e.preventDefault() // 注意:这里一定不能阻止默认事件,因为粘贴功能依赖原生的paste事件 173 | this.clipboard.paste(); 174 | } 175 | // CTRL+A/Command+A 176 | if ((e.ctrlKey && e.keyCode) === 65 || (e.metaKey && e.keyCode === 65)) { 177 | e.preventDefault() 178 | // TODO Select all 179 | } 180 | // CTRL+R/CRTRL+F等类型的事件不阻止默认事件 181 | if (e.metaKey || e.ctrlKey) { 182 | return; 183 | } 184 | 185 | /** 186 | * 由于非编辑模式下,输入中文无法正常触发原生input可编辑元素中的中文输入法模式, 187 | * 固改用利用原生的可编辑元素的input事件处理非编辑模式下直接敲击键盘可进入编辑模式, 188 | * 前提是该元素必须先获取聚焦 189 | */ 190 | // const keyHandler = k => { 191 | // if ( 192 | // (k >= 65 && k <= 90) || 193 | // (k >= 48 && k <= 57) || 194 | // (k >= 96 && k <= 107) || 195 | // (k >= 109 && k <= 111) || 196 | // k === 32 || 197 | // (k >= 186 && k <= 222) 198 | // ) { 199 | // return true; 200 | // } else { 201 | // return false; 202 | // } 203 | // }; 204 | // if (keyHandler(e.keyCode)) { 205 | // return this.startEdit(e.key); 206 | // } 207 | switch (e.keyCode) { 208 | // 左 209 | case 37: 210 | this.moveFocus("LEFT"); 211 | break; 212 | // 上 213 | case 38: 214 | this.moveFocus("TOP"); 215 | break; 216 | // 右 或 Tab 217 | case 9: 218 | case 39: 219 | this.moveFocus("RIGHT"); 220 | break; 221 | // 下 222 | case 40: 223 | this.moveFocus("BOTTOM"); 224 | break; 225 | case 8: // BackSpace/delede 226 | case 46: 227 | this.clearSelectedData() 228 | break; 229 | case 13: 230 | this.startEdit(); 231 | break; 232 | default: 233 | // 234 | } 235 | } 236 | function handleScroll(e) { 237 | e.preventDefault(); 238 | if (this.editor.show) return; 239 | const { deltaX, deltaY } = e; 240 | if (Math.abs(deltaX) > Math.abs(deltaY)) { 241 | if (this.scroller.horizontalScroller.has) { 242 | const maxWidth = this.tableWidth; 243 | if (this.scrollX - deltaX > 0) { 244 | this.scrollX = 0; 245 | } else if ( 246 | maxWidth + this.verticalScrollerSize - this.width + this.scrollX < 247 | deltaX 248 | ) { 249 | this.scrollX = this.width - maxWidth - this.verticalScrollerSize; 250 | } else { 251 | e.preventDefault(); 252 | e.returnValue = false; 253 | this.scrollX -= 2 * deltaX; 254 | } 255 | } 256 | } else { 257 | if (this.scroller.verticalScroller.has) { 258 | if (this.scrollY - deltaY > 0) { 259 | this.scrollY = 0; 260 | } else if ( 261 | this.tableHeight + 262 | this.horizontalScrollerSize - 263 | this.height + 264 | this.scrollY < 265 | deltaY 266 | ) { 267 | this.scrollY = 268 | this.height - this.tableHeight - this.horizontalScrollerSize; 269 | } else { 270 | e.preventDefault(); 271 | e.returnValue = false; 272 | this.scrollY -= 2 * deltaY; 273 | } 274 | } 275 | } 276 | this.scroller.setPosition(); 277 | } 278 | function handleResize() { 279 | this.resize(); 280 | } 281 | 282 | class Events { 283 | constructor(grid, el) { 284 | this.grid = grid; 285 | this.el = el 286 | this.isFirefox = typeof navigator !== 'undefined' && navigator.userAgent.toLowerCase().indexOf('firefox') > -1 287 | 288 | this.init() 289 | } 290 | init() { 291 | const { 292 | el, 293 | grid, 294 | isFirefox 295 | } = this 296 | // const rootEl = el.parentElement; 297 | this.eventTasks = { 298 | 'clickoutside': handleClickoutside.bind(grid), 299 | 'mousedown': handleMouseDown.bind(grid), 300 | 'mousemove': handleMouseMove.bind(grid), 301 | 'mouseup': handleMouseUp.bind(grid), 302 | 'click': handleClick.bind(grid), 303 | 'dblclick': handleDbClick.bind(grid), 304 | 'mousewheel': handleScroll.bind(grid), 305 | 'keydown': handleKeydown.bind(grid), 306 | 'resize': throttle(handleResize, 100, { 307 | context: grid 308 | }), 309 | } 310 | /** 311 | * 这里用js的方案实现Clickoutside会导致一个问题,对于select/data-picker等浮层组件, 312 | * 若其超过视窗之外,则会判断不准确,所以直接用v-clickoutside指令的方式完美替代; 313 | * ----------------------------------------------------------------------------- 314 | * 再解释这里为什么有些事件绑定在canvas上而有些绑定在window上? 315 | * mousemove/mouseup事件:因为存在一些拖拽的事件(比如调整列宽、拖动滚动条等)拥有“中间状态”, 316 | * 需要鼠标在画布之外时也保持事件执行的能力 317 | */ 318 | // bindClickoutside.call(grid, rootEl, handleClickoutside.bind(grid)) 319 | bind(el, 'mousedown', this.eventTasks.mousedown, false) 320 | bind(window, 'mousemove', this.eventTasks.mousemove, false) 321 | bind(window, 'mouseup', this.eventTasks.mouseup, false) 322 | bind(el, 'click', this.eventTasks.click, false) 323 | bind(el, 'dblclick', this.eventTasks.dblclick, false) 324 | bind(el, isFirefox ? 'DOMMouseScroll' : 'mousewheel', this.eventTasks.mousewheel, false) 325 | bind(window, 'keydown', this.eventTasks.keydown, false) // canvas元素不支持keydown事件 326 | bind(window, 'resize', this.eventTasks.resize, false) 327 | } 328 | destroy() { 329 | const { 330 | el, 331 | isFirefox 332 | } = this 333 | // const rootEl = el.parentElement; 334 | // unbindClickoutside(rootEl) 335 | unbind(el, 'mousedown', this.eventTasks.mousedown, false) 336 | unbind(window, 'mousemove', this.eventTasks.mousemove, false) 337 | unbind(window, 'mouseup', this.eventTasks.mouseup, false) 338 | unbind(el, 'click', this.eventTasks.click, false) 339 | unbind(el, 'dblclick', this.eventTasks.dblclick, false) 340 | unbind(el, isFirefox ? 'DOMMouseScroll' : 'mousewheel', this.eventTasks.mousewheel, false) 341 | unbind(window, 'keydown', this.eventTasks.keydown, false) 342 | unbind(window, 'resize', this.eventTasks.resize, false) 343 | } 344 | } 345 | export default Events; 346 | -------------------------------------------------------------------------------- /src/core/Header.js: -------------------------------------------------------------------------------- 1 | function getLeafColumnsWidth(arr) { 2 | const _arr = toLeaf(arr) 3 | const width = _arr.reduce((sum, item) => { 4 | return sum + SIZE_MAP[item.size || "mini"] 5 | }, 0) 6 | 7 | return width 8 | } 9 | /** 10 | * 根据列的索引获取列的宽度 11 | * @param {Number} index 12 | */ 13 | function getColWidthByIndex(index) { 14 | let width 15 | for (let col of this.allColumnHeaders) { 16 | if (col.index === index && col.colspan <= 1) { 17 | width = col.width 18 | break 19 | } 20 | } 21 | return width 22 | } 23 | 24 | import Context from "./Context.js"; 25 | import ColumnHeader from "./ColumnHeader.js"; 26 | import { 27 | toLeaf, 28 | } from './util.js' 29 | import { 30 | ROW_INDEX_WIDTH, 31 | MIN_CELL_WIDTH, 32 | CHECK_BOX_WIDTH, 33 | SIZE_MAP 34 | } from "./constants.js"; 35 | 36 | const oncheck = new Image(); 37 | const offcheck = new Image(); 38 | const indeterminate = new Image(); 39 | oncheck.src = require("./images/oncheck.png"); 40 | offcheck.src = require("./images/offcheck.png"); 41 | indeterminate.src = require("./images/indeterminate.png"); 42 | 43 | class Header extends Context { 44 | constructor(grid, x, y) { 45 | super(grid, x, y); 46 | this.checked = false; 47 | this.indeterminate = false 48 | this.paint() 49 | } 50 | paint() { 51 | this.allColumnHeaders = []; // 所有列 52 | this.fixedColumnHeaders = []; // 冻结列 53 | this.columnHeaders = []; // 非冻结列 54 | 55 | let columnIndex = 0 56 | const renderHeader = (arr, parent, originX) => { 57 | const len = arr.length 58 | let everyOffsetX = originX 59 | 60 | for (let i = 0; i < len; i++) { 61 | const item = arr[i]; 62 | const height = this.grid.headerHeight * (item.rowspan || 1) 63 | const y = this.grid.headerHeight * item.level 64 | let width = SIZE_MAP[item.size || "mini"]; // 读取映射宽度 65 | let fixed = '' 66 | 67 | if (item.children) { 68 | // 父级表头的宽度是叶子节点表头的宽度总和 69 | width = getLeafColumnsWidth(item.children) 70 | } 71 | if (parent) { 72 | fixed = parent.fixed 73 | } else if (i < this.grid.fixedLeft) { 74 | fixed = "left"; 75 | } else if (i > len - 1 - this.grid.fixedRight) { 76 | fixed = "right"; 77 | } 78 | item.fixed = fixed; 79 | 80 | const columnHeader = new ColumnHeader( 81 | this.grid, 82 | columnIndex, 83 | everyOffsetX, 84 | y, 85 | width, 86 | height, 87 | item 88 | ); 89 | 90 | this.allColumnHeaders.push(columnHeader); 91 | if (fixed) { 92 | this.fixedColumnHeaders.push(columnHeader); 93 | } else { 94 | this.columnHeaders.push(columnHeader); 95 | } 96 | !item.children && columnIndex ++ 97 | item.children && renderHeader(item.children, item, everyOffsetX) 98 | 99 | everyOffsetX += width 100 | } 101 | } 102 | renderHeader(this.grid.headers, null, this.grid.originFixedWidth) 103 | } 104 | // 开始调整列宽 105 | mouseDown(x, y) { 106 | if (this.resizeTarget) { 107 | this.resizeOriginalX = x; 108 | this.resizeOriginalWidth = this.resizeTarget.width; 109 | this.isResizing = true; 110 | } else { 111 | for (let col of this.allColumnHeaders) { 112 | if ( 113 | x > col.x + this.grid.scrollX && 114 | x < col.x + this.grid.scrollX + col.width && 115 | y > col.y && y < col.y + col.height 116 | ) { 117 | this.grid.selectCols(col) 118 | } 119 | } 120 | } 121 | } 122 | // 鼠标移动调整中 123 | resizing(x, y) { 124 | if (this.isResizing) { 125 | const index = this.resizeTarget.index; 126 | const resizeDiffWidth = x - this.resizeOriginalX; 127 | const newWidth = this.resizeTarget.width + resizeDiffWidth; 128 | // 滚动列最后一列或者无横向滚动,不允许调小宽度 129 | if ( 130 | (index === this.grid.columnsLength - this.grid.fixedRight - 1 || 131 | this.grid.width === this.grid.tableWidth + this.grid.verticalScrollerSize || 132 | newWidth < MIN_CELL_WIDTH) && 133 | newWidth <= this.resizeOriginalWidth 134 | ) { 135 | return; 136 | } 137 | 138 | this.grid.resizeColumn(index, resizeDiffWidth); 139 | this.resizeOriginalX = x 140 | } else { 141 | // 鼠标移动中 -> 寻找需要调整列宽的列目标 142 | this.resizeTarget = null; 143 | for (let col of this.allColumnHeaders) { 144 | if ( 145 | x > col.x + this.grid.scrollX + col.width - 4 && 146 | x < col.x + this.grid.scrollX + col.width + 4 && 147 | x < this.grid.width - this.grid.verticalScrollerSize - 4 && // 视窗中最后一列不允许调整宽 148 | col.colspan <= 1 // 父级表头不触发 149 | ) { 150 | this.grid.target.style.cursor = "col-resize"; 151 | this.resizeTarget = col; 152 | } 153 | } 154 | } 155 | } 156 | // 结束调整列宽 157 | endResize() { 158 | this.resizeTarget = null; 159 | this.isResizing = false; 160 | } 161 | handleCheck(opt) { 162 | if (opt) { 163 | this.checked = typeof opt.checked === 'boolean' ? opt.checked : this.checked 164 | this.indeterminate = typeof opt.indeterminate === 'boolean' ? opt.indeterminate : this.indeterminate 165 | } else { 166 | if (this.indeterminate) { 167 | this.indeterminate = false 168 | } else { 169 | this.checked = !this.checked 170 | } 171 | } 172 | 173 | } 174 | resizeColumn(colIndex, diffWidth) { 175 | const scrollDiffWidth = 176 | this.grid.width - 177 | this.grid.tableWidth - 178 | this.grid.verticalScrollerSize - 179 | this.grid.scrollX; 180 | 181 | for (let col of this.allColumnHeaders) { 182 | // 避免操作过快是出现断层 183 | if (scrollDiffWidth <= diffWidth) { 184 | /** 185 | * 由于存在复合表头的场景,复合表头的index和其子级表头的第一个列的index是相等的, 186 | * 更新子表头的宽度时同时也要更新其所有的父表头,所以这里就不能直接取数组的下标, 187 | * 而是用表头的index字段 188 | */ 189 | if (colIndex >= col.index && colIndex < col.index + col.colspan) { 190 | col.width += diffWidth; 191 | } 192 | 193 | // 该列之后的所有列的x轴位移需要更新 194 | if (col.index > colIndex && !(scrollDiffWidth === 0 && diffWidth <= 0)) { 195 | col.x += diffWidth; 196 | } 197 | } 198 | 199 | // 滚动到最右侧,调小列宽时只更新目标列宽和相邻下一列的x轴坐标 200 | if (scrollDiffWidth === 0 && diffWidth <= 0) { 201 | if (colIndex >= col.index && colIndex < col.index + col.colspan) { 202 | col.width += diffWidth; 203 | } 204 | if (col.index === colIndex + 1) { 205 | col.width -= diffWidth; 206 | col.x += diffWidth; 207 | } 208 | } 209 | } 210 | } 211 | resizeAllColumn(fellWidth) { 212 | let parent = { x: this.grid.originFixedWidth, width: 0, level: 0 } 213 | for (let col of this.allColumnHeaders) { 214 | col.width += fellWidth * col.colspan; 215 | if (col.level && col.level !== parent.level) { 216 | col.x = parent.x; 217 | } else { 218 | col.x = parent.x + parent.width; 219 | } 220 | parent = col 221 | } 222 | } 223 | draw() { 224 | // 滚动列阴影 225 | this.grid.painter.drawRect(this.x, this.y, this.grid.width, this.grid.tableHeaderHeight, { 226 | fillColor: "#f9f9f9", 227 | shadowBlur: 4, 228 | shadowColor: "rgba(143, 140, 140, 0.22)", 229 | shadowOffsetX: 0, 230 | shadowOffsetY: 2 231 | }); 232 | 233 | // 滚动表头 234 | for (let col of this.columnHeaders) { 235 | if (col.isVisibleOnScreen()) { 236 | col.draw(); 237 | } 238 | } 239 | 240 | // 固定列阴影 241 | if (this.grid.scrollX !== 0) { 242 | this.grid.painter.drawRect( 243 | this.x, 244 | this.y, 245 | this.grid.fixedLeftWidth, 246 | this.grid.tableHeaderHeight, 247 | { 248 | fillColor: "#f9f9f9", 249 | shadowBlur: 4, 250 | shadowColor: "rgba(143, 140, 140, 0.22)", 251 | shadowOffsetX: 2, 252 | shadowOffsetY: -2 253 | } 254 | ); 255 | } 256 | if ( 257 | this.grid.tableWidth + 258 | this.grid.verticalScrollerSize - 259 | this.grid.width + 260 | this.grid.scrollX > 261 | 0 262 | ) { 263 | this.grid.painter.drawRect( 264 | this.grid.width - this.grid.fixedRightWidth, 265 | this.y, 266 | this.grid.fixedRightWidth - this.grid.verticalScrollerSize, 267 | this.grid.tableHeaderHeight, 268 | { 269 | fillColor: "#f9f9f9", 270 | shadowBlur: 4, 271 | shadowColor: "rgba(143, 140, 140, 0.22)", 272 | shadowOffsetX: -2, 273 | shadowOffsetY: -2 274 | } 275 | ); 276 | } 277 | 278 | // 冻结表头 279 | for (let col of this.fixedColumnHeaders) { 280 | col.draw(); 281 | } 282 | 283 | // 绘制checkbox 284 | const style = { 285 | borderColor: this.grid.borderColor, 286 | borderWidth: this.grid.borderWidth, 287 | fillColor: this.grid.fillColor 288 | }; 289 | if (this.grid.showCheckbox) { 290 | const checkEl = this.checked ? 291 | (this.indeterminate ? indeterminate : oncheck) 292 | : offcheck; 293 | this.grid.painter.drawRect( 294 | ROW_INDEX_WIDTH, 295 | 0, 296 | CHECK_BOX_WIDTH, 297 | this.grid.tableHeaderHeight, 298 | style 299 | ); 300 | this.grid.painter.drawImage( 301 | checkEl, 302 | ROW_INDEX_WIDTH + (CHECK_BOX_WIDTH - 20) / 2, 303 | (this.grid.tableHeaderHeight - 20) / 2, 304 | 20, 305 | 20 306 | ); 307 | } 308 | 309 | // 最左上角方格 310 | this.grid.painter.drawRect(0, 0, ROW_INDEX_WIDTH, this.grid.tableHeaderHeight, style); 311 | } 312 | } 313 | export default Header; 314 | -------------------------------------------------------------------------------- /src/core/History.js: -------------------------------------------------------------------------------- 1 | /** 2 | * 数据历史堆栈 3 | */ 4 | class Histories { 5 | constructor(grid) { 6 | this.grid = grid; 7 | this.history = []; 8 | this.historyIndex = -1; 9 | } 10 | // 推入历史堆栈 11 | pushState(data) { 12 | this.history.push(data) 13 | if (this.history.length > 20) { 14 | this.history.splice(0, 1) 15 | } 16 | this.historyIndex = this.history.length - 1 17 | } 18 | // 回退 19 | backState() { 20 | if (this.historyIndex >= 0) { 21 | const backValue = this.history[this.historyIndex] 22 | // 单个操作 23 | if (backValue.type === 'single') { 24 | this.grid.setData(backValue.before.value, backValue.before) 25 | } else { 26 | this.grid.batchSetData(backValue.before) 27 | } 28 | this.historyIndex -= 1 29 | } 30 | } 31 | // 前进 32 | forwardState() { 33 | if (this.historyIndex < this.history.length - 1) { 34 | this.historyIndex += 1 35 | const forwardValue = this.history[this.historyIndex] 36 | // 单个操作 37 | if (forwardValue.type === 'single') { 38 | this.grid.setData(forwardValue.after.value, forwardValue.after) 39 | } else { 40 | this.grid.batchSetData(forwardValue.after) 41 | } 42 | } 43 | } 44 | } 45 | 46 | export default Histories -------------------------------------------------------------------------------- /src/core/Paint.js: -------------------------------------------------------------------------------- 1 | /** 2 | * 画笔 3 | */ 4 | const ALIGN_MAP = { 5 | left: "right", 6 | right: "left" 7 | }; 8 | 9 | // 计算文本各种对齐方式下的绘制x轴起始坐标 10 | function calucateTextAlign(value, width, padding, align) { 11 | let x = width / 2; 12 | const textWidth = this.ctx.measureText(value).width; 13 | if (textWidth > width - padding * 2 || align === "left") { 14 | x = textWidth / 2 + padding; 15 | } else if (align === "right") { 16 | x = width - textWidth / 2 - padding; 17 | } 18 | return x; 19 | } 20 | 21 | class Paint { 22 | constructor(target) { 23 | this.ctx = target.getContext("2d"); 24 | } 25 | scaleCanvas(dpr) { 26 | this.ctx.scale(dpr, dpr); // 解决高清屏canvas绘制模糊的问题 27 | } 28 | drawLine(points, options) { 29 | if (!points[0]) return; 30 | options = Object.assign( 31 | { 32 | lineCap: "square", 33 | lineJoin: "miter", 34 | borderWidth: 1, 35 | borderColor: undefined, 36 | fillColor: undefined 37 | }, 38 | options 39 | ); 40 | 41 | this.ctx.save(); 42 | this.ctx.beginPath(); 43 | this.ctx.moveTo(points[0][0] + 0.5, points[0][1] + 0.5); // + 0.5解决1px模糊的问题 44 | for (let i = 1; i < points.length; i++) { 45 | this.ctx.lineTo(points[i][0] + 0.5, points[i][1] + 0.5); 46 | } 47 | this.ctx.lineWidth = options.borderWidth; 48 | this.ctx.lineCap = options.lineCap; 49 | this.ctx.lineJoin = options.lineJoin; 50 | if (options.lineDash) { 51 | this.ctx.lineDashOffset = 4; 52 | this.ctx.setLineDash(options.lineDash); 53 | } 54 | 55 | if (options.fillColor) { 56 | this.ctx.fillStyle = options.fillColor; 57 | this.ctx.fill(); 58 | } 59 | if (options.borderColor) { 60 | this.ctx.strokeStyle = options.borderColor; 61 | this.ctx.stroke(); 62 | } 63 | this.ctx.restore(); 64 | } 65 | /** 66 | * 在指定位置渲染文本 67 | * @param {String} text 渲染的文本 68 | * @param {Number} x 渲染的x轴位置 69 | * @param {Number} y 渲染的y轴位置 70 | * @param {Object} options 71 | */ 72 | drawText(text, x, y, options) { 73 | options = Object.assign( 74 | { 75 | font: 76 | 'normal 12px "Helvetica Neue", Helvetica, "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", Arial, sans-serif', 77 | color: "#495060", 78 | align: "center", 79 | baseLine: "middle" 80 | }, 81 | options 82 | ); 83 | 84 | this.ctx.font = options.font; 85 | this.ctx.fillStyle = options.color; 86 | this.ctx.textAlign = ALIGN_MAP[options.align] || options.align; 87 | this.ctx.textBaseline = options.baseLine; 88 | this.ctx.fillText(text, x, y); 89 | } 90 | /** 91 | * 在指定宽高的盒子里渲染文本,会结合文本的长度和盒子的宽来决定对齐方式 92 | * @param {String} text 渲染的文本 93 | * @param {Number} x 渲染的初始x轴位置 94 | * @param {Number} y 渲染的初始y轴位置 95 | * @param {Number} width 盒子的宽 96 | * @param {Number} height 盒子的高 97 | * @param {Number} padding 左右边距 98 | * @param {Object} options color, font, align... 99 | */ 100 | drawCellText(text, x, y, width, height, padding, options) { 101 | options = Object.assign( 102 | { 103 | font: 104 | 'normal 12px "Helvetica Neue", Helvetica, "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", Arial, sans-serif', 105 | color: "#495060", 106 | align: "left", 107 | baseLine: "middle", 108 | icon: null, 109 | iconWidth: 14, 110 | iconHeight: 14, 111 | iconOffsetX: 0, 112 | iconOffsetY: 0 113 | }, 114 | options 115 | ); 116 | 117 | this.ctx.font = options.font; 118 | this.ctx.fillStyle = options.color; 119 | this.ctx.textAlign = "center"; 120 | this.ctx.textBaseline = options.baseLine; 121 | // font会影响measureText获取的值 122 | let posX = x 123 | const posY = options.baseLine === 'middle' ? y + height / 2 : y 124 | const startOffset = calucateTextAlign.call( 125 | this, 126 | text, 127 | width, 128 | padding, 129 | options.align 130 | ); 131 | if (options.icon && typeof options.icon === 'object' && options.icon.src) { 132 | // 绘制日历控件小图标,固定在单元格左侧 133 | this.drawImage( 134 | options.icon, 135 | x + options.iconOffsetX, 136 | posY - options.iconOffsetY - options.iconHeight / 2, 137 | options.iconWidth, 138 | options.iconHeight 139 | ); 140 | // 如果有图标而且是左对齐,那么渲染文本x轴坐标需要增加,给左侧图标留空间 141 | if (options.align === 'left') { 142 | posX += 2 * options.iconWidth 143 | } 144 | } 145 | this.ctx.fillText(text, posX + startOffset, posY); 146 | this.ctx.restore() 147 | } 148 | // 在指定宽度的单元格尾部渲染一个图标 149 | drawCellAffixIcon(icon, x, y, width, height, options) { 150 | options = Object.assign( 151 | { 152 | color: '#bbbec4', 153 | fillColor: '#fff' 154 | }, 155 | options 156 | ); 157 | const rightIconPadding = 25 158 | this.drawRect(x + width - rightIconPadding, y + 1, rightIconPadding, height, { 159 | fillColor: options.fillColor 160 | }) 161 | if (icon === 'arrow') { 162 | const points = [ 163 | [x + width - 20, y + height / 2 - 2], 164 | [x + width - 15, y + height / 2 + 3], 165 | [x + width - 10, y + height / 2 - 2] 166 | ]; 167 | this.drawLine(points, { 168 | borderColor: options.color, 169 | borderWidth: 1 170 | }); 171 | } 172 | } 173 | /** 174 | * 在文本前指定距离的位置渲染一个图标 175 | * @param {String} label 支持字符串图标或者实体字符 176 | * @param {String} text 指定文本 177 | * @param {Number} x 178 | * @param {Number} y 179 | * @param {Number} width 180 | * @param {Number} padding 181 | * @param {Number} offsetX 横坐标位移 182 | * @param {Number} offsetY 纵坐标位移 183 | * @param {Object} options 184 | */ 185 | drawIcon(label, text, x, y, width, padding, offsetX, offsetY, options) { 186 | options = Object.assign( 187 | { 188 | font: 189 | 'normal 12px "Helvetica Neue", Helvetica, "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", Arial, sans-serif', 190 | color: "#495060", 191 | align: "center", 192 | baseLine: "middle" 193 | }, 194 | options 195 | ); 196 | const textWidth = this.ctx.measureText(text).width; 197 | const startOffset = calucateTextAlign.call( 198 | this, 199 | text, 200 | width, 201 | padding, 202 | options.align 203 | ); 204 | 205 | this.ctx.font = options.font; 206 | this.ctx.fillStyle = options.color; 207 | this.ctx.textAlign = "center"; 208 | this.ctx.textBaseline = options.baseLine; 209 | this.ctx.fillText(label, x + startOffset - textWidth / 2 - offsetX, y + offsetY); 210 | this.ctx.restore() 211 | } 212 | // 绘制矩形 213 | drawRect(x, y, width, height, options) { 214 | options = Object.assign( 215 | { 216 | borderWidth: 1, 217 | borderColor: undefined, 218 | fillColor: undefined, 219 | shadowOffsetX: undefined, 220 | shadowOffsetY: undefined, 221 | shadowBlur: 0, 222 | shadowColor: undefined 223 | }, 224 | options 225 | ); 226 | this.ctx.save(); 227 | this.ctx.beginPath(); 228 | 229 | if (options.shadowOffsetX) { 230 | this.ctx.shadowOffsetX = options.shadowOffsetX; 231 | } 232 | if (options.shadowOffsetY) { 233 | this.ctx.shadowOffsetY = options.shadowOffsetY; 234 | } 235 | if (options.shadowBlur) { 236 | this.ctx.shadowBlur = options.shadowBlur; 237 | } 238 | if (options.shadowColor) { 239 | this.ctx.shadowColor = options.shadowColor; 240 | } 241 | 242 | // 填充颜色 243 | if (options.fillColor) { 244 | this.ctx.fillStyle = options.fillColor; 245 | } 246 | 247 | // 线条宽度及绘制颜色 248 | if (options.borderColor) { 249 | this.ctx.lineWidth = options.borderWidth; 250 | this.ctx.strokeStyle = options.borderColor; 251 | } 252 | 253 | // 绘制矩形路径,+ 0.5解决1px模糊的问题 254 | this.ctx.rect(x + 0.5, y + 0.5, width, height); 255 | 256 | // 如果有填充色,则填充 257 | if (options.fillColor) { 258 | this.ctx.fill(); 259 | } 260 | 261 | // 如果有绘制色,则绘制 262 | if (options.borderColor) { 263 | this.ctx.stroke(); 264 | } 265 | this.ctx.restore(); 266 | } 267 | // 绘制圆角矩形路径 268 | drawRoundRect(x, y, width, height, raidus, options) { 269 | options = Object.assign( 270 | { 271 | borderWidth: 1, 272 | borderColor: undefined, 273 | fillColor: undefined, 274 | shadowOffsetX: undefined, 275 | shadowOffsetY: undefined, 276 | shadowBlur: 0, 277 | shadowColor: undefined 278 | }, 279 | options 280 | ); 281 | this.ctx.save(); 282 | this.ctx.beginPath(); 283 | 284 | if (options.shadowOffsetX) { 285 | this.ctx.shadowOffsetX = options.shadowOffsetX; 286 | } 287 | if (options.shadowOffsetY) { 288 | this.ctx.shadowOffsetY = options.shadowOffsetY; 289 | } 290 | if (options.shadowBlur) { 291 | this.ctx.shadowBlur = options.shadowBlur; 292 | } 293 | if (options.shadowColor) { 294 | this.ctx.shadowColor = options.shadowColor; 295 | } 296 | 297 | // 填充颜色 298 | if (options.fillColor) { 299 | this.ctx.fillStyle = options.fillColor; 300 | } 301 | 302 | // 线条宽度及绘制颜色 303 | if (options.borderColor) { 304 | this.ctx.lineWidth = options.borderWidth; 305 | this.ctx.strokeStyle = options.borderColor; 306 | } 307 | 308 | this.ctx.moveTo(x + raidus, y); 309 | this.ctx.arcTo(x + width, y, x + width, y + raidus, raidus); // draw right side and bottom right corner 310 | this.ctx.arcTo( 311 | x + width, 312 | y + height, 313 | x + width - raidus, 314 | y + height, 315 | raidus 316 | ); // draw bottom and bottom left corner 317 | this.ctx.arcTo(x, y + height, x, y + height - raidus, raidus); // draw left and top left corner 318 | this.ctx.arcTo(x, y, x + raidus, y, raidus); 319 | 320 | // this.ctx.moveTo(x+raidus, y); 321 | // this.ctx.arcTo(x+width, y, x+width, y+height, raidus); 322 | // this.ctx.arcTo(x+width, y+height, x, y+height, raidus); 323 | // this.ctx.arcTo(x, y+height, x, y, raidus); 324 | // this.ctx.arcTo(x, y, x+width, y, raidus); 325 | 326 | // 如果有填充色,则填充 327 | if (options.fillColor) { 328 | this.ctx.fill(); 329 | } 330 | 331 | // 如果有绘制色,则绘制 332 | if (options.borderColor) { 333 | this.ctx.stroke(); 334 | } 335 | this.ctx.restore(); 336 | } 337 | drawImage(img, x, y, width, height) { 338 | this.ctx.drawImage(img, x, y, width, height); 339 | } 340 | clearCanvas(x = 0, y = 0, width, height) { 341 | this.ctx.clearRect( 342 | x, 343 | y, 344 | width || this.ctx.canvas.width, 345 | height || this.ctx.canvas.height 346 | ); 347 | } 348 | /** 349 | * 计算长文本自动换行 350 | * @param {String} text 文本 351 | * @param {Number} width 单元格宽度 352 | * @param {Number} padding 左右需要留出的padding 353 | */ 354 | getTextWrapping(text, width, padding) { 355 | if (!text && text !== 0) { 356 | return ''; 357 | } 358 | this.ctx.font = 359 | 'normal 12px "Helvetica Neue", Helvetica, "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", Arial, sans-serif'; 360 | const chr = `${text}`.split(""); 361 | let temp = chr[0]; 362 | const arr = []; 363 | 364 | for (let i = 1; i < chr.length; i++) { 365 | if (this.ctx.measureText(temp).width >= width - padding * 2) { 366 | arr.push(temp); 367 | temp = ""; 368 | } 369 | temp += chr[i]; 370 | } 371 | arr.push(temp); 372 | 373 | return arr; 374 | } 375 | } 376 | 377 | export default Paint; 378 | -------------------------------------------------------------------------------- /src/core/Row.js: -------------------------------------------------------------------------------- 1 | import RowHeader from "./RowHeader.js"; 2 | import Cell from "./Cell.js"; 3 | import Context from "./Context.js"; 4 | import { 5 | ROW_INDEX_WIDTH, 6 | SIZE_MAP 7 | } from "./constants"; 8 | 9 | class Row extends Context { 10 | constructor(grid, rowIndex, x, y, height, data) { 11 | super(grid, x, y, null, height); 12 | 13 | this.data = data; 14 | this.rowIndex = rowIndex; 15 | this.checked = false; 16 | 17 | this.allCells = []; 18 | this.fixedCells = []; 19 | this.cells = []; 20 | 21 | const style = { 22 | color: this.grid.color, 23 | fillColor: this.grid.fillColor, 24 | borderColor: this.grid.borderColor, 25 | borderWidth: this.grid.borderWidth 26 | }; 27 | this.rowHeader = new RowHeader( 28 | grid, 29 | rowIndex, 30 | x, 31 | y, 32 | ROW_INDEX_WIDTH, 33 | height, 34 | style 35 | ); 36 | 37 | // cells对象集合 38 | let everyOffsetX = grid.originFixedWidth; 39 | 40 | for (let i = 0; i < this.grid.columnsLength; i++) { 41 | const column = this.grid.columns[i]; 42 | const width = SIZE_MAP[column.size || "mini"]; 43 | let fixed = ""; 44 | 45 | if (i < this.grid.fixedLeft) { 46 | fixed = "left"; 47 | } else if (i > this.grid.columnsLength - 1 - this.grid.fixedRight) { 48 | fixed = "right"; 49 | } 50 | column.fixed = fixed 51 | 52 | const cell = new Cell( 53 | data[column.key], 54 | data[column.label], 55 | grid, 56 | i, 57 | rowIndex, 58 | everyOffsetX, 59 | y, 60 | width, 61 | this.height, 62 | column, 63 | data, 64 | style 65 | ); 66 | 67 | this.allCells.push(cell); 68 | if (fixed) { 69 | this.fixedCells.push(cell); 70 | } else { 71 | this.cells.push(cell); 72 | } 73 | 74 | everyOffsetX += width; 75 | } 76 | } 77 | // 鼠标枞坐标是否位于焦点单元格所在的autofill触点范围内 78 | isInVerticalAutofill(mouseX, mouseY) { 79 | return ( 80 | this.grid.autofill.yIndex === this.rowIndex && 81 | mouseY > this.y + this.grid.scrollY + this.height - 4 && 82 | mouseY < this.y + this.height + this.grid.scrollY + 4 83 | ); 84 | } 85 | handleCheck(checked) { 86 | this.checked = typeof checked === 'boolean' ? checked : !this.checked; 87 | this.rowHeader.handleCheck(this.checked); 88 | } 89 | // 选中单个单元格 90 | mouseDown(x, y) { 91 | // 如果位于autofill触点上则不执行发选择单元格 92 | const cell = this.allCells[this.grid.autofill.xIndex]; 93 | if (cell && ( 94 | cell.isInHorizontalAutofill(x, y) || 95 | cell.isInsideFixedHorizontalAutofill(x, y) 96 | )) { 97 | return 98 | } 99 | 100 | for (let i = 0; i < this.allCells.length; i++) { 101 | const cell = this.allCells[i]; 102 | if (cell.dataType === 'select' && cell.isInsideAffixIcon(x, y)) { 103 | this.grid.selectCell(cell); 104 | // 选择和编辑同时触发会导致切换下拉单元格的时候下拉浮层延迟消失 105 | setTimeout(() => { 106 | this.grid.startEdit(); 107 | }, 0) 108 | } else if ( 109 | cell.isInsideHorizontalBodyBoundary(x, y) || 110 | cell.isInsideFixedHorizontalBodyBoundary(x, y) 111 | ) { 112 | this.grid.selectCell(cell); 113 | } 114 | } 115 | } 116 | // 批量选中单元格时移动批量选取 117 | mouseMove(mouseX, mouseY) { 118 | for (let i = 0; i < this.allCells.length; i++) { 119 | const cell = this.allCells[i]; 120 | if ( 121 | cell.isInsideHorizontalTableBoundary(mouseX, mouseY) || 122 | cell.isInsideFixedHorizontalBodyBoundary(mouseX, mouseY) 123 | ) { 124 | const { colIndex, rowIndex, x, y, width, height, valid, message, fixed } = cell; 125 | this.grid.multiSelectCell(colIndex, rowIndex, mouseX, mouseY); 126 | 127 | // 显示单元格tooltip校验失败提示文案 128 | this.grid.tooltip.update({ 129 | valid, 130 | message, 131 | x, 132 | y, 133 | colWidth: width, 134 | colHeight: height, 135 | fixed 136 | }); 137 | 138 | if (cell.dataType === 'select' && cell.isInsideAffixIcon(mouseX, mouseY)) { 139 | this.grid.target.style.cursor = "pointer"; 140 | } 141 | } 142 | } 143 | } 144 | // 寻找autofill触点 145 | handleAutofill(x, y) { 146 | const cell = this.allCells[this.grid.autofill.xIndex]; 147 | if (!cell) return; 148 | if ( 149 | cell.isInHorizontalAutofill(x, y) || 150 | cell.isInsideFixedHorizontalAutofill(x, y) 151 | ) { 152 | this.grid.target.style.cursor = "crosshair"; 153 | } 154 | } 155 | // 单击autofill触点开始拖拽 156 | handleStartAutofill(x, y) { 157 | const cell = this.allCells[this.grid.autofill.xIndex]; 158 | if (!cell) return; 159 | if ( 160 | cell.isInHorizontalAutofill(x, y) || 161 | cell.isInsideFixedHorizontalAutofill(x, y) 162 | ) { 163 | this.grid.startAutofill(); 164 | } 165 | } 166 | click(x, y) { 167 | if (this.rowHeader.isInsideCheckboxBoundary(x, y)) { 168 | this.handleCheck(); 169 | // body部分勾选状态发生变化,需要影响到表头的indeterminate状态 170 | this.grid.handleCheckHeader() 171 | } else if (this.rowHeader.isInsideIndexBoundary(x, y)) { 172 | this.grid.selectRows(this) 173 | } 174 | } 175 | dbClick(x, y) { 176 | for (let i = 0; i < this.allCells.length; i++) { 177 | const cell = this.allCells[i]; 178 | // 仅当鼠标坐标位于body内的单元格之内时才会触发编辑模式 179 | if ( 180 | cell.isInsideHorizontalBodyBoundary(x, y) || 181 | cell.isInsideFixedHorizontalBodyBoundary(x, y) 182 | ) { 183 | this.grid.startEdit(); 184 | } 185 | } 186 | } 187 | resizeColumn(colIndex, diffWidth) { 188 | const scrollDiffWidth = 189 | this.grid.width - 190 | this.grid.tableWidth - 191 | this.grid.verticalScrollerSize - 192 | this.grid.scrollX; 193 | 194 | const cell = this.allCells[colIndex]; 195 | if (scrollDiffWidth <= diffWidth) { 196 | cell.width += diffWidth; 197 | 198 | // 避免操作过快是出现断层 199 | for (let i = colIndex + 1; i < this.grid.columnsLength; i++) { 200 | this.allCells[i].x += diffWidth; 201 | } 202 | } 203 | 204 | // 滚动到最右侧,调小列宽时只更新目标列宽和相邻下一列的x轴坐标 205 | if (scrollDiffWidth === 0 && diffWidth <= 0) { 206 | cell.width += diffWidth; 207 | this.allCells[colIndex + 1].width -= diffWidth; 208 | this.allCells[colIndex + 1].x += diffWidth; 209 | } 210 | } 211 | rePaint() { 212 | for (let i = 0; i < this.cells.length; i++) { 213 | const cell = this.cells[i]; 214 | cell.height = this.height; 215 | cell.y = this.y; 216 | } 217 | for (let i = 0; i < this.fixedCells.length; i++) { 218 | const cell = this.fixedCells[i]; 219 | cell.height = this.height; 220 | cell.y = this.y; 221 | } 222 | 223 | this.rowHeader.height = this.height; 224 | this.rowHeader.y = this.y; 225 | } 226 | draw() { 227 | // 绘制主体body部分 228 | for (let i = 0; i < this.cells.length; i++) { 229 | const cell = this.cells[i]; 230 | if (cell.isHorizontalVisibleOnBody()) { 231 | cell.draw(); 232 | } 233 | } 234 | // 固定列阴影 235 | if (this.grid.scrollX !== 0) { 236 | this.grid.painter.drawRect( 237 | this.x + this.grid.originFixedWidth, 238 | this.y + this.grid.scrollY, 239 | this.grid.fixedLeftWidth - this.grid.originFixedWidth, 240 | this.height, 241 | { 242 | fillColor: "#f9f9f9", 243 | shadowBlur: 4, 244 | shadowColor: "rgba(143, 140, 140, 0.22)", 245 | shadowOffsetX: 2, 246 | shadowOffsetY: 2 247 | } 248 | ); 249 | } 250 | if ( 251 | this.grid.tableWidth + 252 | this.grid.verticalScrollerSize - 253 | this.grid.width + 254 | this.grid.scrollX > 255 | 0 256 | ) { 257 | this.grid.painter.drawRect( 258 | this.grid.width - this.grid.fixedRightWidth, 259 | this.y + this.grid.scrollY, 260 | this.grid.fixedRightWidth - this.grid.verticalScrollerSize, 261 | this.height, 262 | { 263 | fillColor: "#f9f9f9", 264 | shadowBlur: 4, 265 | shadowColor: "rgba(143, 140, 140, 0.22)", 266 | shadowOffsetX: -2, 267 | shadowOffsetY: 2 268 | } 269 | ); 270 | } 271 | 272 | // 左右冻结列 273 | for (let i = 0; i < this.fixedCells.length; i++) { 274 | const cell = this.fixedCells[i]; 275 | cell.draw(); 276 | } 277 | 278 | // 绘制每行索引及勾选框 279 | this.rowHeader.draw(); 280 | } 281 | } 282 | 283 | export default Row; 284 | -------------------------------------------------------------------------------- /src/core/RowHeader.js: -------------------------------------------------------------------------------- 1 | import Context from "./Context.js"; 2 | import { 3 | CHECK_BOX_WIDTH, 4 | SELECT_BORDER_COLOR, 5 | SELECT_BG_COLOR 6 | } from "./constants.js"; 7 | 8 | const oncheck = new Image(); 9 | const offcheck = new Image(); 10 | oncheck.src = require("./images/oncheck.png"); 11 | offcheck.src = require("./images/offcheck.png"); 12 | 13 | class RowHeader extends Context { 14 | constructor(grid, rowIndex, x, y, width, height, options) { 15 | super(grid, x, y, width, height); 16 | 17 | this.rowIndex = rowIndex; 18 | this.text = rowIndex + 1; 19 | this.checked = false; 20 | 21 | Object.assign(this, options); 22 | } 23 | handleCheck(val) { 24 | this.checked = val; 25 | } 26 | draw() { 27 | const y = this.y + this.grid.scrollY; 28 | const editor = this.grid.editor; 29 | const selector = this.grid.selector; 30 | 31 | // 绘制checkbox 32 | if (this.grid.showCheckbox) { 33 | const checkEl = this.checked ? oncheck : offcheck; 34 | this.grid.painter.drawRect(this.width, y, CHECK_BOX_WIDTH, this.height, { 35 | borderColor: this.borderColor, 36 | fillColor: this.fillColor, 37 | borderWidth: this.borderWidth 38 | }); 39 | this.grid.painter.drawImage( 40 | checkEl, 41 | this.width + (CHECK_BOX_WIDTH - 20) / 2, 42 | y + (this.height - 20) / 2, 43 | 20, 44 | 20 45 | ); 46 | } 47 | 48 | // 绘制每行的索引的边框 49 | this.grid.painter.drawRect(this.x, y, this.width, this.height, { 50 | fillColor: this.fillColor, 51 | // borderColor: this.borderColor, 52 | borderWidth: this.borderWidth 53 | }); 54 | // 绘制每行的索引 55 | this.grid.painter.drawCellText( 56 | this.text, 57 | this.x, 58 | y, 59 | this.width, 60 | this.height, 61 | 10, 62 | { 63 | color: this.color, 64 | align: 'center' 65 | } 66 | ); 67 | 68 | /** 69 | * 焦点高亮 70 | */ 71 | // 背景色 72 | if (selector.show || editor.show) { 73 | const minY = selector.yArr[0]; 74 | const maxY = selector.yArr[1]; 75 | 76 | if (this.rowIndex >= minY && this.rowIndex <= maxY) { 77 | this.grid.painter.drawRect( 78 | this.x, 79 | y, 80 | this.width + this.grid.checkboxWidth, 81 | this.height, 82 | { 83 | fillColor: SELECT_BG_COLOR 84 | } 85 | ); 86 | } 87 | 88 | // 线 89 | if (this.rowIndex >= minY && this.rowIndex <= maxY) { 90 | const points = [ 91 | [this.width + this.grid.checkboxWidth, y], 92 | [this.width + this.grid.checkboxWidth, y + this.height] 93 | ]; 94 | this.grid.painter.drawLine(points, { 95 | borderColor: SELECT_BORDER_COLOR, 96 | borderWidth: 2 97 | }); 98 | } 99 | } 100 | } 101 | } 102 | 103 | export default RowHeader; 104 | -------------------------------------------------------------------------------- /src/core/Scroller.js: -------------------------------------------------------------------------------- 1 | import { 2 | SCROLLER_SIZE, 3 | SCROLLER_TRACK_SIZE, 4 | SCROLLER_COLOR, 5 | SCROLLER_FOCUS_COLOR, 6 | SELECT_BORDER_COLOR 7 | } from "./constants.js"; 8 | 9 | class Scroller { 10 | constructor(grid) { 11 | this.grid = grid; 12 | this.horizontalScroller = { 13 | x: 0, // 滚动条位移 14 | move: false, // 是否开始滚动中 15 | focus: false, // 是否获得焦点 16 | size: 0, // 滚动滑块的尺寸 17 | ratio: 1, // 画布实际滚动的位移和滚动条实际滚动的位移之比 18 | has: false // 是否有滚动条 19 | }; 20 | this.verticalScroller = { 21 | y: 0, 22 | move: false, 23 | focus: false, // 是否获得焦点 24 | size: 0, 25 | ratio: 1, 26 | has: false 27 | }; 28 | } 29 | /** 30 | * 初始化滚动条配置 31 | */ 32 | reset() { 33 | const { 34 | width, 35 | height, 36 | tableWidth, 37 | tableHeight, 38 | originFixedWidth, 39 | tableHeaderHeight, 40 | verticalScrollerSize, 41 | horizontalScrollerSize 42 | } = this.grid; 43 | const viewWidth = width - originFixedWidth - verticalScrollerSize; 44 | const viewHeight = height - tableHeaderHeight - horizontalScrollerSize; 45 | const horizontalRatio = viewWidth / tableWidth; 46 | const verticalRatio = viewHeight / tableHeight; 47 | if (horizontalRatio >= 1) { 48 | this.horizontalScroller.size = 0; 49 | } else { 50 | this.horizontalScroller.size = parseInt(horizontalRatio * viewWidth); 51 | } 52 | if (verticalRatio >= 1) { 53 | this.verticalScroller.size = 0; 54 | } else { 55 | this.verticalScroller.size = parseInt(verticalRatio * viewHeight); 56 | } 57 | if (this.horizontalScroller.size < 30) { 58 | this.horizontalScroller.size = 30; 59 | } 60 | if (this.verticalScroller.size < 30) { 61 | this.verticalScroller.size = 30; 62 | } 63 | 64 | this.horizontalScroller.has = !(tableWidth <= width - SCROLLER_TRACK_SIZE); 65 | this.verticalScroller.has = !(tableHeight <= height - SCROLLER_TRACK_SIZE); 66 | 67 | // 计算滚动距离的比例 68 | this.horizontalScroller.ratio = this.horizontalScroller.has 69 | ? (width - 70 | originFixedWidth - 71 | SCROLLER_TRACK_SIZE - 72 | this.horizontalScroller.size - 73 | SCROLLER_SIZE) / 74 | (tableWidth + SCROLLER_TRACK_SIZE - width) 75 | : 0; 76 | this.verticalScroller.ratio = this.verticalScroller.has 77 | ? (height - 78 | tableHeaderHeight - 79 | SCROLLER_TRACK_SIZE - 80 | this.verticalScroller.size - 81 | SCROLLER_SIZE) / 82 | (tableHeight + SCROLLER_TRACK_SIZE - height) 83 | : 0; 84 | } 85 | update(diff, dir) { 86 | if (dir === "HORIZONTAL") { 87 | this.grid.scrollX += diff; 88 | } else if (dir === "VERTICAL") { 89 | this.grid.scrollY += diff; 90 | } 91 | this.setPosition(); 92 | } 93 | setPosition() { 94 | this.horizontalScroller.x = -parseInt( 95 | this.grid.scrollX * this.horizontalScroller.ratio 96 | ); 97 | this.verticalScroller.y = -parseInt( 98 | this.grid.scrollY * this.verticalScroller.ratio 99 | ); 100 | } 101 | // 鼠标位移是否在滚动轨道范围区域内 102 | isInsideHorizontalScroller(mouseX, mouseY) { 103 | return ( 104 | mouseX >= this.grid.originFixedWidth && 105 | mouseX <= this.grid.width - SCROLLER_TRACK_SIZE && 106 | mouseY > this.grid.height - SCROLLER_TRACK_SIZE && 107 | mouseY < this.grid.height - (SCROLLER_TRACK_SIZE - SCROLLER_SIZE) / 2 108 | ); 109 | } 110 | // 鼠标位移是否在滚动滑块范围区域内 111 | isInsideHorizontalScrollerBar(mouseX, mouseY) { 112 | return ( 113 | this.horizontalScroller.has && 114 | mouseX >= this.grid.originFixedWidth + this.horizontalScroller.x && 115 | mouseX <= 116 | this.grid.originFixedWidth + 117 | this.horizontalScroller.x + 118 | this.horizontalScroller.size + 119 | SCROLLER_SIZE && 120 | mouseY > this.grid.height - SCROLLER_TRACK_SIZE && 121 | mouseY < this.grid.height 122 | ); 123 | } 124 | isInsideVerticalScroller(mouseX, mouseY) { 125 | return ( 126 | mouseX > this.grid.width - SCROLLER_TRACK_SIZE && 127 | mouseX < this.grid.width - (SCROLLER_TRACK_SIZE - SCROLLER_SIZE) / 2 && 128 | mouseY > this.grid.tableHeaderHeight && 129 | mouseY < this.grid.height - SCROLLER_TRACK_SIZE 130 | ); 131 | } 132 | isInsideVerticalScrollerBar(mouseX, mouseY) { 133 | return ( 134 | this.verticalScroller.has && 135 | mouseX > this.grid.width - SCROLLER_TRACK_SIZE && 136 | mouseX < this.grid.width && 137 | mouseY > this.grid.tableHeaderHeight + this.verticalScroller.y && 138 | mouseY < 139 | this.grid.tableHeaderHeight + 140 | this.verticalScroller.y + 141 | this.verticalScroller.size + 142 | SCROLLER_SIZE 143 | ); 144 | } 145 | mouseDown(x, y) { 146 | if (this.isInsideHorizontalScrollerBar(x, y)) { 147 | this.mouseOriginalX = x; 148 | this.horizontalScroller.move = true; 149 | } else if (this.isInsideVerticalScrollerBar(x, y)) { 150 | this.mouseOriginalY = y; 151 | this.verticalScroller.move = true; 152 | } 153 | } 154 | mouseMove(x, y) { 155 | this.horizontalScroller.focus = this.isInsideHorizontalScroller(x, y) 156 | ? true 157 | : false; 158 | this.verticalScroller.focus = this.isInsideVerticalScroller(x, y) 159 | ? true 160 | : false; 161 | if (this.grid.editor.show) return; 162 | if (this.horizontalScroller.move) { 163 | const diffX = x - this.mouseOriginalX; 164 | const movedX = this.horizontalScroller.x + diffX; 165 | const trachWidth = 166 | this.grid.width - 167 | this.grid.originFixedWidth - 168 | this.horizontalScroller.size - 169 | SCROLLER_TRACK_SIZE - 170 | SCROLLER_SIZE; 171 | 172 | if (movedX > 0 && movedX < trachWidth) { 173 | this.horizontalScroller.x += diffX; 174 | this.grid.scrollX = 175 | -this.horizontalScroller.x / this.horizontalScroller.ratio; 176 | } else if (movedX <= 0) { 177 | this.horizontalScroller.x = 0; 178 | this.grid.scrollX = 0; 179 | } else { 180 | this.horizontalScroller.x = trachWidth; 181 | this.grid.scrollX = 182 | this.grid.width - this.grid.tableWidth - SCROLLER_TRACK_SIZE; 183 | } 184 | this.mouseOriginalX = x; 185 | } else if (this.verticalScroller.move) { 186 | const diffY = y - this.mouseOriginalY; 187 | const movedY = this.verticalScroller.y + diffY; 188 | const trackHeight = 189 | this.grid.height - 190 | this.grid.tableHeaderHeight - 191 | this.verticalScroller.size - 192 | SCROLLER_TRACK_SIZE - 193 | SCROLLER_SIZE; 194 | if (movedY > 0 && movedY < trackHeight) { 195 | this.verticalScroller.y = movedY; 196 | this.grid.scrollY = 197 | -this.verticalScroller.y / this.verticalScroller.ratio; 198 | } else if (movedY <= 0) { 199 | this.verticalScroller.y = 0; 200 | this.grid.scrollY = 0; 201 | } else { 202 | this.verticalScroller.y = trackHeight; 203 | this.grid.scrollY = 204 | this.grid.height - this.grid.tableHeight - SCROLLER_TRACK_SIZE; 205 | } 206 | this.mouseOriginalY = y; 207 | } 208 | } 209 | mouseUp() { 210 | this.horizontalScroller.move = false; 211 | this.verticalScroller.move = false; 212 | } 213 | draw() { 214 | const scrollerWidth = this.grid.width - SCROLLER_TRACK_SIZE; 215 | const scrollerHeight = this.grid.height - SCROLLER_TRACK_SIZE; 216 | const trackOffset = SCROLLER_TRACK_SIZE / 2; 217 | const thumbOffset = SCROLLER_SIZE / 2; 218 | 219 | // 横向滚动条 220 | // 轨道 221 | this.grid.painter.drawRect( 222 | 0, 223 | scrollerHeight, 224 | scrollerWidth + SCROLLER_TRACK_SIZE, 225 | SCROLLER_TRACK_SIZE, 226 | { 227 | fillColor: this.grid.fillColor, 228 | borderColor: this.grid.borderColor, 229 | borderWidth: this.grid.borderWidth 230 | } 231 | ); 232 | // 滑块起始位置线条 233 | this.grid.painter.drawLine( 234 | [ 235 | [this.grid.originFixedWidth, scrollerHeight], 236 | [this.grid.originFixedWidth, scrollerHeight + SCROLLER_TRACK_SIZE] 237 | ], 238 | { 239 | borderColor: this.grid.borderColor, 240 | borderWidth: this.grid.borderWidth 241 | } 242 | ); 243 | // 滑块结束位置线条 244 | this.grid.painter.drawLine( 245 | [ 246 | [scrollerWidth, scrollerHeight], 247 | [scrollerWidth, scrollerHeight + SCROLLER_TRACK_SIZE] 248 | ], 249 | { 250 | borderColor: this.grid.borderColor, 251 | borderWidth: this.grid.borderWidth 252 | } 253 | ); 254 | // 滑块 255 | if (this.horizontalScroller.has) { 256 | this.grid.painter.drawLine( 257 | [ 258 | [ 259 | this.grid.originFixedWidth + 260 | this.horizontalScroller.x + 261 | thumbOffset, 262 | scrollerHeight + trackOffset 263 | ], 264 | [ 265 | this.grid.originFixedWidth + 266 | this.horizontalScroller.x + 267 | thumbOffset + 268 | this.horizontalScroller.size, 269 | scrollerHeight + trackOffset 270 | ] 271 | ], 272 | { 273 | borderColor: 274 | this.horizontalScroller.move || this.horizontalScroller.focus 275 | ? SCROLLER_FOCUS_COLOR 276 | : SCROLLER_COLOR, 277 | borderWidth: SCROLLER_SIZE, 278 | lineCap: "round" 279 | } 280 | ); 281 | } 282 | 283 | // 纵向滚动条 284 | this.grid.painter.drawRect( 285 | scrollerWidth, 286 | 0, 287 | SCROLLER_TRACK_SIZE, 288 | scrollerHeight, 289 | { 290 | fillColor: this.grid.fillColor, 291 | borderColor: this.grid.borderColor, 292 | borderWidth: this.grid.borderWidth 293 | } 294 | ); 295 | // 滑块起始位置线条 296 | this.grid.painter.drawLine( 297 | [ 298 | [scrollerWidth, this.grid.tableHeaderHeight], 299 | [scrollerWidth + SCROLLER_TRACK_SIZE, this.grid.tableHeaderHeight] 300 | ], 301 | { 302 | borderColor: this.grid.borderColor, 303 | borderWidth: this.grid.borderWidth 304 | } 305 | ); 306 | // 滑块 307 | if (this.verticalScroller.has) { 308 | this.grid.painter.drawLine( 309 | [ 310 | [ 311 | scrollerWidth + trackOffset, 312 | this.verticalScroller.y + thumbOffset + this.grid.tableHeaderHeight 313 | ], 314 | [ 315 | scrollerWidth + trackOffset, 316 | this.verticalScroller.y + 317 | thumbOffset + 318 | this.grid.tableHeaderHeight + 319 | this.verticalScroller.size 320 | ] 321 | ], 322 | { 323 | borderColor: 324 | this.verticalScroller.move || this.verticalScroller.focus 325 | ? SCROLLER_FOCUS_COLOR 326 | : SCROLLER_COLOR, 327 | borderWidth: SCROLLER_SIZE, 328 | lineCap: "round" 329 | } 330 | ); 331 | } 332 | // 弥补最右侧autofull触点 333 | if (this.grid.selector.show) { 334 | const { 335 | range, 336 | height, 337 | width, 338 | tableWidth, 339 | tableHeight, 340 | scrollX, 341 | scrollY, 342 | painter, 343 | selector, 344 | autofill, 345 | verticalScrollerSize 346 | } = this.grid 347 | const cell = this.grid.getCell(autofill.xIndex, autofill.yIndex) 348 | const x = 349 | cell.fixed === "right" 350 | ? width - 351 | (tableWidth - cell.x - cell.width) - 352 | cell.width - 353 | verticalScrollerSize 354 | : cell.fixed === "left" 355 | ? cell.x 356 | : cell.x + scrollX; 357 | const y = cell.y + scrollY; 358 | const minX = selector.xArr[0]; 359 | const maxX = selector.xArr[1]; 360 | const minY = selector.yArr[0]; 361 | const maxY = selector.yArr[1]; 362 | const autofill_width = 6; 363 | const autofillSty = { 364 | borderColor: "#fff", 365 | borderWidth: 2, 366 | fillColor: SELECT_BORDER_COLOR 367 | } 368 | 369 | // 最右侧 370 | if (maxX === range.maxX && (!cell.fixed && width === tableWidth + scrollX + SCROLLER_TRACK_SIZE || cell.fixed === 'right')) { 371 | const minCell = this.grid.getCell(maxX, minY) 372 | const maxCell = this.grid.getCell(maxX, maxY) 373 | const diffY = maxCell.y - minCell.y 374 | const points = [ 375 | [scrollerWidth, minCell.y + scrollY], 376 | [scrollerWidth, minCell.y + diffY + maxCell.height + scrollY] 377 | ]; 378 | painter.drawLine(points, { 379 | borderColor: SELECT_BORDER_COLOR, 380 | borderWidth: 2 381 | }); 382 | painter.drawRect( 383 | scrollerWidth - 3, 384 | y + cell.height - 3, 385 | autofill_width, 386 | autofill_width, 387 | autofillSty 388 | ); 389 | } 390 | // 最下面 391 | if (maxY === range.maxY && height === tableHeight + scrollY + SCROLLER_TRACK_SIZE) { 392 | const minCell = this.grid.getCell(minX, maxY) 393 | const maxCell = this.grid.getCell(maxX, maxY) 394 | const diffX = maxCell.x - minCell.x 395 | const points = [ 396 | [minCell.x + scrollX, scrollerHeight], 397 | [minCell.x + diffX + maxCell.width + scrollX, scrollerHeight] 398 | ]; 399 | painter.drawLine(points, { 400 | borderColor: SELECT_BORDER_COLOR, 401 | borderWidth: 2 402 | }); 403 | painter.drawRect( 404 | x + cell.width - 3, 405 | scrollerHeight - 3, 406 | autofill_width, 407 | autofill_width, 408 | autofillSty 409 | ); 410 | } 411 | } 412 | } 413 | } 414 | 415 | export default Scroller; 416 | -------------------------------------------------------------------------------- /src/core/Selector.js: -------------------------------------------------------------------------------- 1 | import { h } from "./element.js"; 2 | import { CSS_PREFIX } from "./constants.js"; 3 | 4 | class Selector { 5 | constructor() { 6 | this.cornerEl = h("div", `${CSS_PREFIX}-selector-corner`); 7 | this.areaEl = h("div", `${CSS_PREFIX}-selector-wrap`) 8 | .child(this.cornerEl) 9 | .hide(); 10 | this.clipboardEl = h("div", `${CSS_PREFIX}-selector-clipboard`).hide(); 11 | this.autofillEl = h("div", `${CSS_PREFIX}-selector-autofill`).hide(); 12 | this.el = h("div", `${CSS_PREFIX}-selector`) 13 | .children(this.areaEl, this.clipboardEl, this.autofillEl) 14 | .hide(); 15 | } 16 | } 17 | 18 | export default Selector; 19 | -------------------------------------------------------------------------------- /src/core/Tooltip.js: -------------------------------------------------------------------------------- 1 | import { ERROR_TIP_COLOR } from "./constants.js"; 2 | 3 | class Tooltip { 4 | constructor(grid, x, y, message, type) { 5 | this.grid = grid; 6 | this.x = x; 7 | this.y = y; 8 | this.width = 200; 9 | this.height = 90; 10 | this.valid = true; 11 | this.message = message; 12 | } 13 | update(value) { 14 | Object.assign(this, value); 15 | } 16 | draw() { 17 | const { 18 | selector, 19 | scrollX, 20 | scrollY, 21 | width, 22 | height, 23 | tableWidth, 24 | verticalScrollerSize, 25 | color, 26 | painter 27 | } = this.grid 28 | if (!this.valid && !selector.isSelected) { 29 | const poX = this.x + this.colWidth + 2 30 | const poY = this.y + 1 31 | const isBeyondHorizontalView = poX + this.width + scrollX > width // tooltip浮层是否超过水平可视区 32 | const isBeyondVerticalView = poY + this.height + scrollY > height // tooltip浮层是否超过垂直可视区 33 | let x = isBeyondHorizontalView 34 | ? this.fixed === 'right' 35 | ? width - 36 | (tableWidth - this.x - this.colWidth) - 37 | this.colWidth - 38 | verticalScrollerSize - 39 | this.width - 40 | 1 41 | : this.x - this.width - 1 42 | : poX; 43 | if (!this.fixed) { 44 | x += scrollX; 45 | } 46 | let y = isBeyondVerticalView 47 | ? this.y - (this.height - this.colHeight) - 1 48 | : this.y 49 | y += scrollY; 50 | painter.drawRoundRect(x, y, this.width, this.height, 4, { 51 | shadowBlur: 16, 52 | shadowColor: "rgba(28,36,56,0.16)", 53 | shadowOffsetX: 0, 54 | shadowOffsetY: 0, 55 | fillColor: "#fff", 56 | borderWidth: 1 57 | }); 58 | 59 | painter.drawCellText("数据错误", x, y + 24, this.width, this.height, 16, { 60 | font: 61 | 'bold 14px "Helvetica Neue", Helvetica, "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", Arial, sans-serif', 62 | color: ERROR_TIP_COLOR, 63 | align: "left", 64 | baseLine: "bottom" 65 | }); 66 | 67 | const textArr = painter.getTextWrapping( 68 | this.message, 69 | this.width, 70 | 16 71 | ); 72 | let _y = y + 50; 73 | for (let i = 0; i < textArr.length; i++) { 74 | painter.drawCellText( 75 | textArr[i], 76 | x, 77 | _y + i * 18, 78 | this.width, 79 | this.height, 80 | 16, 81 | { 82 | color, 83 | align: "left", 84 | baseLine: "bottom" 85 | } 86 | ); 87 | } 88 | } 89 | } 90 | } 91 | 92 | export default Tooltip; 93 | -------------------------------------------------------------------------------- /src/core/Validator.js: -------------------------------------------------------------------------------- 1 | const rules = { 2 | number: /^(-?\d{1,11}(\.\d*)?)$/, 3 | phone: /^[1-9]\d{10}$/, 4 | email: /w+([-+.]w+)*@w+([-.]w+)*.w+([-.]w+)*/ 5 | }; 6 | function parseValue(v) { 7 | const { type } = this; 8 | if (type === "date") { 9 | return new Date(v); 10 | } 11 | if (type === "number") { 12 | return Number(v); 13 | } 14 | return v; 15 | } 16 | function getValidation(flag, key) { 17 | if (this.message) { 18 | return { flag, message: this.message }; 19 | } 20 | let message = ""; 21 | if (!flag) { 22 | switch (key) { 23 | case "required": 24 | message = `${this.validateTitle}字段必填哦!`; 25 | break; 26 | case "notMatch": 27 | message = `${this.validateTitle}字段不符合预期格式哦!`; 28 | break; 29 | case "notIn": 30 | message = `${this.validateTitle}字段是无效值哦!`; 31 | break; 32 | default: 33 | message = `${this.validateTitle}字段是无效值哦!`; 34 | } 35 | } 36 | return { flag, message }; 37 | } 38 | 39 | class Validator { 40 | constructor(column) { 41 | /** 42 | * type: month|date|datetime|number|phone|email|select 数据格式类型 43 | * required 是否必填 44 | * validator: RegExp|Function 校验器 45 | * message 校验失败提示文案 46 | * validateKey 单元格key 47 | * validateTitle 单元格title 48 | * options 数据格式为type时的枚举数据 49 | */ 50 | this.validateKey = column.key; 51 | this.validateTitle = column.title; 52 | this.type = column.type; 53 | this.options = column.options; 54 | Object.assign(this, column.rule); 55 | } 56 | async validate(v, row) { 57 | const self = this 58 | const { required, validator, operator, options, type, descriptor } = this; 59 | 60 | // 必填校验不通过,不再进行后续的校验 61 | let requiredValid = typeof v === 'string' ? !!v.trim() : !!v || v === 0 62 | if (required && !requiredValid) { 63 | return getValidation.call(this, false, "required"); 64 | } 65 | // 空值不参与下面的校验 66 | if (!requiredValid) return { flag: true }; 67 | 68 | if (rules[type] && !rules[type].test(v)) { 69 | return getValidation.call(this, false, "notMatch"); 70 | } 71 | // 下拉校验值必须存在于枚举中 72 | if (type === "select") { 73 | const flag = options.map(item => item.value).includes(v); 74 | if (!flag) return getValidation.call(this, flag, "notMatch"); 75 | } 76 | if (type === "month" || type === "date" || type === "datetime") { 77 | const flag = isNaN(v) && !isNaN(Date.parse(v)); 78 | if (!flag) return getValidation.call(this, flag, "notMatch"); 79 | } 80 | 81 | if (validator instanceof RegExp) { 82 | const pattern = new RegExp(validator); 83 | return getValidation.call(this, pattern.test(v), "notIn"); 84 | } else if (typeof validator === "function") { 85 | let flag = true 86 | // 这里处理异步校验函数 87 | await validator(v, row, (res) => { 88 | if (typeof res === 'string') { 89 | self.message = res 90 | flag = !res 91 | } else if (res === false) { 92 | flag = false 93 | } 94 | }) 95 | return getValidation.call(this, flag, "notIn"); 96 | } 97 | 98 | // if (operator) { 99 | // const v1 = parseValue.call(this, v); 100 | // if (operator === 'be') { 101 | // const [min, max] = value; 102 | // return getValidation.call(this, 103 | // v1 >= parseValue.call(this, min) && v1 <= parseValue.call(this, max), 104 | // 'between', 105 | // min, 106 | // max, 107 | // ); 108 | // } 109 | // if (operator === 'nbe') { 110 | // const [min, max] = value; 111 | // return getValidation.call(this, 112 | // v1 < parseValue.call(this, min) || v1 > parseValue.call(this, max), 113 | // 'notBetween', 114 | // min, 115 | // max, 116 | // ); 117 | // } 118 | // if (operator === 'eq') { 119 | // return getValidation.call(this, 120 | // v1 === parseValue.call(this, value), 121 | // 'equal', 122 | // value, 123 | // ); 124 | // } 125 | // if (operator === 'neq') { 126 | // return getValidation.call(this, 127 | // v1 !== parseValue.call(this, value), 128 | // 'notEqual', 129 | // value, 130 | // ); 131 | // } 132 | // if (operator === 'lt') { 133 | // return getValidation.call(this, 134 | // v1 < parseValue.call(this, value), 135 | // 'lessThan', 136 | // value, 137 | // ); 138 | // } 139 | // if (operator === 'lte') { 140 | // return getValidation.call(this, 141 | // v1 <= parseValue.call(this, value), 142 | // 'lessThanEqual', 143 | // value, 144 | // ); 145 | // } 146 | // if (operator === 'gt') { 147 | // return getValidation.call(this, 148 | // v1 > parseValue.call(this, value), 149 | // 'greaterThan', 150 | // value, 151 | // ); 152 | // } 153 | // if (operator === 'gte') { 154 | // return getValidation.call(this, 155 | // v1 >= parseValue.call(this, value), 156 | // 'greaterThanEqual', 157 | // value, 158 | // ); 159 | // } 160 | // } 161 | return { flag: true }; 162 | } 163 | } 164 | 165 | export default Validator; 166 | -------------------------------------------------------------------------------- /src/core/config.js: -------------------------------------------------------------------------------- 1 | export const dpr = window.devicePixelRatio || 1; 2 | export default { 3 | dpr 4 | }; 5 | -------------------------------------------------------------------------------- /src/core/constants.js: -------------------------------------------------------------------------------- 1 | export const CSS_PREFIX = "xs-data-grid"; 2 | 3 | export const HEADER_HEIGHT = 36; // 表头行高 4 | 5 | export const ROW_INDEX_WIDTH = 36; // 索引列宽 6 | export const CHECK_BOX_WIDTH = 36; // 勾选框列宽 7 | 8 | export const CELL_HEIGHT = 36; // 表格body部分的行高 9 | export const MIN_CELL_WIDTH = 100; // 表格body部分的最小列宽 10 | export const MIN_CELL_HEIGHT = 36; // 表格body部分的最小行高 11 | 12 | export const SCROLLER_TRACK_SIZE = 16; // 滚动条轨道尺寸 13 | export const SCROLLER_SIZE = 8; // 滚动条滑块尺寸 14 | export const SCROLLER_COLOR = "#dee0e3"; // 滚动条滑块颜色 15 | export const SCROLLER_FOCUS_COLOR = "#bbbec4"; // 滚动条滑块聚焦时的颜色 16 | 17 | export const SELECT_BORDER_COLOR = "rgb(82,146,247)"; // 选中区域边框颜色 18 | export const SELECT_AREA_COLOR = "rgba(82,146,247,0.1)"; // 选中区域背景颜色 19 | export const SELECT_BG_COLOR = "rgba(82,146,247,0.1)"; // 当前焦点单元格所在行、列的背景色 20 | 21 | export const READONLY_COLOR = "#f8f8f9"; // 单元格只读背景色 22 | export const READONLY_TEXT_COLOR = "#80848f"; // 单元格只读文本颜色 23 | export const ERROR_TIP_COLOR = "#ED3F14"; // 单元格错误提示文本颜色 24 | 25 | export const SIZE_MAP = { // 尺寸枚举映射 26 | mini: 100, 27 | small: 140, 28 | medium: 200, 29 | large: 300 30 | }; 31 | 32 | export const VALIDATOR_TYPES = [ // 校验类型枚举映射 33 | 'month', 34 | 'date', 35 | 'datetime', 36 | 'number', 37 | 'phone', 38 | 'email', 39 | 'select' 40 | ] -------------------------------------------------------------------------------- /src/core/element.js: -------------------------------------------------------------------------------- 1 | class Element { 2 | constructor(tag, className = "") { 3 | if (typeof tag === "string") { 4 | this.el = document.createElement(tag); 5 | this.el.className = className; 6 | } else { 7 | this.el = tag; 8 | } 9 | this.data = {}; 10 | } 11 | 12 | data(key, value) { 13 | if (value !== undefined) { 14 | this.data[key] = value; 15 | return this; 16 | } 17 | return this.data[key]; 18 | } 19 | 20 | on(eventNames, handler) { 21 | const [fen, ...oen] = eventNames.split("."); 22 | let eventName = fen; 23 | if ( 24 | eventName === "mousewheel" && 25 | /Firefox/i.test(window.navigator.userAgent) 26 | ) { 27 | eventName = "DOMMouseScroll"; 28 | } 29 | this.el.addEventListener(eventName, evt => { 30 | handler(evt); 31 | for (let i = 0; i < oen.length; i += 1) { 32 | const k = oen[i]; 33 | if (k === "left" && evt.button !== 0) { 34 | return; 35 | } 36 | if (k === "right" && evt.button !== 2) { 37 | return; 38 | } 39 | if (k === "stop") { 40 | evt.stopPropagation(); 41 | } 42 | } 43 | }); 44 | return this; 45 | } 46 | 47 | offset(value) { 48 | if (value !== undefined) { 49 | Object.keys(value).forEach(k => { 50 | this.css(k, `${value[k]}px`); 51 | }); 52 | return this; 53 | } 54 | const { offsetTop, offsetLeft, offsetHeight, offsetWidth } = this.el; 55 | return { 56 | top: offsetTop, 57 | left: offsetLeft, 58 | height: offsetHeight, 59 | width: offsetWidth 60 | }; 61 | } 62 | 63 | scroll(v) { 64 | const { el } = this; 65 | if (v !== undefined) { 66 | if (v.left !== undefined) { 67 | el.scrollLeft = v.left; 68 | } 69 | if (v.top !== undefined) { 70 | el.scrollTop = v.top; 71 | } 72 | } 73 | return { left: el.scrollLeft, top: el.scrollTop }; 74 | } 75 | 76 | box() { 77 | return this.el.getBoundingClientRect(); 78 | } 79 | 80 | parent() { 81 | return new Element(this.el.parentNode); 82 | } 83 | 84 | children(...eles) { 85 | if (arguments.length === 0) { 86 | return this.el.childNodes; 87 | } 88 | eles.forEach(ele => this.child(ele)); 89 | return this; 90 | } 91 | 92 | removeChild(el) { 93 | this.el.removeChild(el); 94 | } 95 | 96 | child(arg) { 97 | let ele = arg; 98 | if (typeof arg === "string") { 99 | ele = document.createTextNode(arg); 100 | } else if (arg instanceof Element) { 101 | ele = arg.el; 102 | } 103 | this.el.appendChild(ele); 104 | return this; 105 | } 106 | 107 | contains(ele) { 108 | return this.el.contains(ele); 109 | } 110 | 111 | className(v) { 112 | if (v !== undefined) { 113 | this.el.className = v; 114 | return this; 115 | } 116 | return this.el.className; 117 | } 118 | 119 | addClass(name) { 120 | this.el.classList.add(name); 121 | return this; 122 | } 123 | 124 | hasClass(name) { 125 | return this.el.classList.contains(name); 126 | } 127 | 128 | removeClass(name) { 129 | this.el.classList.remove(name); 130 | return this; 131 | } 132 | 133 | toggle(cls = "active") { 134 | return this.toggleClass(cls); 135 | } 136 | 137 | toggleClass(name) { 138 | return this.el.classList.toggle(name); 139 | } 140 | 141 | active(flag = true, cls = "active") { 142 | if (flag) this.addClass(cls); 143 | else this.removeClass(cls); 144 | return this; 145 | } 146 | 147 | checked(flag = true) { 148 | this.active(flag, "checked"); 149 | return this; 150 | } 151 | 152 | disabled(flag = true) { 153 | if (flag) this.addClass("disabled"); 154 | else this.removeClass("disabled"); 155 | return this; 156 | } 157 | 158 | // key, value 159 | // key 160 | // {k, v}... 161 | attr(key, value) { 162 | if (value !== undefined) { 163 | this.el.setAttribute(key, value); 164 | } else { 165 | if (typeof key === "string") { 166 | return this.el.getAttribute(key); 167 | } 168 | Object.keys(key).forEach(k => { 169 | this.el.setAttribute(k, key[k]); 170 | }); 171 | } 172 | return this; 173 | } 174 | 175 | removeAttr(key) { 176 | this.el.removeAttribute(key); 177 | return this; 178 | } 179 | 180 | html(content) { 181 | if (content !== undefined) { 182 | this.el.innerHTML = content; 183 | return this; 184 | } 185 | return this.el.innerHTML; 186 | } 187 | 188 | val(v) { 189 | if (v !== undefined) { 190 | this.el.value = v; 191 | return this; 192 | } 193 | return this.el.value; 194 | } 195 | 196 | focus() { 197 | if (window.getSelection) { 198 | // ie11 10 9 ff safari 199 | this.el.focus(); // 解决ff不获取焦点无法定位问题 200 | const range = window.getSelection(); // 创建range 201 | range.selectAllChildren(this.el); // range 选择obj下所有子内容 202 | range.collapseToEnd(); // 光标移至最后 203 | } else if (document.selection) { 204 | // ie10以下 205 | const range = document.selection.createRange(); // 创建选择对象 206 | // var range = document.body.createTextRange(); 207 | range.moveToElementText(this.el); // range定位到obj 208 | range.collapse(false); // 光标移至最后 209 | range.select(); 210 | } 211 | } 212 | 213 | cssRemoveKeys(...keys) { 214 | keys.forEach(k => this.el.style.removeProperty(k)); 215 | return this; 216 | } 217 | 218 | // css( propertyName ) 219 | // css( propertyName, value ) 220 | // css( properties ) 221 | css(name, value) { 222 | if (value === undefined && typeof name !== "string") { 223 | Object.keys(name).forEach(k => { 224 | this.el.style[k] = name[k]; 225 | }); 226 | return this; 227 | } 228 | if (value !== undefined) { 229 | this.el.style[name] = value; 230 | return this; 231 | } 232 | return this.el.style[name]; 233 | } 234 | 235 | computedStyle() { 236 | return window.getComputedStyle(this.el, null); 237 | } 238 | 239 | show() { 240 | this.css("display", "block"); 241 | return this; 242 | } 243 | 244 | hide() { 245 | this.css("display", "none"); 246 | return this; 247 | } 248 | } 249 | 250 | const h = (tag, className = "") => new Element(tag, className); 251 | 252 | export { Element, h }; 253 | -------------------------------------------------------------------------------- /src/core/images/date.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jakever/canvas-spreadsheet/ab7e3e7f94ca464610fdf4736641fd3bc8a53dc3/src/core/images/date.png -------------------------------------------------------------------------------- /src/core/images/indeterminate.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jakever/canvas-spreadsheet/ab7e3e7f94ca464610fdf4736641fd3bc8a53dc3/src/core/images/indeterminate.png -------------------------------------------------------------------------------- /src/core/images/offcheck.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jakever/canvas-spreadsheet/ab7e3e7f94ca464610fdf4736641fd3bc8a53dc3/src/core/images/offcheck.png -------------------------------------------------------------------------------- /src/core/images/oncheck.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jakever/canvas-spreadsheet/ab7e3e7f94ca464610fdf4736641fd3bc8a53dc3/src/core/images/oncheck.png -------------------------------------------------------------------------------- /src/core/images/time.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jakever/canvas-spreadsheet/ab7e3e7f94ca464610fdf4736641fd3bc8a53dc3/src/core/images/time.png -------------------------------------------------------------------------------- /src/core/util.js: -------------------------------------------------------------------------------- 1 | const alpha = "ABCDEFGHIJKLMNOPQRSTUVWXYZ"; //0 = A, 25 = Z 2 | 3 | const iToA = i => { 4 | let current = i; 5 | 6 | let a = ""; 7 | 8 | while (current > -1) { 9 | let digit = current % 26; 10 | a = alpha[digit] + "" + a; 11 | 12 | //This is not a straight number base conversion, we need to 13 | //treat A as 14 | current = Math.floor(current / 26) - 1; 15 | } 16 | 17 | return a; 18 | }; 19 | 20 | const aToI = a => { 21 | let index = (alpha.indexOf(a[0]) + 1) * Math.pow(26, a.length - 1) - 1; 22 | 23 | for (let i = a.length - 1; i > 0; i--) { 24 | index += (alpha.indexOf(a[i]) + 1) * Math.pow(26, a.length - i - 1); 25 | } 26 | 27 | return index; 28 | }; 29 | 30 | /* 31 | ** 获取叶子节点数组 32 | */ 33 | const toLeaf = (arr = []) => { 34 | let tmp = [] 35 | arr.forEach(item => { 36 | if (item.children) { 37 | tmp = tmp.concat(toLeaf(item.children)) 38 | } else { 39 | tmp.push(item) 40 | } 41 | }) 42 | return tmp 43 | } 44 | /* 45 | ** 获取最大深度 46 | */ 47 | const getMaxRow = (config) => { 48 | if (config) { 49 | return config.map(item => { 50 | return getMaxRow(item.children) + 1 51 | }).sort((a, b) => b - a)[0] 52 | } else { 53 | return 0 54 | } 55 | } 56 | /* 57 | ** 根据数据结结构层级关系计算复合表头的跨行、跨列数 58 | */ 59 | const calCrossSpan = (arr = [], maxRow, level = 0) => { 60 | if (maxRow === undefined) { 61 | maxRow = getMaxRow(arr) 62 | } 63 | 64 | if (arr) { 65 | return arr.map((config) => { 66 | if (config.children) { 67 | let colspan = 0 68 | const children = calCrossSpan(config.children, maxRow - 1, level + 1) 69 | 70 | children.forEach((item) => { 71 | colspan += item.colspan 72 | }) 73 | 74 | return { 75 | level, 76 | rowspan: 1, 77 | colspan, 78 | ...config, 79 | children 80 | } 81 | } else { 82 | return { 83 | level, 84 | rowspan: maxRow, 85 | colspan: 1, 86 | ...config 87 | } 88 | } 89 | }) 90 | } 91 | } 92 | 93 | export { 94 | toLeaf, 95 | getMaxRow, 96 | calCrossSpan 97 | } -------------------------------------------------------------------------------- /src/main.js: -------------------------------------------------------------------------------- 1 | import Vue from "vue"; 2 | import App from "./App.vue"; 3 | import './components/Icon' 4 | import "./plugins/element.js"; 5 | 6 | Vue.config.productionTip = false; 7 | 8 | new Vue({ 9 | render: h => h(App) 10 | }).$mount("#app"); 11 | -------------------------------------------------------------------------------- /src/plugins/element.js: -------------------------------------------------------------------------------- 1 | import Vue from "vue"; 2 | import { 3 | Button, 4 | DatePicker, 5 | Select, 6 | Option, 7 | Cascader, 8 | Dropdown, 9 | DropdownMenu, 10 | DropdownItem, 11 | Pagination, 12 | Popover, 13 | loading, 14 | Form, 15 | FormItem 16 | } from "element-ui"; 17 | 18 | Vue.use(Button); 19 | Vue.use(DatePicker); 20 | Vue.use(Select); 21 | Vue.use(Option); 22 | Vue.use(Cascader); 23 | Vue.use(Dropdown); 24 | Vue.use(DropdownMenu); 25 | Vue.use(DropdownItem); 26 | Vue.use(Pagination); 27 | Vue.use(Popover); 28 | Vue.use(Form); 29 | Vue.use(FormItem); 30 | Vue.use(loading); 31 | 32 | // Vue.directive('loading', loading) 33 | -------------------------------------------------------------------------------- /tests/unit/example.spec.js: -------------------------------------------------------------------------------- 1 | import { expect } from "chai"; 2 | import { shallowMount } from "@vue/test-utils"; 3 | import HelloWorld from "@/components/HelloWorld.vue"; 4 | 5 | describe("HelloWorld.vue", () => { 6 | it("renders props.msg when passed", () => { 7 | const msg = "new message"; 8 | const wrapper = shallowMount(HelloWorld, { 9 | propsData: { msg } 10 | }); 11 | expect(wrapper.text()).to.include(msg); 12 | }); 13 | }); 14 | -------------------------------------------------------------------------------- /vue.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | lintOnSave: false, 3 | publicPath: process.env.NODE_ENV === 'production' 4 | ? '/canvas-spreadsheet/' 5 | : '/' 6 | }; 7 | --------------------------------------------------------------------------------