├── .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 | 
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 |
37 |
44 |
45 |
218 | ```
219 | ### Example for Composite header(复合表头)
220 | 
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 |
2 |
3 |
4 |
5 | 获取数据
6 | 更新行数据
7 | 获取选中行数据
8 | 获取已改变行数据
11 | 获取校验结果
14 | 设置校验结果
17 | 清空校验结果
20 |
21 | {{ !isFullscreen ? "全屏" : "退出全屏" }}
22 |
23 |
24 |
35 |
36 |
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 |
2 |
6 |
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 |
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 |
2 |
3 |
4 |
8 |
13 |
14 |
23 |
37 |
38 |
52 |
53 |
67 |
68 |
80 |
86 |
87 |
97 |
98 |
99 |
100 |
101 |
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 |
--------------------------------------------------------------------------------