├── .babelrc ├── .editorconfig ├── .eslintignore ├── .eslintrc.js ├── .gitignore ├── .npmrc ├── CHANGELOG.md ├── README.md ├── deploy.sh ├── index.html ├── package.json ├── screenshots ├── 版本1效果图.png ├── 版本1架构图.png ├── 重构1-效果图.png └── 重构1-渲染原理图.png ├── src ├── abstract │ ├── base.ts │ ├── canvas.ts │ ├── range-base.ts │ └── shape-base.ts ├── config │ ├── alphabet.ts │ └── engineoption.ts ├── engine.d.ts ├── engine.ts ├── event │ ├── event.ts │ ├── event-emiiter.ts │ └── index.ts ├── index.d.ts ├── index.less ├── index.ts ├── interface │ ├── canvas.ts │ ├── engine.ts │ └── index.ts ├── model │ ├── command.ts │ ├── mdata.ts │ └── vdata.ts ├── type │ └── index.ts ├── utils │ ├── canvas-util │ │ ├── draw.ts │ │ ├── offscreen.ts │ │ └── text.ts │ ├── dom-util │ │ └── index.ts │ ├── index.ts │ ├── is-type.ts │ ├── log.js │ └── util.ts └── view │ ├── editor │ └── index.ts │ ├── index.ts │ ├── rangeman │ ├── fixedheader-range.ts │ ├── grid-range.ts │ ├── index.ts │ ├── style-range.ts │ └── text-range.ts │ ├── render │ ├── canvas.ts │ └── dom.ts │ ├── scrollbar │ └── index.ts │ ├── selector │ └── index.ts │ └── toolbar │ └── index.ts ├── tsconfig.json └── webpack.config.js /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": [ 3 | "@babel/preset-env", 4 | [ 5 | "@babel/preset-typescript", 6 | { 7 | "isTSX": true, 8 | "allExtensions": true, 9 | "jsxPragma": "h" 10 | } 11 | ] 12 | ], 13 | "plugins": [ 14 | "@babel/plugin-proposal-class-properties", 15 | "@babel/plugin-transform-runtime" 16 | ] 17 | } -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # EditorConfig is awesome: http://EditorConfig.org 2 | 3 | # top-most EditorConfig file 4 | root = true 5 | 6 | [*.md] 7 | trim_trailing_whitespace = false 8 | 9 | [*.js] 10 | trim_trailing_whitespace = true 11 | 12 | # Unix-style newlines with a newline ending every file 13 | [*] 14 | indent_style = space 15 | indent_size = 4 16 | # 保证在任何操作系统上都有统一的行尾结束字符 17 | end_of_line = lf 18 | charset = utf-8 19 | insert_final_newline = true 20 | max_line_length = 100 -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | bugs/* 2 | -viewdata 3 | dist/* 4 | example/* 5 | webpack.*.js -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | root: true, 3 | extends: ['plugin:@typescript-eslint/recommended'], 4 | plugins: ['@typescript-eslint'], 5 | parser: '@typescript-eslint/parser', 6 | rules: { 7 | indent: ['error', 4], // 保存代码时缩进4个空格 8 | 'no-multi-spaces': ['error', {ignoreEOLComments: true}], 9 | }, 10 | }; 11 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | dist 3 | bugs 4 | -viewdata 5 | .idea 6 | .record -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | registry = https://registry.npm.taobao.org -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | 遵循 [Semantic Versioning 2.0.0](http://semver.org/lang/zh-CN/) 语义化版本规范。 2 | 3 | #### 发布周期 4 | 5 | - 修订版本号:bugfix(当你做了不兼容的 API 修改) 6 | - 次版本号: 新功能 (当你做了向下兼容的功能性新增) 7 | - 主版本号:新特性 (当你做了向下兼容的问题修正) 8 | 9 | icon说明: 10 | 🏆 里程碑 11 | 🐞 fix 12 | 🌟 亮点 13 | 💄 主题色 14 | 🚮 移除 15 | 🆕 新增 16 | 🛠 属性、api 17 | ⚡️ 提升性能 18 | 🛠 重构 19 | 🔧 修改配置文件 20 | 21 | --- 22 | ## 0.6.3 23 | `2021-04-19` 24 | - 🌟 提升表格清晰度: Retina高清屏适配 25 | - 🐞滚动bugfix 26 | - 滚动联带选中框的交互fix 27 | - 滚动联带索引栏hover的小方块交互fix 28 | - 滚动时有特殊行高时渲染行数丢失fix 29 | - 🐞编辑bugfix 30 | - 编辑时选中的单元格可通过键盘键移动 31 | - 编辑时清除单元格为空无法清除 32 | 33 | ## 0.6.0 34 | - 坐标网格渲染 35 | - 行列伸缩 36 | - 鼠标移动到行列表头时,当前行or列高亮 37 | - mousemove时,有辅助线跟随 38 | - 行列伸缩的附加影响:当前行列面积改变、命中的选中框面积改变 39 | - 滚动 40 | - 滚动的触发:鼠标向下拖动滚动条、鼠标滚轮mousewheel 41 | - 表头索引栏固定不动 42 | - 表格内容滚动 43 | - 命中的单元格跟随滚动 44 | - 选中框 45 | - 单击单元实现单元格选中 46 | - 单击单元格,并mousemove,实现单元格多选 47 | - 鼠标上下左右键,实现单元格移动 48 | - 滚动时候,命中的选中框实现跟随滚动 49 | - 单元格文字渲染处理(换行、溢出) 50 | - 文字不超出单元格宽高 51 | - 行列伸缩时,文字要重绘,以适应单元格大小 52 | - 编辑框 53 | - 双击单元格进入编辑状态,该单元格可输入文字 54 | - viewdata 55 | - 所有与表格实际意义有关的数据抽象在viewdata里,改变方法也在viewdata里 56 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # web excel 2 | 3 | [在线预览](https://emilyyoung71415.github.io/web-excel/index.html) 4 | 5 | 6 | 7 | 运行 8 | === 9 | 10 | ```bash 11 | npm i 12 | npm start 13 | ``` 14 | 15 | 说明 16 | === 17 | 18 | 功能: 19 | 20 | - [x] 坐标网格渲染 21 | - [x] 行列伸缩 22 | - [x] 滚动 23 | - [x] 单元格文字渲染处理(换行、溢出省略、居中) 24 | - [x] 选中框 25 | - [x] 编辑框 26 | - [x] 单元格属性更改: 字体大小、字体加粗、字体颜色、单元格背景色 27 | - [ ] 合并单元格 28 | - [ ] 复制粘贴 29 | - [ ] 撤销前进 30 | 31 | ⭐️ feature 32 | - 全面拥抱TS 33 | - 计算缓存 34 | - 单元格更新支持局部渲染 35 | - 更细致的分层:event(aciton) -> command -> datamodel -> viewdata -> render 36 | - 渲染机制更新:canvas声明式更新、dom命令式更新 37 | - 模块松耦 38 | - 事件系统 39 | - 开放view注册:registerview 40 | 41 | 使用 42 | === 43 | 44 | 直接在浏览器里引用 45 | 46 | ```html 47 | 48 | 49 |
50 |
51 |
52 | 107 | 108 | 109 | ``` 110 | 111 | 原理概览 112 | === 113 | 114 | -------------------------------------------------------------------------------- /deploy.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env sh 2 | # 发生错误时退出 3 | set -e 4 | # 打包 5 | npm run build 6 | # 进入dist目录 将dist目录的代码提交到 gh-pages分支 7 | cd dist 8 | git init 9 | git add -A 10 | git commit -m 'deploy' 11 | git push -f https://github.com/EmilyYoung71415/web-excel.git master:gh-pages 12 | cd - -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Document 8 | 9 | 10 |
11 | 79 | 80 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "x-web-excel", 3 | "version": "1.0.0", 4 | "description": "", 5 | "main": "src/index.ts", 6 | "scripts": { 7 | "start": "npm run dev", 8 | "build": "cross-env NODE_ENV=prod webpack", 9 | "dev": "cross-env NODE_ENV=dev webpack serve --open", 10 | "deploy": "sh deploy.sh" 11 | }, 12 | "keywords": [ 13 | "javascript", 14 | "excel" 15 | ], 16 | "author": "EmilyYoung71415 ", 17 | "license": "MIT", 18 | "repository": { 19 | "type": "git", 20 | "url": "https://github.com/EmilyYoung71415/web-excel" 21 | }, 22 | "devDependencies": { 23 | "cross-env": "^7.0.3", 24 | "css-loader": "^5.2.6", 25 | "html-webpack-plugin": "^5.3.1", 26 | "less": "^4.1.1", 27 | "less-loader": "^9.0.0", 28 | "mini-css-extract-plugin": "^1.6.0", 29 | "style-loader": "^2.0.0", 30 | "ts-loader": "^9.2.3", 31 | "typescript": "^4.3.2", 32 | "webpack": "^5.38.1", 33 | "webpack-cli": "^4.7.2", 34 | "webpack-dev-server": "^3.11.2" 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /screenshots/版本1效果图.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/EmilyYoung71415/web-excel/942e941136ae680fda4148de29aefdd6cd5a65c0/screenshots/版本1效果图.png -------------------------------------------------------------------------------- /screenshots/版本1架构图.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/EmilyYoung71415/web-excel/942e941136ae680fda4148de29aefdd6cd5a65c0/screenshots/版本1架构图.png -------------------------------------------------------------------------------- /screenshots/重构1-效果图.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/EmilyYoung71415/web-excel/942e941136ae680fda4148de29aefdd6cd5a65c0/screenshots/重构1-效果图.png -------------------------------------------------------------------------------- /screenshots/重构1-渲染原理图.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/EmilyYoung71415/web-excel/942e941136ae680fda4148de29aefdd6cd5a65c0/screenshots/重构1-渲染原理图.png -------------------------------------------------------------------------------- /src/abstract/base.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @file 基本类 3 | * - cfg属性挂在_cfg上 4 | * - 通过this.get(xx) 来读取this._cfg.xx 5 | * - this.set同理 6 | */ 7 | import { isObj, _merge } from '../utils'; 8 | import { LooseObject } from '../interface'; 9 | import { EventEmitter } from '../event'; 10 | 11 | export interface IBase { 12 | _cfg: LooseObject; 13 | get(name: string): any; 14 | set(name: string, value: any); 15 | } 16 | 17 | export abstract class Base extends EventEmitter implements IBase { 18 | _cfg: LooseObject; 19 | getDefaultCfg() { 20 | return {}; 21 | } 22 | constructor(cfg) { 23 | super(); 24 | const defaultCfg = this.getDefaultCfg(); 25 | this._cfg = _merge(defaultCfg, cfg); 26 | } 27 | get(keystr) { 28 | const keys = keystr.split('.'); 29 | const resval = keys.reduce((accur, key) => { 30 | return accur[key]; 31 | }, this._cfg); 32 | return resval; 33 | } 34 | set(key, value) { 35 | this._cfg[key] = value; 36 | } 37 | _setObj(obj: LooseObject) { 38 | if (!isObj(obj)) return; 39 | const defaultCfg = this.getDefaultCfg(); 40 | for (const key in obj) { 41 | if (defaultCfg[key]) { 42 | this.set(key, obj[key]); 43 | } 44 | } 45 | } 46 | } -------------------------------------------------------------------------------- /src/abstract/canvas.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @file canvas基类: 和业务数据没关的一些方法 3 | * - 局部渲染 drawRegion 4 | * - 画线 5 | * - 设置笔触 6 | */ 7 | import { Base } from './base'; 8 | import { RectOffset, Point, CanvasCtxAttrs } from '../type'; 9 | import { isString } from '../utils'; 10 | import { LooseObject } from '@interface/index'; 11 | const CANVAS_ATTRS_MAP = { 12 | fontColor: 'fillStyle', 13 | bgcolor: 'fillStyle', 14 | linecolor: 'strokeStyle', 15 | linewidth: 'lineWidth', 16 | opacity: 'globalAlpha' 17 | }; 18 | 19 | // 不需要将ctx传来传去 20 | // 外界和组件内部的ctx共享的是一个 且保持更新 21 | interface ICanvas { 22 | drawRegion(rect: RectOffset, renderfn: (extra?: LooseObject) => void, extra?: LooseObject); 23 | drawLine(start: Point, end: Point); 24 | drawRect(rect: RectOffset, fillcolor: string, border?: string); 25 | applyAttrToCtx(attr: CanvasCtxAttrs); 26 | clearRect(x: number, y: number, width: number, height: number); 27 | getViewRange(): RectOffset; 28 | /** 29 | * document -> canvas 30 | * @param {number} clientX 屏幕 x 坐标 31 | * @param {number} clientY 屏幕 y 坐标 32 | * @return {object} 画布坐标 33 | */ 34 | getPointByClient(clientX: number, clientY: number): Point; 35 | /** 36 | * canvas -> document 37 | * @param {number} canvasX 画布 x坐标 38 | * @param {number} canvasY 画布 y坐标 39 | * @return {Point} 屏幕坐标 40 | */ 41 | getClientByPoint(canvasX: number, canvasY: number): Point; 42 | } 43 | 44 | export abstract class AbstraCanvas extends Base implements ICanvas { 45 | constructor(container: HTMLElement) { 46 | super({ container }); 47 | this._initContainer(); 48 | this._initDom(); 49 | } 50 | // 复写基类函数 51 | getDefaultCfg() { 52 | const cfg = super.getDefaultCfg(); 53 | return cfg; 54 | } 55 | drawRegion( 56 | rect: RectOffset, 57 | renderfn: () => void, 58 | ): void { 59 | const context = this.get('context'); 60 | const { left, top, width, height } = rect; 61 | context.clearRect(left, top, width, height); 62 | context.save(); 63 | context.beginPath(); 64 | context.rect(left, top, width, height); 65 | context.clip(); 66 | renderfn(); 67 | context.restore(); 68 | } 69 | drawLine(start: Point, end: Point): void { 70 | const context = this.get('context'); 71 | context.beginPath(); 72 | context.moveTo(start.x, start.y); // 起点 73 | context.lineTo(end.x, end.y);// 终点 74 | context.stroke(); 75 | } 76 | drawRect(rect: RectOffset, fillcolor: string, border?: string) { 77 | const context = this.get('context'); 78 | const { left, top, width, height } = rect; 79 | context.beginPath(); 80 | context.rect(left, top, width, height); 81 | context.fillStyle = fillcolor; 82 | if (isString(border)) { 83 | const [bordersize, borderstyle, bordercolor] = border.split(' '); 84 | // border: 1px solid red | 4px dash blue 85 | if (borderstyle === 'dash') { 86 | context.setLineDash([10, 10]); 87 | } 88 | context.lineWidth = bordersize.endsWith('px') ? bordersize.slice(0, -2) : bordersize; 89 | context.strokeStyle = bordercolor; 90 | } 91 | context.fill(); 92 | context.stroke(); 93 | } 94 | applyAttrToCtx(attrs: CanvasCtxAttrs) { 95 | const context = this.get('context'); 96 | Object.keys(attrs).forEach(k => { 97 | const v = attrs[k]; 98 | const name = CANVAS_ATTRS_MAP[k] ? CANVAS_ATTRS_MAP[k] : k; 99 | context[name] = v; 100 | }); 101 | } 102 | clearRect(x: number, y: number, width: number, height: number) { 103 | const context = this.get('context'); 104 | context.clearRect(x, y, width, height); 105 | } 106 | getViewRange(): RectOffset { 107 | return { 108 | left: 0, 109 | top: 0, 110 | width: this.get('width'), 111 | height: this.get('height'), 112 | }; 113 | } 114 | getPointByClient(clientX: number, clientY: number): Point { 115 | const el = this.get('el'); 116 | const bbox = el.getBoundingClientRect(); 117 | return { 118 | x: clientX - bbox.left, 119 | y: clientY - bbox.top, 120 | }; 121 | } 122 | getClientByPoint(x: number, y: number): Point { 123 | const el = this.get('el'); 124 | const bbox = el.getBoundingClientRect(); 125 | return { 126 | x: x + bbox.left, 127 | y: y + bbox.top, 128 | }; 129 | } 130 | protected _initContainer() { 131 | let container = this.get('container'); 132 | if (typeof container === 'string') { 133 | container = document.getElementById(container); 134 | this.set('container', container); 135 | } 136 | } 137 | protected _initDom() { 138 | const el = this._createDom(); 139 | this.set('el', el); 140 | // 设置初始宽度 141 | // this._setDOMSize(this.get('width'), this.get('height')); 142 | } 143 | // 创建画布容器 144 | // abstract createDom(): HTMLElement; 145 | protected _createDom(): HTMLElement { 146 | const element = document.createElement('canvas'); 147 | const context = element.getContext('2d'); 148 | // 缓存 context 对象 149 | this.set('context', context); 150 | return element; 151 | } 152 | protected _setDOMSize() { 153 | const width = this.get('width'); 154 | const height = this.get('height'); 155 | const el = this.get('el'); 156 | const context = this.get('context'); 157 | const pixelRatio = this._getPixelRatio(); 158 | // 高清屏适配 159 | el.width = pixelRatio * width; 160 | el.height = pixelRatio * height; 161 | el.style.cssText = `transform:scale(${1 / pixelRatio});transform-origin:0 0`; 162 | if (pixelRatio > 1) { 163 | // 像素单位缩放 164 | context.scale(pixelRatio, pixelRatio); 165 | } 166 | this.get('container').appendChild(el); 167 | } 168 | // 获取设备像素比 169 | protected _getPixelRatio() { 170 | const pixelRatio = this.get('pixelRatio') || (window ? window.devicePixelRatio : 1); 171 | // 不足1取1,超1取整 172 | const respixelRatio = pixelRatio >= 1 ? Math.ceil(pixelRatio) : 1; 173 | this.set('pixelRatio', respixelRatio); 174 | return respixelRatio; 175 | } 176 | } -------------------------------------------------------------------------------- /src/abstract/range-base.ts: -------------------------------------------------------------------------------- 1 | import { RangeRenderController } from '../view/rangeman'; // 抽象依赖于具体了。。FIXME: 改为依赖抽象的接口 2 | import { Base } from './base'; 3 | import { LooseObject } from '../interface'; 4 | import { CanvasRender } from '../view'; // FIXME: 改为依赖抽象的接口 5 | 6 | export abstract class BaseRange extends Base { 7 | protected _ctx: CanvasRenderingContext2D; 8 | protected _style: LooseObject; 9 | protected _props: RangeRenderController; 10 | protected _canvas: CanvasRender; 11 | getDefaultCfg() { 12 | const cfg = super.getDefaultCfg(); 13 | return cfg; 14 | } 15 | constructor( 16 | rangecontroller: RangeRenderController, 17 | cfg?: LooseObject 18 | ) { 19 | super(cfg); 20 | this._props = rangecontroller; 21 | this._canvas = rangecontroller.canvas; 22 | this._ctx = this._canvas.get('context'); 23 | this._style = this.get('style'); 24 | } 25 | // abstract render: () => void; 26 | } -------------------------------------------------------------------------------- /src/abstract/shape-base.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @file registerView所需要的基类 3 | * - 用于canvas表格以外的dom建设,辅助用户action 及 表格显示的 4 | * - createRender、对dom注册的事件、以及对外开放的事件behavior:creatHook 5 | */ 6 | import { Base } from './base'; 7 | import { LooseObject } from '../interface'; 8 | import { Engine } from '../engine'; 9 | export class Shape extends Base { 10 | engine: Engine; 11 | getDefaultCfg() { 12 | const cfg = super.getDefaultCfg(); 13 | return cfg; 14 | } 15 | constructor(Engine: Engine, cfg?: LooseObject) { 16 | super(cfg); 17 | this.engine = Engine; 18 | } 19 | createRender(): string { return '' } 20 | initEvent() { } 21 | creatHook() { } 22 | } -------------------------------------------------------------------------------- /src/config/alphabet.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * table header row索引栏 3 | */ 4 | const alphabets = [ 5 | 'A', 'B', 'C', 'D', 'E', 'F', 'G', 'H', 'I', 6 | 'J', 'K', 'L', 'M', 'N', 'O', 'P', 'Q', 'R', 7 | 'S', 'T', 'U', 'V', 'W', 'X', 'Y', 'Z', 8 | ]; 9 | 10 | export default { 11 | stringAt: index => { 12 | let str = ''; 13 | let cindex = index; 14 | while (cindex >= alphabets.length) { 15 | cindex /= alphabets.length; 16 | cindex -= 1; 17 | str += alphabets[parseInt(cindex, 10) % alphabets.length]; 18 | } 19 | const last = index % alphabets.length; 20 | str += alphabets[last]; 21 | return str; 22 | }, 23 | indexAt: str => { 24 | let ret = 0; 25 | for (let i = 0; i < str.length - 1; i += 1) { 26 | const cindex = str.charCodeAt(i) - 65; 27 | const exponet = str.length - 1 - i; 28 | ret += (alphabets.length ** exponet) + (alphabets.length * cindex); 29 | } 30 | ret += str.charCodeAt(str.length - 1) - 65; 31 | return ret; 32 | }, 33 | }; 34 | -------------------------------------------------------------------------------- /src/config/engineoption.ts: -------------------------------------------------------------------------------- 1 | import { EngineOption, CanvasCfg } from '@type/index'; 2 | 3 | export const defaultEngineOption: EngineOption = { 4 | container: null, 5 | // 外观配置项 6 | viewOption: { 7 | showToolbar: true, 8 | showCtxMenu: true, 9 | viewHeight: document.documentElement.clientHeight, 10 | viewWidth: document.documentElement.clientWidth, 11 | // tableStyle: { 12 | // bgcolor: '#ffffff', 13 | // lineWidth: .5, // 网格线粗细, 具体单元格可用border对其覆写 14 | // lineColor: '#333333', 15 | // cellpadding: 10, // 网格内间距 16 | // fixedHeaderStyle: { 17 | // bgcolor: '#f4f5f8', 18 | // lineWidth: .5, 19 | // }, 20 | // }, 21 | }, 22 | interactOption: { 23 | // 允许编辑 24 | canEdit: true, 25 | // 框选功能 26 | // selectView: { 27 | // border: '1px solid blue', 28 | // background: '#fff', 29 | // opacity: .6, 30 | // }, 31 | }, 32 | }; 33 | 34 | export const defaultCanvasOption: CanvasCfg = { 35 | width: defaultEngineOption.viewOption.viewWidth, 36 | height: defaultEngineOption.viewOption.viewHeight, 37 | }; -------------------------------------------------------------------------------- /src/engine.d.ts: -------------------------------------------------------------------------------- 1 | import { EngineOption, SourceData, GridMdata, Cell, Point, Rect, RectIndexes, Boxsize, TableStatus, Range, Cursor } from './type'; 2 | import { CanvasRender, DomRender } from './view'; 3 | import { Base } from './abstract/base'; 4 | import { Shape } from './abstract/shape-base'; 5 | import { DataModel } from './model/mdata'; 6 | import { IEngine } from './interface'; 7 | export declare class Engine extends Base implements IEngine { 8 | canvasRender: CanvasRender; 9 | domRender: DomRender; 10 | dataModel: DataModel; 11 | getDefaultCfg(): { 12 | container: HTMLElement; 13 | viewOption: import("./type").ViewOption; 14 | interactOption: import("./type").InteractOption; 15 | }; 16 | static ViewDomMap: { 17 | [key: string]: { 18 | new (...arg: any[]): Shape; 19 | }; 20 | }; 21 | constructor(engineOpt: EngineOption); 22 | griddata(grid: GridMdata): this; 23 | source(data: SourceData): this; 24 | setRange(properties: Cell): this; 25 | getIdxByPoint(point: Point): Rect; 26 | getRange(): Range; 27 | getCell(point: RectIndexes): Cell; 28 | getSumHeight(): number; 29 | getSumWidth(): number; 30 | getBoxSize(): Boxsize; 31 | getStatus(): TableStatus; 32 | changeCursor(type: Cursor): void; 33 | } 34 | export declare function RegisterView(target: { 35 | new (...arg: any[]): any; 36 | }, shapeName: string): void; 37 | -------------------------------------------------------------------------------- /src/engine.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @file 主函数 3 | * 数据链路是:event(aciton) -> datamodel -> viewdata -> render 4 | * 5 | */ 6 | import { EngineOption, SourceData, GridMdata, Cell, Point, Rect, RectIndexes, Boxsize, TableStatus, Range, Cursor } from './type'; 7 | import { defaultEngineOption } from './config/engineoption'; 8 | import { CanvasRender, DomRender } from './view'; 9 | import { Base } from './abstract/base'; 10 | import { Shape } from './abstract/shape-base'; 11 | import { EventController } from './event'; 12 | import { DataModel } from './model/mdata'; 13 | import { ViewModel } from './model/vdata'; 14 | import { IEngine } from './interface'; 15 | 16 | export class Engine extends Base implements IEngine { 17 | canvasRender: CanvasRender; 18 | domRender: DomRender; // toolbar、scrollbar等 19 | dataModel: DataModel; 20 | getDefaultCfg() { 21 | return { 22 | ...defaultEngineOption, 23 | }; 24 | } 25 | // 使用RegisterView函数注册的图形将被存放在此处,由初始化时注入进来,DomRender进行具体渲染控制 26 | static ViewDomMap: { 27 | [key: string]: { new(...arg): Shape } 28 | } = {}; 29 | constructor(engineOpt: EngineOption) { 30 | super(engineOpt); 31 | const viewRect = { 32 | viewHeight: this.get('viewOption.viewHeight'), 33 | viewWidth: this.get('viewOption.viewWidth') 34 | } 35 | /** 36 | * initUI = canvasRender(viewmodel = datamodel(cfg)) 37 | * 数据流:datamodel -> viewdata -> render 38 | */ 39 | this.canvasRender = new CanvasRender(engineOpt.container, viewRect); 40 | const viewModel = new ViewModel(this.canvasRender); 41 | this.dataModel = new DataModel(viewModel, viewRect); 42 | this.dataModel.emit = this.emit.bind(this); // 赋予datamodel派发事件的能力 43 | this.domRender = new DomRender(this, engineOpt); 44 | 45 | /** 46 | * interactionUI = render (viewmodel = (datamodel.command(action = eventController.emit))) 47 | * 数据流:event(aciton) -> datamodel -> viewdata -> render 48 | */ 49 | // 有两个事件处理入口:1.event 将用户交互事件派发到engine.on上 2.registerview里的initevent 50 | const eventcontroller = new EventController(this); 51 | this._setObj({ eventcontroller }); 52 | } 53 | griddata(grid: GridMdata) { 54 | this.dataModel.resetGrid(grid); 55 | return this; 56 | } 57 | // 载入数据 58 | source(data: SourceData) { // viewdata 59 | this.dataModel.source(data); 60 | return this; 61 | } 62 | setRange(properties: Cell) { 63 | this.dataModel.command({ 64 | type: 'setRange', 65 | properties: properties 66 | }); 67 | return this; 68 | } 69 | getIdxByPoint(point: Point): Rect { 70 | return this.dataModel.getIdxByPoint(point); 71 | } 72 | getRange(): Range { 73 | return this.dataModel.getRange(); 74 | } 75 | getCell(point: RectIndexes): Cell { 76 | return this.dataModel.getCell(point); 77 | } 78 | getSumHeight(): number { 79 | return this.dataModel.getSumHeight(); 80 | } 81 | getSumWidth(): number { 82 | return this.dataModel.getSumWidth(); 83 | } 84 | // 获取当前表格的宽高数据:画布大小、实际content大小 85 | getBoxSize(): Boxsize { 86 | const [contentH, contentW] = this.dataModel.getRealContentSize(); 87 | return { 88 | viewH: this.get('viewOption.viewHeight'), 89 | viewW: this.get('viewOption.viewWidth'), 90 | contentH: contentH, 91 | contentW: contentW 92 | } 93 | } 94 | getStatus(): TableStatus { 95 | return this.dataModel.getStatus(); 96 | } 97 | changeCursor(type: Cursor) { 98 | this.domRender.changeCursor(type); 99 | } 100 | } 101 | 102 | /** 103 | * 注册view在表格上 104 | */ 105 | export function RegisterView(target: { new(...arg): any }, shapeName: string) { 106 | Engine.ViewDomMap[shapeName] = target; 107 | target.prototype.name = shapeName; 108 | } -------------------------------------------------------------------------------- /src/event/event.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @file 事件管理 3 | * - dominner 响应用户操作,并将dom数据加工后转发到engine上(engine.emit) 4 | * 业务层处理业务逻辑通过 egine.on(eventname, callback) 处理 5 | */ 6 | import { Engine } from '../engine'; 7 | import { addEventListener, isNil, each, isBetween } from '../utils'; 8 | import { IExcelEvent } from '../interface'; 9 | import { Rect } from '../type'; 10 | 11 | export enum ExcelEvent { 12 | // common events 13 | CLICK = 'click', 14 | DBLCLICK = 'dblclick', 15 | MOUSEDOWN = 'mousedown', 16 | MOUDEUP = 'mouseup', 17 | CONTEXTMENU = 'contextmenu', 18 | MOUSEENTER = 'mouseenter', 19 | MOUSEOUT = 'mouseout', 20 | MOUSEOVER = 'mouseover', 21 | MOUSEMOVE = 'mousemove', 22 | MOUSELEAVE = 'mouseleave', 23 | KEYUP = 'keyup', 24 | KEYDOWN = 'keydown', 25 | WHEEL = 'wheel', 26 | FOCUS = 'focus', 27 | BLUR = 'blur', 28 | 29 | // canvas events 30 | CANVAS_CONTEXTMENU = 'canvas:contextmenu', 31 | CANVAS_CLICK = 'canvas:click', 32 | CANVAS_DBLCLICK = 'canvas:dblclick', 33 | CANVAS_MOUSEDOWN = 'canvas:mousedown', 34 | CANVAS_MOUSEUP = 'canvas:mouseup', 35 | CANVAS_MOUSEENTER = 'canvas:mouseenter', 36 | CANVAS_MOUSELEAVE = 'canvas:mouseleave', 37 | CANVAS_MOUSEMOVE = 'canvas:mousemove', 38 | CANVAS_MOUSEOUT = 'canvas:mouseout', 39 | CANVAS_MOUSEOVER = 'canvas:mouseover', 40 | 41 | // 业务层 42 | CANVAS_CELLCLICK = 'canvas:cellclick', 43 | CANVAS_SELECT = 'canvas:select', 44 | CANVAS_SCROLL = 'canvas:scroll', 45 | CANVAS_RESIZE = 'canvas:resize', 46 | } 47 | 48 | export class EventController { 49 | protected destroyed = false; 50 | protected extendEvents: any[] = []; // 使用数组存放监听事件函数,当engine销毁的时候,一并销毁掉这些lisener 51 | protected engine: Engine; 52 | protected selectStartRect: Rect | null; 53 | protected multiSelect = false; 54 | protected isResizing = false; 55 | protected el = null; 56 | constructor(engine: Engine) { 57 | this.engine = engine; 58 | this.initEvents(); 59 | 60 | } 61 | initEvents() { 62 | const { engine, extendEvents = [] } = this; 63 | const dom = engine.domRender; 64 | const el = dom.get('el'); 65 | this.el = el; 66 | 67 | 68 | // 滚动 69 | extendEvents.push(addEventListener(el, 'DOMMouseScroll', this.onWheelEvent.bind(this))); 70 | extendEvents.push(addEventListener(el, 'mousewheel', this.onWheelEvent.bind(this))); 71 | 72 | // mousemove 73 | extendEvents.push(addEventListener(el, 'mousemove', this.onCanvasEvents.bind(this))); 74 | extendEvents.push(addEventListener(el, 'mouseup', this.onCanvasEvents.bind(this))); 75 | extendEvents.push(addEventListener(el, 'mousedown', this.onCanvasEvents.bind(this))); 76 | 77 | // click 78 | extendEvents.push(addEventListener(el, 'click', this.onCanvasEvents.bind(this))); 79 | 80 | 81 | // 键盘事件 82 | if (typeof window !== 'undefined') { 83 | extendEvents.push(addEventListener(window as any, 'keydown', this.onExtendEvents.bind(this))); 84 | extendEvents.push(addEventListener(window as any, 'keyup', this.onExtendEvents.bind(this))); 85 | extendEvents.push(addEventListener(window as any, 'focus', this.onExtendEvents.bind(this))); 86 | } 87 | 88 | extendEvents.push(addEventListener(window as any, 'onbeforeunload', this.unload.bind(this))); 89 | } 90 | /** 91 | * 处理 canvas 事件 92 | * @param evt 事件句柄 93 | * 鼠标在canvas画布上的操作,将evt转换为画布坐标 94 | */ 95 | protected onCanvasEvents(evt: IExcelEvent) { 96 | const { engine } = this; 97 | const dom = engine.domRender; 98 | const eventType = evt.type; 99 | 100 | const point = dom.getPointByClient(evt.clientX, evt.clientY); 101 | evt.canvasX = point.x; 102 | evt.canvasY = point.y; 103 | if (eventType === 'mousedown') { 104 | this.onMouseDown(evt); 105 | } else if (eventType === 'mousemove') { 106 | this.onMouseMove(evt); 107 | } else if (eventType === 'mouseup') { 108 | if (this.multiSelect) { 109 | // 框选结束 110 | this.multiSelect = false; 111 | this.selectStartRect = null; 112 | return; 113 | } 114 | } 115 | engine.emit(`canvas:${eventType} `, evt); 116 | } 117 | /** 118 | * - onCanvasDblClick 双击编辑 119 | * - onCanvasClick 单击单元格选中 120 | */ 121 | onMouseDown(evt: IExcelEvent) { 122 | const classList = Array.from(evt.target.classList); 123 | if (classList.includes('xexcel-scrollbar')) return; 124 | if (evt.detail === 2) { 125 | this.onCanvasDblClick(evt); 126 | } else { 127 | // 单选:mousedown mouseup === click 128 | // 框选:mousedown mousemove mouseup 129 | if (!evt.shiftKey) { 130 | this.onCanvasClick(evt); 131 | } 132 | } 133 | } 134 | /** 135 | * - 索引栏上:mousemove时候 鼠标resizer化 136 | * - 单元格框选时:mousedown mousemove mouseup 137 | */ 138 | onMouseMove(evt: IExcelEvent) { 139 | const { engine } = this; 140 | this.engine.changeCursor('auto'); 141 | if (this.checkResizerCell(evt)) { 142 | return; 143 | } 144 | if (this.selectStartRect && !this.isResizing) { 145 | if (evt.buttons === 1 && !evt.shiftKey) { 146 | const endCell = engine.getIdxByPoint({ 147 | x: evt.canvasX, 148 | y: evt.canvasY 149 | }); 150 | let [eri, eci] = [endCell.ri, endCell.ci]; 151 | 152 | if (eri === -1 && eci === -1) return; 153 | let { ri: sri, ci: sci } = this.selectStartRect; 154 | 155 | if (sri >= eri) { 156 | [sri, eri] = [eri, sri]; 157 | } 158 | if (sci >= eci) { 159 | [sci, eci] = [eci, sci]; 160 | } 161 | const width = endCell.left + endCell.width - this.selectStartRect.left; 162 | const height = endCell.top + endCell.height - this.selectStartRect.top; 163 | engine.emit('canvas:select', { 164 | sri, 165 | sci, 166 | eri, 167 | eci, 168 | left: this.selectStartRect.left, 169 | top: this.selectStartRect.top, 170 | width, 171 | height, 172 | }); 173 | this.multiSelect = true; 174 | } 175 | } 176 | } 177 | unload() { 178 | this.engine.emit('destroy'); 179 | this.engine.dataModel.export(); 180 | this.destroy(); 181 | } 182 | public destroy() { 183 | const { extendEvents } = this; 184 | each(extendEvents, (event) => { 185 | event.remove(); 186 | }); 187 | this.extendEvents.length = 0; 188 | this.destroyed = true; 189 | } 190 | // 找到当前canvas点击的哪个cell 191 | onCanvasClick(evt: IExcelEvent) { 192 | const { engine } = this; 193 | const resizerCell = this.checkResizerCell(evt); 194 | if (resizerCell) { 195 | this.handleResize(evt, resizerCell); 196 | return; 197 | } 198 | this.selectStartRect = null; 199 | const cell = engine.getIdxByPoint({ 200 | x: evt.canvasX, 201 | y: evt.canvasY 202 | }); 203 | this.selectStartRect = cell; 204 | engine.emit('canvas:cellclick', cell); 205 | } 206 | 207 | // canvas 双击 进入编辑 208 | onCanvasDblClick(evt: IExcelEvent) { 209 | const { engine } = this; 210 | this.selectStartRect && engine.emit('canvas:dblclick', this.selectStartRect); 211 | } 212 | /** 213 | * 处理滚轮事件 214 | * @param evt 事件句柄 215 | */ 216 | protected onWheelEvent(evt: IExcelEvent) { 217 | if (isNil(evt.wheelDelta)) { 218 | evt.wheelDelta = -evt.detail; 219 | } 220 | this.engine.emit('wheel', evt); 221 | } 222 | /** 223 | * 处理扩展事件 224 | * @param evt 事件句柄 225 | */ 226 | protected onExtendEvents(evt: IExcelEvent) { 227 | this.engine.emit(evt.type, evt); 228 | } 229 | protected mouseMoveUp(moveFunc, moveUpFunc) { 230 | const func1 = addEventListener(this.el, 'mousemove', moveFunc.bind(this)); 231 | const func2 = addEventListener(this.el, 'mouseup', movefinished.bind(this)); 232 | function movefinished(evt) { 233 | func1.remove(); 234 | func2.remove(); 235 | moveUpFunc.call(this, evt); 236 | } 237 | } 238 | /** 239 | * 判断鼠标在索引栏上时是否处于准备resize改变行高列宽的间隔线附近 240 | * @return Rect: 当前hover的索引栏格 241 | */ 242 | protected checkResizerCell(evt: IExcelEvent): Rect | null { 243 | const { fixedColWidth, fixedRowHeight } = this.engine.getStatus(); 244 | if (evt.canvasX > fixedColWidth && evt.canvasY > fixedRowHeight) return null; 245 | // if (evt.buttons !== 0) return false; 246 | const cell = this.engine.getIdxByPoint({ 247 | x: evt.canvasX, 248 | y: evt.canvasY 249 | }); 250 | if (cell.ci === -1 && cell.ri === -1) return null; 251 | if (cell.ci === -1 || cell.ri === -1) { 252 | const isColResizing = cell.ri === -1; 253 | const { lineoffset } = cell; 254 | const boxsize = isColResizing ? cell.width : cell.height; 255 | const evtOffset = isColResizing ? evt.canvasX : evt.canvasY; 256 | const buffer = ~~(boxsize / 6); 257 | if (isBetween(evtOffset, lineoffset - buffer, lineoffset + buffer)) { 258 | this.engine.changeCursor(`${isColResizing ? 'col-resize' : 'row-resize'}`); 259 | return cell; 260 | } 261 | } 262 | return null; 263 | } 264 | /** 265 | * @param evt: IExcelEvent 鼠标坐标, cell: Rect当前命中索引栏格 266 | * mousedown时已确定好了 targetcell 267 | * 接下来拆解为俩动作:mousemove、mouseup 268 | * - mousemove: 辅助线 269 | * - mouseup: 通知datamodel改变gridmap 270 | */ 271 | protected handleResize(evt: IExcelEvent, cell: Rect) { 272 | const canvasrender = this.engine.canvasRender; 273 | canvasrender.saveDrawingSurface(); 274 | const { rowminsize, colminsize, sumheight, sumwidth } = this.engine.getStatus(); 275 | const isColResizing = cell.ri === -1; 276 | const minDistance = isColResizing ? colminsize : rowminsize; 277 | let startEvt = evt; 278 | let distance = isColResizing ? cell.width : cell.height; 279 | this.mouseMoveUp(moveFunc, moveUp); 280 | 281 | function moveFunc(e) { 282 | this.isResizing = true; 283 | canvasrender.restoreDrawingSurface(); 284 | if (startEvt !== null && e.buttons === 1) { 285 | if (isColResizing) { 286 | distance += e.movementX; 287 | } 288 | else { 289 | distance += e.movementY; 290 | } 291 | if (distance > minDistance) { 292 | drawGuidelines(distance + cell[isColResizing ? 'left' : 'top']); 293 | } 294 | startEvt = e; 295 | } 296 | } 297 | 298 | function moveUp() { 299 | startEvt = null; 300 | this.isResizing = false; 301 | canvasrender.restoreDrawingSurface(); 302 | const cellsize = isColResizing ? cell.width : cell.height; 303 | if (distance < minDistance) { 304 | distance = minDistance; 305 | } 306 | this.engine.dataModel.command({ 307 | type: 'resizeGrid', 308 | isCol: isColResizing, 309 | idx: isColResizing ? cell.ci : cell.ri, 310 | diff: distance - cellsize 311 | }); 312 | } 313 | 314 | function drawGuidelines(offset: number) { 315 | const context = canvasrender.get('context'); 316 | context.strokeStyle = '#4b89ff'; 317 | context.setLineDash([10, 10]); 318 | context.lineWidth = 3; 319 | context.beginPath(); 320 | if (isColResizing) { 321 | context.moveTo(offset, 0); 322 | context.lineTo(offset, sumheight); 323 | } 324 | else { 325 | context.moveTo(0, offset); 326 | context.lineTo(sumwidth, offset); 327 | } 328 | context.stroke(); 329 | } 330 | } 331 | } -------------------------------------------------------------------------------- /src/event/event-emiiter.ts: -------------------------------------------------------------------------------- 1 | interface EventType { 2 | // eslint-disable-next-line @typescript-eslint/ban-types 3 | readonly callback: Function; 4 | readonly once: boolean; 5 | } 6 | 7 | type EventsType = Record; 8 | 9 | export class EventEmitter { 10 | private _events: EventsType = {}; 11 | 12 | /** 13 | * 监听一个事件 14 | * @param evt 15 | * @param callback 16 | * @param once 17 | */ 18 | on(evt: string, callback: Function, once?: boolean) { 19 | if (!this._events[evt]) { 20 | this._events[evt] = []; 21 | } 22 | this._events[evt].push({ 23 | callback, 24 | once: !!once, 25 | }); 26 | return this; 27 | } 28 | /** 29 | * 监听一个事件一次 30 | * @param evt 31 | * @param callback 32 | */ 33 | once(evt: string, callback: Function) { 34 | return this.on(evt, callback, true); 35 | } 36 | /** 37 | * 触发一个事件 38 | * @param evt 39 | * @param args 40 | */ 41 | emit(evt: string, ...args: any[]) { 42 | const events = this._events[evt] || []; 43 | // 实际的处理 emit 方法 44 | const doEmit = (es: EventType[]) => { 45 | let length = es.length; 46 | for (let i = 0; i < length; i++) { 47 | if (!es[i]) continue; 48 | const { callback, once } = es[i]; 49 | 50 | if (once) { 51 | es.splice(i, 1); 52 | 53 | if (es.length === 0) { 54 | delete this._events[evt]; 55 | } 56 | 57 | length--; 58 | i--; 59 | } 60 | 61 | callback.apply(this, args); 62 | } 63 | }; 64 | 65 | doEmit(events); 66 | } 67 | /* 当前所有的事件 */ 68 | getEvents() { 69 | return this._events; 70 | } 71 | /** 72 | * 取消监听一个事件 73 | * @param evt 74 | * @param callback 75 | */ 76 | off(evt?: string, callback?: Function) { 77 | if (!evt) { 78 | // evt 为空全部清除 79 | this._events = {}; 80 | } else { 81 | if (!callback) { 82 | // evt 存在,callback 为空,清除事件所有方法 83 | delete this._events[evt]; 84 | } else { 85 | // evt 存在,callback 存在,清除匹配的 86 | const events = this._events[evt] || []; 87 | 88 | let length = events.length; 89 | for (let i = 0; i < length; i++) { 90 | if (events[i].callback === callback) { 91 | events.splice(i, 1); 92 | length--; 93 | i--; 94 | } 95 | } 96 | 97 | if (events.length === 0) { 98 | delete this._events[evt]; 99 | } 100 | } 101 | } 102 | 103 | return this; 104 | } 105 | } -------------------------------------------------------------------------------- /src/event/index.ts: -------------------------------------------------------------------------------- 1 | export { EventController } from './event'; 2 | export { EventEmitter } from './event-emiiter'; -------------------------------------------------------------------------------- /src/index.d.ts: -------------------------------------------------------------------------------- 1 | import { Engine } from './engine'; 2 | import { EngineOption } from './type'; 3 | import './index.less'; 4 | declare const XWebExcel: { 5 | create(container: HTMLElement, opt?: EngineOption): Engine; 6 | }; 7 | export default XWebExcel; 8 | export { XWebExcel, }; 9 | -------------------------------------------------------------------------------- /src/index.less: -------------------------------------------------------------------------------- 1 | #app { 2 | position: relative; 3 | border: 1px solid #000; 4 | 5 | * { 6 | margin: 0; 7 | padding: 0; 8 | } 9 | 10 | ul, 11 | li { 12 | list-style: none; 13 | } 14 | 15 | #xexcel-canvas { 16 | // border: 1px solid red; 17 | // margin-left: 100px; 18 | // margin-top: 100px; 19 | } 20 | 21 | /* toolbar */ 22 | .xsheet-toolbar { 23 | margin-bottom: 10px; 24 | 25 | ul.xsheet-toolbar-content { 26 | height: 30px; 27 | border: 1px solid green; 28 | // overflow: hidden; 29 | 30 | li.xsheet-toolbar-item { 31 | position: relative; 32 | float: left; 33 | line-height: 30px; 34 | vertical-align: middle; 35 | padding: 0 5px; 36 | border-right: 1px solid #E8E6E3; 37 | 38 | &:hover, 39 | &.active { 40 | background: #E8E6E3; 41 | cursor: pointer; 42 | font-weight: 600; 43 | 44 | .toolbar-setting-menu { 45 | display: block; 46 | } 47 | } 48 | 49 | input[type='color'] { 50 | cursor: pointer; 51 | } 52 | 53 | label { 54 | cursor: pointer; 55 | } 56 | 57 | // 字号选择 58 | .toolbar-setting-menu { 59 | // display: none; 60 | position: absolute; 61 | width: 100%; 62 | z-index: 100; 63 | top: 100%; 64 | left: 0; 65 | background-color: #fff; 66 | 67 | .toolbar-setting-menu-item { 68 | text-align: center; 69 | 70 | &:hover { 71 | background-color: lavender; 72 | } 73 | } 74 | } 75 | } 76 | } 77 | } 78 | 79 | // zindex管理: 80 | // content > selector(2) > editor(3) > scroll(4) 81 | .xexcel-main-table { 82 | position: absolute; 83 | left: 0; 84 | top: 40px; // top: toolbarheight 85 | width: 100%; 86 | height: 100%; 87 | overflow: hidden; 88 | z-index: 1; 89 | 90 | .xexcel-main-table-inner { 91 | position: absolute; 92 | overflow: hidden; 93 | 94 | /* selector */ 95 | .xexcel-selector { 96 | position: absolute; 97 | left: 0; 98 | top: 0; 99 | z-index: 2; 100 | 101 | .xexcel-selector-area { 102 | display: none; 103 | position: absolute; 104 | border: 2px solid rgb(75, 137, 255); 105 | background: rgba(75, 137, 255, .1); 106 | pointer-events: none; 107 | } 108 | 109 | .xexcel-selector-corner { 110 | position: absolute; 111 | font-size: 0; 112 | height: 5px; 113 | width: 5px; 114 | right: -5px; 115 | bottom: -5px; 116 | border: 2px solid rgb(255, 255, 255); 117 | background: rgb(75, 137, 255); 118 | } 119 | 120 | /* editor */ 121 | .xexcel-editor { 122 | position: absolute; 123 | text-align: left; 124 | border: 2px solid #4b89ff; 125 | line-height: 0; 126 | z-index: 3; 127 | overflow: hidden; 128 | 129 | .xexcel-editor-textarea { 130 | position: absolute; 131 | left: 0; 132 | top: 0; 133 | width: 100%; 134 | height: 100%; 135 | box-sizing: content-box; 136 | border: none; 137 | padding: 0 4px; 138 | outline-width: 0; 139 | resize: none; 140 | text-align: start; 141 | overflow-y: hidden; 142 | font-family: inherit; 143 | font-size: inherit; 144 | color: inherit; 145 | white-space: normal; 146 | word-wrap: break-word; 147 | line-height: 22px; 148 | margin: 0; 149 | } 150 | } 151 | } 152 | 153 | /* scrollbar */ 154 | .xexcel-scrollbar { 155 | position: absolute; 156 | opacity: 0.85; 157 | z-index: 12; 158 | 159 | &.horizontal { 160 | bottom: 44px; // scrollbar height 161 | left: 0; 162 | overflow-x: scroll; 163 | overflow-y: hidden; 164 | } 165 | 166 | &.vertical { 167 | top: 0; 168 | right: 5px; 169 | overflow-x: hidden; 170 | overflow-y: scroll; 171 | } 172 | 173 | &:hover { 174 | opacity: .85; 175 | } 176 | 177 | &::-webkit-scrollbar { 178 | width: 8px; 179 | height: 8px; 180 | background: rgba(75, 137, 255, 0.2); 181 | } 182 | 183 | &::-webkit-scrollbar-thumb { 184 | background: rgb(75, 137, 255); 185 | border-radius: 6px; 186 | } 187 | 188 | &::-webkit-scrollbar-corner { 189 | background: rgba(75, 137, 255, .2); 190 | } 191 | } 192 | } 193 | 194 | 195 | } 196 | } 197 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import { Engine, RegisterView } from './engine'; 2 | import { EngineOption } from '@type/index'; 3 | import { ToolBar } from './view/toolbar'; 4 | import { ScrollBar } from './view/scrollbar'; 5 | import { Selector } from './view/selector'; 6 | import './index.less'; 7 | 8 | RegisterView(ToolBar, 'toolbar'); 9 | RegisterView(ScrollBar, 'scrollbar'); 10 | RegisterView(Selector, 'selector'); 11 | 12 | const XWebExcel = { 13 | /** 14 | * 创建表格 15 | * 载入数据: return engine; engine.source() 16 | * @param container 17 | * @param opt 18 | */ 19 | create( 20 | container: HTMLElement, 21 | opt?: EngineOption, 22 | ): Engine { 23 | const engine = new Engine({ 24 | container, 25 | ...opt, 26 | }); 27 | return engine; 28 | }, 29 | }; 30 | 31 | if (window) { 32 | (window as any).XWebExcel = XWebExcel; 33 | } 34 | 35 | export default XWebExcel; 36 | export { 37 | XWebExcel, 38 | }; -------------------------------------------------------------------------------- /src/interface/canvas.ts: -------------------------------------------------------------------------------- 1 | // 传到这层应该是在可视区域实际渲染的: 2 | // viewIdx 3 | // viewOffset 4 | // realIdx? 5 | // viewSource 6 | 7 | // 至于:以下方法应该是更上层去做 8 | // canvas层:realIdx -> viewIdx: (idx: RangeIndexes, scrollidx: ScrollIndexes) => RangeIndexes; 9 | // rangecontroller层:getOffsetByIndex: 逻辑索引得到物理定位 10 | 11 | export const CellZindexMap = { 12 | 'blank': 0, 13 | // 'merge': 1, 14 | 'grid-range': 2, 15 | 'style-range': 3, 16 | // 3: 'bgcolor', 17 | // 4: 'border', 18 | 'text-range': 100, 19 | 'selector-range': 999, 20 | }; -------------------------------------------------------------------------------- /src/interface/engine.ts: -------------------------------------------------------------------------------- 1 | import { 2 | GridMdata, 3 | SourceData, 4 | Cell, 5 | Point, 6 | Rect, 7 | RectIndexes, 8 | Boxsize, 9 | TableStatus, 10 | Cursor, 11 | Range 12 | } from '../type'; 13 | 14 | export interface IEngine { 15 | // getDefaultCfg: () => Partial; 16 | get: (key: string) => T; 17 | set: (key: string | Record, value?: T) => void; 18 | /** 19 | * 载入基础网格数据配置 20 | * @param grid 21 | */ 22 | griddata(grid: GridMdata): this; 23 | /** 24 | * 载入单元格数据 25 | * @param data 26 | */ 27 | source(data: SourceData): this; 28 | /** 29 | * 设置选区数据,用于toolbar响应用户操作后给选中的单元格设置对应属性 30 | * @param properties 31 | */ 32 | setRange(properties: Cell): this; 33 | /** 34 | * 根据画布坐标,获取当前cell:cell逻辑索引、cell物理坐标 35 | * @param point 36 | */ 37 | getIdxByPoint(point: Point): Rect; 38 | getCell(cellidx: RectIndexes): Cell; 39 | getBoxSize: () => Boxsize; 40 | getRange: () => Range; 41 | getCell: (point: RectIndexes) => Cell; 42 | getSumHeight: () => number; 43 | getSumWidth: () => number; 44 | getStatus: () => TableStatus; 45 | changeCursor: (type: Cursor) => void; 46 | // destroy: () => void; 47 | // onChange: (eventName: string, callback: (state: T, next: T) => void); 48 | } -------------------------------------------------------------------------------- /src/interface/index.ts: -------------------------------------------------------------------------------- 1 | export * from './canvas'; 2 | export * from './engine'; 3 | 4 | export interface LooseObject { 5 | [key: string]: any; 6 | } 7 | 8 | export interface IExcelEvent { 9 | type: string; // 事件类型 10 | canvasX: number; // (canvasX, canvasY): 相对于 左上角的坐标; 11 | canvasY: number; 12 | clientX: number; // (clientX, clientY): 相对于页面的坐标; 13 | clientY: number; 14 | wheelDelta: number; 15 | detail: number; 16 | key?: string; 17 | target: LooseObject; 18 | [key: string]: unknown; 19 | } -------------------------------------------------------------------------------- /src/model/command.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @file action层通过command层操作datamodel里的数据 3 | * action -> command -> command: datamodel: vdata -> UI = render(vdata) 4 | * - 考虑维护renderqueue 进行setdata之后的异步渲染(每次渲染尽量积累足够多的车 5 | * - 考虑rangemm维护在这里 cellmm = computed(rangemm) 6 | */ 7 | import { Cell } from '../type'; 8 | import { _merge, parseRangeKey, each } from '../utils'; 9 | 10 | type SetRangeOperation = { 11 | type: 'setRange'; 12 | rangeidxes?: string; 13 | properties: Cell; 14 | } 15 | 16 | type ScrollOperation = { 17 | type: 'scrollView'; 18 | distance: number; 19 | isVertical: boolean; 20 | } 21 | 22 | type ResizeOperation = { 23 | type: 'resizeGrid'; 24 | idx: number; 25 | diff: number; // 改动差距 可正可负 26 | isCol: boolean; // 修改列宽 27 | } 28 | 29 | 30 | type AddRowOperation = { 31 | type: 'addRow', 32 | count: number; 33 | bettweenkey: string; 34 | } 35 | 36 | export type Operation = 37 | | SetRangeOperation 38 | | ScrollOperation 39 | | ResizeOperation 40 | | AddRowOperation; 41 | 42 | 43 | interface ICommand { 44 | setRange: (op: SetRangeOperation) => void; 45 | scrollView: (op: ScrollOperation) => void; 46 | resizeGrid: (op: ResizeOperation) => void; 47 | } 48 | 49 | function setCellmm(ri: number, ci: number, properties: Cell) { 50 | let cellmm = {}; 51 | try { 52 | cellmm = this._proxyViewdata.cellmm[ri][ci]; 53 | } catch { 54 | this._proxyViewdata.cellmm[ri] = cellmm; 55 | } 56 | const curval = _merge(cellmm, properties); 57 | this._proxyViewdata.cellmm[ri][ci] = curval; 58 | this._mdata.cellmm[ri] = this._mdata.cellmm[ri] || cellmm; 59 | this._mdata.cellmm[ri][ci] = curval; 60 | } 61 | 62 | export const Command: ICommand = { 63 | setRange(op: SetRangeOperation): void { 64 | // 对datamodel.range进行修改 65 | const { rangeidxes, properties } = op; 66 | const curRangeidxes = (rangeidxes && parseRangeKey(rangeidxes)) || this._selectIdxes;// 没传的话就是默认的当前selectidxes 67 | let { sri, sci, eri, eci } = curRangeidxes; 68 | const scrollRi = this._scrollIdexes?.ri || 0; 69 | const scrollCi = this._scrollIdexes?.ci || 0; 70 | sri += scrollRi; 71 | sci += scrollCi; 72 | eri += scrollRi; 73 | eci += scrollCi; 74 | 75 | // 先降级处理,把range打散为cell操作 之后引入rangemm统一管理 76 | // 遍历cellmm 给每一个range内的单元格挂上属性 77 | for (let i = sri; i <= eri; i++) { 78 | for (let j = sci; j <= eci; j++) { 79 | setCellmm.call(this, i, j, properties); 80 | } 81 | } 82 | }, 83 | scrollView(op: ScrollOperation): void { 84 | const { distance, isVertical } = op; 85 | const { row, col } = this._grid; 86 | let scrollOffset = 0; 87 | let scrollIdx = 0; 88 | const maxLen = isVertical ? row.len : col.len; 89 | const getCurSize = isVertical ? (i) => this.getRowHeight(i) : (i) => this.getColWidth(i); 90 | for (let i = 1; i < maxLen; i++) { 91 | const curSize = getCurSize(i - 1); 92 | if (scrollOffset + curSize > distance) break; 93 | scrollIdx = i; 94 | scrollOffset += curSize; 95 | } 96 | this._scrollOffset[isVertical ? 'y' : 'x'] = scrollOffset; 97 | this._scrollIdexes[isVertical ? 'ri' : 'ci'] = scrollIdx; 98 | this.computedGridMap(this._scrollIdexes); 99 | this._proxyViewdata.gridmap = this._computedgridmap; 100 | this._proxyViewdata.scrollIdexes = this._scrollIdexes; 101 | this.emit('canvas:scroll', { 102 | x: this._scrollOffset.x, 103 | y: this._scrollOffset.y, 104 | ri: this._scrollIdexes.ri, 105 | ci: this._scrollIdexes.ci 106 | }); 107 | }, 108 | // 行高列宽 109 | resizeGrid(op: ResizeOperation): void { 110 | const { diff, isCol, idx: targetIdx } = op; 111 | // 修改vdata 112 | const targetArr = this._proxyViewdata.gridmap[isCol ? 'col' : 'row']; 113 | each(targetArr, (item, i) => { 114 | if (targetIdx === i) { 115 | item[isCol ? 'width' : 'height'] += diff; 116 | } else if (i > targetIdx) { 117 | item[isCol ? 'left' : 'top'] += diff; 118 | } 119 | }); 120 | // 维护mdata 121 | this._mdata[isCol ? 'colm' : 'rowm'][targetIdx] = this._mdata[isCol ? 'colm' : 'rowm'][targetIdx] || {}; 122 | this._mdata[isCol ? 'colm' : 'rowm'][targetIdx].size = targetArr[targetIdx][isCol ? 'width' : 'height']; 123 | this.emit('canvas:resize', { 124 | key: `${isCol ? 'colm' : 'rowm'}[${targetIdx}]` 125 | }); 126 | } 127 | } -------------------------------------------------------------------------------- /src/model/mdata.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @file modelData 模型数据 3 | * mdata = computed(cfg) & computed(interactiondata) 4 | * 将众多的数据过滤、计算、重组,得到最精简的、最适合render的数据vdata供 render 渲染 5 | * 业务层要更改viewmodel引起渲染,通过 datamodel.command() 6 | * 业务层 通过接口访问mdata的数据 7 | */ 8 | import { 9 | Mdata, 10 | SourceData, 11 | GridIdxToOffsetMap, 12 | Rect, 13 | Point, 14 | GridMdata, 15 | RangeIndexes, 16 | RangeOffset, 17 | Range, 18 | ViewDataRange, 19 | ViewDataSource, 20 | ScrollIndexes, 21 | RectIndexes, 22 | Cell, 23 | ScrollOffset, 24 | TableStatus 25 | } from '../type/index'; 26 | import { _merge, draw, isObj, each } from '../utils/index'; 27 | import { Operation, Command } from './command'; 28 | import { ViewModel } from './vdata'; 29 | 30 | export const FIXEDHEADERMARGIN = { 31 | left: 50, 32 | top: 25, 33 | } 34 | 35 | export const BUFFERPADDING = 2; 36 | 37 | interface IDataModel { 38 | // 外界载入grid棋盘数据 如果没有主动调用 那会启用默认的生成棋盘格 39 | resetGrid: (grid: GridMdata) => GridMdata; 40 | // 当source被设置了关键数据更改的时候 会触发调用 41 | computedGridMap: (scroll: ScrollIndexes) => void; 42 | // 载入表格单元格数据 43 | source: (sdata: SourceData) => void; 44 | // 响应interation对viewmodel的修改 45 | command: (op: Operation) => void; 46 | // 根据鼠标坐标得到鼠标指向的单元格 47 | getIdxByPoint: (point: Point) => Rect; 48 | // 指定单元格: 逻辑索引 -> 单元格物理坐标 49 | getRangeOffsetByIdxes: (idxes: RangeIndexes) => RangeOffset; 50 | // 指定单元格: 逻辑索引 -> 单元格信息属性 51 | getCell: (point: RectIndexes) => Cell; 52 | // 指定行的行高 53 | getRowHeight: (index: number) => number; 54 | getColWidth: (index: number) => number; 55 | // 本该渲染的内容高度,用于scrollbar进度条显示 56 | getRealContentSize: (initgrid?: GridMdata) => number[]; 57 | getStatus: () => TableStatus; 58 | // 设置当前选中单元格 全局单例 59 | setSelect: (range: RangeIndexes) => RangeIndexes; 60 | // 向engine派发事件 61 | emit: (evt: string, ...args: any[]) => void; 62 | // 导入数据(暂时从本地的localstorage获取 63 | import: () => Mdata; 64 | // 导出数据 65 | export: () => string; 66 | } 67 | 68 | const defaultGridData = { 69 | row: { len: 100, size: 25, minsize: 25, }, 70 | col: { len: 25, size: 25, minsize: 30, }, 71 | colm: {}, 72 | rowm: {}, 73 | cellmm: {}, 74 | } 75 | 76 | export class DataModel implements IDataModel { 77 | // modeldata:视图数据、互动数据 计算出viewdata 78 | _mdata: Mdata; 79 | private _boxrealsize: number[]; 80 | // sourcedata:griddata => viewtdata: gridmap 81 | private _grid: GridMdata; 82 | 83 | // ==== viewdata 决定视图的渲染 ====// 84 | // gridmap 棋盘格布局数据 85 | private _computedgridmap: GridIdxToOffsetMap; 86 | // public rangemm: ViewDataRange = {}; 87 | private _initcellmm: ViewDataRange = {}; 88 | private _selectIdxes: RangeIndexes = null; 89 | private _scrollIdexes: ScrollIndexes = { ri: 0, ci: 0 }; 90 | private _scrollOffset: ScrollOffset = { x: 0, y: 0 }; 91 | private _viewModel: ViewModel; 92 | private _proxyViewdata: ViewDataSource; 93 | emit: (evt: string, ...args: any[]) => void; // 父传下来的方法 94 | 95 | private _getDefaultSource() { 96 | return { 97 | viewHeight: 800, 98 | viewWidth: 400, 99 | ...defaultGridData, 100 | scrollOffset: { x: 0, y: 0, }, 101 | } 102 | } 103 | constructor(viewmodel: ViewModel, data: Mdata) { 104 | this._selectIdxes = { sri: 1, sci: 1, eri: 1, eci: 1 }; 105 | this._viewModel = viewmodel; 106 | Promise.resolve().then(() => { 107 | this._init(data); 108 | }); 109 | } 110 | private _init(data: Mdata) { 111 | const defaultdata = this._getDefaultSource(); 112 | const _mdata = Object.assign(defaultdata, this._grid, data); 113 | const _mdatacellmm = _merge(_mdata.cellmm, this._initcellmm); 114 | _mdata.cellmm = _mdatacellmm; 115 | const localmdata = this.import(); 116 | this._mdata = _merge(_mdata, localmdata); 117 | this.computedGridMap(this._scrollIdexes); 118 | // 将计算出的vdata放入viewmodel:1.proxy对数据进行访问拦截 2. 绑定data-view之间的关系 119 | // 之后的交互action-view响应:修改this._viewdata即可 120 | 121 | // vdata = ViewTableSize + cellmm + scrollIdexes + gridmap 122 | // gridmap = f(mdata); 123 | 124 | // import(mdata) : ui = render(vdata = computed(mdata)) 125 | // export:(vdata) => mdata; vTom(vdata); 126 | // vTom函数:way1 diff f(originmdata) vs vdata反向得到操作 127 | // way2 在操作时 维护变化量 128 | // 先暂时采用way2 129 | this._proxyViewdata = this._viewModel.init({ 130 | gridmap: this._computedgridmap, 131 | cellmm: this._initcellmm, 132 | scrollIdexes: this._scrollIdexes, 133 | ...this._mdata, 134 | }); 135 | } 136 | resetGrid(grid: GridMdata): GridMdata { 137 | this._grid = _merge(defaultGridData, grid); 138 | this._boxrealsize = this.getRealContentSize(this._grid); 139 | return this._grid; 140 | } 141 | // 绘制基础的棋盘,并生成gripmap 142 | // 从source配置的行列信息 取得棋盘格的行高、列宽等信息 143 | computedGridMap(scroll: ScrollIndexes) { 144 | const [rowsumheight, row] = this._buildLinesForRows(true, scroll.ri); 145 | const [colsumwidth, col] = this._buildLinesForRows(false, scroll.ci); 146 | // gridmap棋盘格里记录的索引key是相对的 即当前视图内的scrollindex之后的 147 | this._computedgridmap = { 148 | fixedpadding: FIXEDHEADERMARGIN, 149 | rowsumheight, 150 | colsumwidth, 151 | row, 152 | col 153 | } 154 | } 155 | source(mdata: SourceData) { 156 | const { cellmm } = mdata; 157 | if (isObj(cellmm)) { 158 | this._initcellmm = cellmm; 159 | } 160 | } 161 | command(op: Operation): void { 162 | // this._proxyViewdata.xxx = xxx; 163 | return Command[op.type].call(this, op); 164 | } 165 | getIdxByPoint(point: Point): Rect { 166 | const { row, col } = this._proxyViewdata.gridmap; 167 | const targetCell: Rect = { 168 | ri: -1, 169 | ci: -1, 170 | left: 0, 171 | top: 0, 172 | width: 0, 173 | height: 0, 174 | } 175 | // TODO: perf: 二分查找 176 | // FIXME: 这样的算法算出来,当点位在中间位置时, ij与坐标系标注一致 177 | // 当无论是在包围盒的哪个边缘,都应该按在中间位置算的 178 | for (let i = 0; i < row.length; i++) { 179 | if (row[i].top < point.y && point.y <= row[i + 1].top) { 180 | // -1 是为了和cellmm的计数对齐,从0开始 181 | Object.assign(targetCell, row[i - 1]); 182 | } 183 | } 184 | for (let j = 0; j < col.length; j++) { 185 | if (col[j].left < point.x && point.x <= col[j + 1].left) { 186 | Object.assign(targetCell, col[j - 1]); 187 | } 188 | } 189 | if (targetCell.ci === -1 && targetCell.ri >= 0) { 190 | targetCell.width = this._computedgridmap.fixedpadding.left; 191 | targetCell.lineoffset = targetCell.top + this._computedgridmap.fixedpadding.top + targetCell.height; 192 | } else if (targetCell.ri === -1 && targetCell.ci >= 0) { 193 | targetCell.height = this._computedgridmap.fixedpadding.top; 194 | targetCell.lineoffset = targetCell.left + this._computedgridmap.fixedpadding.left + targetCell.width; 195 | } 196 | targetCell.left += BUFFERPADDING * 2; 197 | targetCell.top += BUFFERPADDING; 198 | 199 | return targetCell; 200 | } 201 | getRangeOffsetByIdxes(idxes: RangeIndexes): RangeOffset { 202 | return draw.getRangeOffsetByIdxes(this._proxyViewdata.gridmap, idxes); 203 | } 204 | getRange(): Range { 205 | if (!this._selectIdxes) return null; 206 | const offset = this.getRangeOffsetByIdxes(this._selectIdxes); 207 | return { 208 | ...this._selectIdxes, 209 | ...offset 210 | }; 211 | } 212 | getCell(point: RectIndexes): Cell { 213 | if (!this._proxyViewdata.cellmm[point.ri]) return null; 214 | return this._proxyViewdata.cellmm[point.ri][point.ci]; 215 | } 216 | // 绘制高度 217 | getSumHeight(): number { 218 | return this._computedgridmap.rowsumheight; 219 | } 220 | getSumWidth(): number { 221 | return this._computedgridmap.colsumwidth; 222 | } 223 | /** 224 | * 本该渲染的内容高度,用于scrollbar进度条显示 225 | * @param initgrid 226 | * @returns [rowsumheight, colsumwidth] 227 | */ 228 | getRealContentSize(initgrid?: GridMdata): number[] { 229 | if (this._boxrealsize) return this._boxrealsize; 230 | const grid = initgrid ? initgrid : this._grid; 231 | const { row, col, rowm, colm } = grid; 232 | let colsumwidth = col.len * col.size; 233 | let rowsumheight = row.len * row.size; 234 | each(colm, item => { 235 | colsumwidth -= col.size; 236 | colsumwidth += item.size; 237 | }); 238 | 239 | each(rowm, item => { 240 | rowsumheight -= col.size; 241 | rowsumheight += item.size; 242 | }); 243 | this._boxrealsize = [rowsumheight, colsumwidth]; 244 | return this._boxrealsize; 245 | } 246 | getRowHeight(index: number): number { 247 | const { row, rowm } = this._grid; 248 | return rowm[`${index}`] ? rowm[`${index}`].size : row.size; 249 | } 250 | getColWidth(index: number): number { 251 | const { col, colm } = this._grid; 252 | return colm[`${index}`] ? colm[`${index}`].size : col.size; 253 | } 254 | setSelect(range: RangeIndexes): RangeIndexes { 255 | this._selectIdxes = range; 256 | return range; 257 | } 258 | getStatus(): TableStatus { 259 | return { 260 | sumheight: this.getSumHeight(), 261 | sumwidth: this.getSumWidth(), 262 | rowminsize: this._mdata.row?.minsize, 263 | colminsize: this._mdata.col?.minsize, 264 | fixedColWidth: this._computedgridmap.fixedpadding.left, 265 | fixedRowHeight: this._computedgridmap.fixedpadding.top, 266 | scroll: { 267 | offsetX: this._scrollOffset.x, 268 | offsetY: this._scrollOffset.y, 269 | ri: this._scrollIdexes.ri, 270 | ci: this._scrollIdexes.ci 271 | } 272 | } 273 | } 274 | import(): Mdata { 275 | return JSON.parse(localStorage.getItem('excel-2021') || '{}') as unknown as Mdata; 276 | } 277 | export(): string { 278 | const compressedData = JSON.stringify(this._mdata); 279 | localStorage.setItem('excel-2021', compressedData); 280 | return compressedData; 281 | } 282 | private _buildLinesForRows(isRow: boolean, scrollIdx: number): [number, any[]] { 283 | const mdata = this._mdata; 284 | const rowLen = mdata[`${isRow ? 'row' : 'col'}`].len; 285 | const rowHeight = mdata[`${isRow ? 'row' : 'col'}`].size; 286 | const rowMaxHeight = mdata[`${isRow ? 'viewHeight' : 'viewWidth'}`]; 287 | // const rowWidth = mdata[`${isRow ? 'viewWidth' : 'viewHeight'}`]; 288 | let startY = 0; 289 | let endY = 0; 290 | let curheight = 0; 291 | const rowarr = []; 292 | // 先采用最简单的,最上面改的方式: 293 | // gridmap记录viewrange的单元格信息 294 | // gridmap[0] = 视觉上的0. 295 | // 实际是 gridmap.index = 整个表格的[scrollidx+index] 296 | // 滚动 -> gridmap重新计算 -> 整个表格render 297 | const rowAddedIdx = scrollIdx || 0; 298 | for (let i = 0; i < rowLen; i++) { 299 | const curSpRow = (mdata[`${isRow ? 'rowm' : 'colm'}`] || {})[i + rowAddedIdx]; 300 | const curRowHeight = curSpRow ? curSpRow.size : rowHeight; 301 | startY = endY += curRowHeight; 302 | curheight += curRowHeight; 303 | rowarr[i] = { 304 | [`${isRow ? 'ri' : 'ci'}`]: i, 305 | [`${isRow ? 'top' : 'left'}`]: startY - curRowHeight, 306 | [`${isRow ? 'height' : 'width'}`]: curRowHeight, 307 | }; 308 | if (curheight >= rowMaxHeight) { 309 | // 多生成一个假数据=cell[i] 主要是为了复用left变量 310 | rowarr[i + 1] = { 311 | [`${isRow ? 'ri' : 'ci'}`]: i + 1, 312 | [`${isRow ? 'top' : 'left'}`]: startY, 313 | [`${isRow ? 'height' : 'width'}`]: curRowHeight, 314 | } 315 | break; 316 | } 317 | } 318 | // 棋盘刚好被整除进 virtualview = realview 319 | return [curheight, rowarr]; 320 | } 321 | } -------------------------------------------------------------------------------- /src/model/vdata.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @file viewmodel: 视图层数据 串联render 和 controller 3 | * - _state:controller修改后的最新的viewdata的数据 4 | * - 代理访问拦截:scrollidxes变量更改后对viewdata的代理访问 5 | * - UI = render(viewmodel.state); vdata更改自动触发视图render 6 | * 7 | * this._proxyViewdata = this._viewModel.init({ 8 | gridmap: this._computedgridmap, // 基础网格数据 9 | cellmm: this._initcellmm, // 单元格信息 10 | scrollIdexes: this._scrollIdexes, // 控制滚动 11 | }); 12 | * 13 | */ 14 | import { ViewDataSource } from '../type'; 15 | import { CanvasRender } from '../view'; 16 | import { isObj } from '../utils'; 17 | 18 | const hasOwn = (val: any, key: any) => Object.prototype.hasOwnProperty.call(val, key); 19 | const originToProxy = new WeakMap(); // origin->proxy: 找到任何代理过的数据是否存在 20 | const proxyToOrigin = new WeakMap(); // 通过代理数据找到原始的数据 21 | 22 | export class ViewModel { 23 | public state: ViewDataSource; 24 | private _canvasRender: CanvasRender; 25 | 26 | constructor(canvasrender: CanvasRender) { 27 | this._canvasRender = canvasrender; 28 | } 29 | init(viewdataObj: ViewDataSource): ViewDataSource { 30 | this.state = this.proxy(viewdataObj, ''); 31 | this._canvasRender.$store = this.state; // 将viewdata挂在渲染器上,下面的数据不靠传参获得数据了 32 | // 首次需要手动调用render 33 | this._updateCanvasView(); 34 | return this.state; 35 | } 36 | // 首屏渲染前,对当前数据进行代理,然后渲染,得到依赖收集, 37 | // 同时数据代理方便 scroll数据的 viewdata控制 38 | proxy>(target: ViewDataSource, prefixKey?: PropertyKey): ViewDataSource { 39 | let observed = originToProxy.get(target) 40 | // 原数据已经有相应的可响应数据, 返回可响应数据 41 | if (observed !== void 0) { 42 | return observed; 43 | } 44 | // 原数据已经是可响应数据 45 | if (proxyToOrigin.has(target)) { 46 | return target; 47 | } 48 | // 新增数据 添加进proxy代理里 49 | observed = new Proxy(target, { 50 | get: (target: ViewDataSource, key: PropertyKey) => { 51 | // let curkey = key; 52 | // curkey = (prefixKey && prefixKey + '.') + key; 53 | const res = Reflect.get(target, key); 54 | // if (isNil(res)) { 55 | // res = Object.create(null); 56 | // res[key] = Object.create(null); 57 | // } 58 | if (isObj(res)) return this.proxy(res); 59 | return res; 60 | }, 61 | set: (target: ViewDataSource, key: PropertyKey, val: any) => { 62 | const hadKey = hasOwn(target, key); 63 | const oldValue = target[key] 64 | // 每一次的 proxy 数据,都会保存在 Map 中,访问时会直接从中查找,从而提高性能 65 | val = proxyToOrigin.get(val) || val 66 | 67 | const result = Reflect.set(target, key, val); 68 | // 通过 判断 key 是否为 target 自身属性,以及设置val是否跟target[key]相等 69 | // 可以确定 trigger 的类型,并且避免多余的 trigger 70 | if (!hadKey) { 71 | this._updateCanvasView(); 72 | } else if (val !== oldValue) { 73 | this._updateCanvasView(); 74 | } 75 | // 返回代理后的对象 76 | return result; 77 | } 78 | }) 79 | // origin -> proxy 80 | originToProxy.set(target, observed); 81 | // proxy -> origin 82 | proxyToOrigin.set(observed, target); 83 | return observed; 84 | } 85 | private _updateCanvasView() { 86 | this._canvasRender.render(); 87 | } 88 | } -------------------------------------------------------------------------------- /src/type/index.ts: -------------------------------------------------------------------------------- 1 | // style-range 2 | export type CellStyle = { 3 | bordersize?: number,// grid:1,fixedheader:0.5 4 | bordercolor?: string; 5 | borderstyle?: 'solid' | 'dash', 6 | bgcolor?: string; 7 | } 8 | 9 | // 单元格分为很多类型: 文本、下拉框、日期选择器 10 | // 单元格的样式、字号等都是由range来管理聚合来的 11 | // stylerange、textrange 12 | export type Cell = CellStyle & CellText & { 13 | // type: 'text' | 'select' | 'datepicker'; 14 | text?: string; 15 | } 16 | 17 | // 棋盘逻辑索引 18 | export type RectIndexes = { 19 | ri: number; 20 | ci: number; 21 | }; 22 | // 棋盘 & range 物理位置 23 | export type RectOffset = { 24 | left: number; 25 | top: number; 26 | width: number; 27 | height: number; 28 | lineoffset?: number; 29 | } 30 | 31 | // range 物理位置 32 | export type RangeOffset = { 33 | left: number; 34 | top: number; 35 | right?: number; 36 | bottom?: number; 37 | width: number; 38 | height: number; 39 | } 40 | 41 | export type Rect = RectIndexes & RectOffset; 42 | 43 | // range范围 44 | export type RangeIndexes = { 45 | sri: number; 46 | sci: number; 47 | eri: number; 48 | eci: number; 49 | } | null; 50 | 51 | export type Range = RangeIndexes & RectOffset; 52 | 53 | /*-------------- 54 | modeldata 55 | -------------*/ 56 | export type ScrollIndexes = { 57 | ri: number; 58 | ci: number; 59 | } 60 | 61 | type RowOrCol = { 62 | // style: Style; 收敛到range控制 63 | size: number; // 行高 | 列宽 64 | minsize?: number; // 最小压缩size 65 | len: number; // 总数 66 | } 67 | 68 | export type GridMdata = { 69 | row?: RowOrCol; 70 | col?: RowOrCol; 71 | // 特殊行总数、行高 72 | // {1: {1: {size:xx}}} 二维对象 73 | rowm?: Record>; 74 | // 特殊列总数、列宽 75 | colm?: Record>; 76 | } 77 | 78 | // 表格数据 79 | export type ViewDataRange = { 80 | // 表格合并的单元格集合 81 | // merges?: RangeIndexes[]; // [{}, {}] 82 | // key:range: '[[1,1],[2,2]]' 83 | // cell里的每个属性对应单独的range 84 | [key: string]: Cell; 85 | } 86 | 87 | export type SourceData = { 88 | cellmm?: Record>; 89 | // merges 90 | } 91 | 92 | export type ScrollOffset = { 93 | x: number; 94 | y: number; 95 | } 96 | 97 | export type Mdata = 98 | & ViewTableSize 99 | & GridMdata 100 | & SourceData; 101 | 102 | /*-------------- 103 | EngineOption 104 | -------------*/ 105 | export type ViewTableSize = { 106 | viewHeight: number; 107 | viewWidth: number; 108 | } 109 | 110 | export type ViewOption = ViewTableSize & { 111 | showToolbar: boolean; 112 | showCtxMenu: boolean; 113 | // tableStyle: GridStyle; 114 | } 115 | 116 | export type InteractOption = { 117 | // 允许编辑 118 | canEdit: boolean; 119 | // 框选功能 120 | // selectView: { 121 | // border: string; // 1px solid blue 122 | // background: string; 123 | // opacity: number; 124 | // } | false; 125 | } 126 | 127 | export type EngineOption = { 128 | container: HTMLElement | null; 129 | // 外观配置项 130 | viewOption: ViewOption; 131 | // 交互配置项 132 | interactOption: InteractOption; 133 | } 134 | 135 | /*-------------- 136 | canvas 137 | -------------*/ 138 | // 笔触的设置 139 | export type CanvasCtxAttrs = { 140 | globalAlpha?: number; 141 | fillstyle?: string; 142 | strokeStyle?: string; 143 | linewidth?: number; 144 | font?: string; 145 | // 复合 146 | bgcolor?: string; 147 | linecolor?: string; 148 | } & CellText; 149 | 150 | export type CellText = { 151 | font?: string, 152 | // 文字渲染规则:超出则换行且 若最后一行仍超出则省略 153 | // textWrap: 'wrap' | 'nowrap' | 'ellipsis'; 154 | /** 文本字体 */ 155 | fontFamily?: string; 156 | /** 文本字体大小 */ 157 | fontSize?: number; 158 | /** 文本粗细 */ 159 | fontWeight?: 'normal' | 'bold' | number; 160 | /** 字体样式: 斜体 */ 161 | fontStyle?: 'normal' | 'italic'; 162 | /** 设置在绘制文本时使用的当前文本基线 */ 163 | textAlign?: 'start' | 'center' | 'end' | 'left' | 'right'; 164 | textBaseline?: 'top' | 'hanging' | 'middle' | 'alphabetic' | 'ideographic' | 'bottom'; 165 | /** 字体装饰: 下划线、删除线 */ 166 | textDecoration?: 'underline' | 'line-through'; 167 | /** 文本颜色 */ 168 | fontColor?: string; 169 | /** 文本行高 */ 170 | lineHeight?: number; 171 | } 172 | 173 | // Cursor style 174 | // See: https://developer.mozilla.org/en-US/docs/Web/CSS/cursor 175 | export type Cursor = 176 | | 'auto' 177 | | 'default' 178 | | 'default' 179 | | 'col-resize' 180 | | 'row-resize' 181 | | 'crosshair' 182 | | 'none' 183 | | 'context-menu' 184 | | 'help' 185 | | 'pointer'; 186 | 187 | // drawall: init, scroll 188 | // range的command: merge、copy paste、 格式刷 189 | // 每次canvas重刷时候的重绘缘由 190 | export type CanvasChangeType = 191 | | 'select' 192 | | 'text' 193 | | 'scroll' 194 | | 'clear'; 195 | 196 | export type CanvasCfg = { 197 | container?: HTMLElement;// 容器 controller传下来的父容器dom 198 | width: number; // 画布宽度 199 | height: number; // 画布高度 200 | // pixelRatio: number; // dpr 用于高清屏适配 201 | }; 202 | 203 | export type Point = { 204 | x: number; 205 | y: number; 206 | } 207 | 208 | /*-------------- 209 | viewdata 210 | -------------*/ 211 | export type GridIdxToOffsetMap = { 212 | fixedpadding: { left: number, top: number }, 213 | rowsumheight: number, 214 | colsumwidth: number, 215 | row: Array<{ ri: number, top: number, height: number }> | [], 216 | col: Array<{ ci: number, left: number, width: number }> | [], 217 | }; 218 | 219 | // 表格渲染生命周期 220 | // export enum LifeCycle { 221 | // BeforeMount, 222 | // Mounted, 223 | // BeforeUpdate, 224 | // Updated, 225 | // } 226 | 227 | export type ViewDataSource = ViewTableSize & { 228 | // gridmap 棋盘格布局数据 229 | gridmap: GridIdxToOffsetMap; 230 | cellmm: ViewDataRange; 231 | // selectIdxes: RangeIndexes; 232 | scrollIdexes: ScrollIndexes; 233 | } 234 | 235 | export type Boxsize = { 236 | viewH: number; 237 | viewW: number; 238 | contentH: number; 239 | contentW: number; 240 | } 241 | 242 | export type TableStatus = { 243 | sumheight: number; 244 | sumwidth: number; 245 | rowminsize: number; 246 | colminsize: number; 247 | fixedColWidth: number; 248 | fixedRowHeight: number; 249 | scroll: { 250 | offsetX: number; 251 | offsetY: number; 252 | ri: number; 253 | ci: number; 254 | } 255 | } -------------------------------------------------------------------------------- /src/utils/canvas-util/draw.ts: -------------------------------------------------------------------------------- 1 | // drawline 2 | // mergeRange 3 | // applyAttrsToContext 4 | 5 | 6 | // 考虑把每个图形都包裹一下? 7 | // 所有的几何图形需要支持以下计算: 8 | // • 包围盒,用于裁剪和快速拾取 9 | // • 点到图形的距离(点是否在线上):用于边(stroke)的拾取 10 | // • 极值点,用于计算包围盒 11 | // • 点是否在图形内,用于拾取填充 12 | // • 长度(周长):用户 lineDash 动画计算 和 按照比例获取点 13 | // • 按照比例获取点:给出一个比例值获取对应的点,用于沿着图形的运动动画以及文本定位等 14 | // • 指定点的切线:绘制箭头 15 | import { RangeIndexes, RectOffset, RangeOffset, GridIdxToOffsetMap } from '../../type/index'; 16 | export const isColorProp = (prop: string): boolean => { 17 | return ['fillStyle', 'strokeStyle'].includes(prop); 18 | }; 19 | 20 | export const getOffsetByIdx = (gridmap: GridIdxToOffsetMap, ri: number, ci: number): RectOffset => { 21 | const fixedRowHeight = gridmap.fixedpadding.top; 22 | const fixedColWidth = gridmap.fixedpadding.left; 23 | // ri = -1 24 | if (ri === -1 && ci === -1) { 25 | return { 26 | left: -fixedColWidth, 27 | top: -fixedRowHeight, 28 | width: fixedColWidth, 29 | height: fixedRowHeight, 30 | } 31 | } 32 | // 行索引栏 33 | if (ri === -1) { 34 | const cellLeft = gridmap.col[ci].left; 35 | const cellWidth = gridmap.col[ci].width; 36 | return { 37 | left: cellLeft, 38 | top: -fixedRowHeight, 39 | width: cellWidth, 40 | height: fixedRowHeight, 41 | } 42 | } 43 | else if (ci === -1) { 44 | const cellTop = gridmap.row[ri].top; 45 | const cellHeight = gridmap.row[ri].height; 46 | return { 47 | left: -fixedColWidth, 48 | top: cellTop, 49 | width: fixedColWidth, 50 | height: cellHeight, 51 | } 52 | } 53 | const cellLeft = gridmap.col[ci].left; 54 | const cellWidth = gridmap.col[ci].width; 55 | const cellTop = gridmap.row[ri].top; 56 | const cellHeight = gridmap.row[ri].height; 57 | return { 58 | left: cellLeft, 59 | top: cellTop, 60 | width: cellWidth, 61 | height: cellHeight, 62 | } 63 | } 64 | 65 | // 传入单元格范围:[1,1]~[3,3]即1,1的左上角~3,3的右下角,即4,4的左上角 66 | export const getRangeOffsetByIdxes = (gridmap: GridIdxToOffsetMap, rect: RangeIndexes): RangeOffset => { 67 | const { sri, sci, eri, eci } = rect; 68 | const sRect = getOffsetByIdx(gridmap, sri, sci); 69 | const isOneCell = eri === sri && eci === sci; 70 | if (isOneCell) { 71 | return { 72 | ...sRect, 73 | right: sRect.left + sRect.width, 74 | bottom: sRect.top + sRect.height, 75 | } 76 | } else { 77 | const eRect = getOffsetByIdx(gridmap, eri + 1, eci + 1); 78 | return { 79 | left: sRect.left, 80 | top: sRect.top, 81 | right: eRect.left, 82 | bottom: eRect.top, 83 | width: eRect.left - sRect.left, 84 | height: eRect.top - sRect.top, 85 | } 86 | } 87 | } -------------------------------------------------------------------------------- /src/utils/canvas-util/offscreen.ts: -------------------------------------------------------------------------------- 1 | // 全局设置一个唯一离屏的 ctx,用于计算:文本高度等 2 | let offScreenCtx = null; 3 | export function getOffScreenContext(): CanvasRenderingContext2D { 4 | if (!offScreenCtx) { 5 | const canvas = document.createElement('canvas'); 6 | canvas.width = 1; 7 | canvas.height = 1; 8 | offScreenCtx = canvas.getContext('2d'); 9 | } 10 | return offScreenCtx; 11 | } -------------------------------------------------------------------------------- /src/utils/canvas-util/text.ts: -------------------------------------------------------------------------------- 1 | import { getOffScreenContext } from './offscreen'; 2 | import { CellText } from '../../type'; 3 | 4 | export function assembleFont(attrs: CellText) { 5 | const { fontSize, fontFamily, fontWeight, fontStyle } = attrs; 6 | return [fontStyle, fontWeight, `${fontSize}px`, fontFamily].join(' ').trim(); 7 | } 8 | 9 | /** 10 | * 获取文本的渲染宽度 11 | * @param text 12 | * @param font 13 | * @returns width 14 | */ 15 | export function getTextWidth(text: string, font: string): number { 16 | const context = getOffScreenContext(); 17 | context.save(); 18 | context.font = font; 19 | const width = context.measureText(text).width; 20 | context.restore(); 21 | return width; 22 | } -------------------------------------------------------------------------------- /src/utils/dom-util/index.ts: -------------------------------------------------------------------------------- 1 | import { isString } from "../index"; 2 | 3 | export function modifyCSS(dom: HTMLElement, css: { [key: string]: any }): HTMLElement { 4 | if (!dom) { 5 | return; 6 | } 7 | for (const key in css) { 8 | if (css.hasOwnProperty(key)) { 9 | let val = css[key]; 10 | if (['width', 'height', 'top', 'left', 'right', 'bottom'].includes(key)) { 11 | val = isString(val) && val.endsWith('px') ? val : `${val}px`; 12 | } 13 | dom.style[key] = val; 14 | } 15 | } 16 | return dom; 17 | } 18 | 19 | export function createDom(str: string): any { 20 | const container = document.createElement('div'); 21 | str = str.replace(/(^\s*)|(\s*$)/g, ''); 22 | container.innerHTML = '' + str; 23 | const dom = container.childNodes[0]; 24 | container.removeChild(dom); 25 | return dom; 26 | } 27 | 28 | export function addEventListener(target: HTMLElement, eventType: string, callback: any) { 29 | if (!target) return; 30 | if (typeof target.addEventListener === 'function') { 31 | target.addEventListener(eventType, callback, false); 32 | // 用于清除 destroy 33 | return { 34 | remove() { 35 | target.removeEventListener(eventType, callback, false); 36 | }, 37 | }; 38 | } 39 | } -------------------------------------------------------------------------------- /src/utils/index.ts: -------------------------------------------------------------------------------- 1 | export * from './is-type'; 2 | export * as draw from './canvas-util/draw'; 3 | export * from './canvas-util/text'; 4 | export * from './util'; 5 | export { getOffScreenContext } from './canvas-util/offscreen'; 6 | export * from './dom-util'; -------------------------------------------------------------------------------- /src/utils/is-type.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @file 判断变量数据类型 3 | */ 4 | 5 | const isType = (value: any, type: string): boolean => { 6 | return {}.toString.call(value) === '[object ' + type + ']'; 7 | }; 8 | 9 | export const isString = (str: any): boolean => { 10 | return isType(str, 'String'); 11 | }; 12 | 13 | export const isFunction = (val: any): boolean => { 14 | return isType(val, 'Function'); 15 | }; 16 | 17 | export const isObj = (val: any): boolean => { 18 | /** 19 | * isObject({}) => true 20 | * isObject([1, 2, 3]) => true 21 | * isObject(Function) => false 22 | * isObject(null) => false 23 | */ 24 | const type = typeof val; 25 | return val !== null && type === 'object'; 26 | }; 27 | 28 | export const isNull = function (value): value is null { 29 | return value === null; 30 | }; 31 | 32 | // 是null 或者 undefined 33 | export const isNil = function (value: any): value is null | undefined { 34 | /** 35 | * isNil(null) => true 36 | * isNil() => true 37 | */ 38 | return value === null || value === undefined; 39 | }; 40 | 41 | export const isArray = function (value: any): value is Array { 42 | return Array.isArray ? 43 | Array.isArray(value) : 44 | isType(value, 'Array'); 45 | } -------------------------------------------------------------------------------- /src/utils/log.js: -------------------------------------------------------------------------------- 1 | /** 2 | * 性能监控的打点 3 | */ 4 | // canvas绘制的相关数据 5 | export function performanceLog() { 6 | const entries = performance.getEntriesByType('measure'); 7 | for (const entry of entries) { 8 | console.table(entry.toJSON()); 9 | } 10 | performance.clearMarks(); 11 | performance.clearMeasures(); 12 | } 13 | 14 | // 监听dom 15 | export function mutationObserverDoc($target) { 16 | const observer = new MutationObserver(mutationsList => { 17 | console.log(`时间:${performance.now()},DOM树发生了变化!有以下变化类型:`); 18 | for (const mutation of mutationsList) { 19 | if (mutation.type === 'childList') { 20 | console.log('A child node has been added or removed.'); 21 | } 22 | else if (mutation.type === 'attributes') { 23 | console.log('The ' + mutation.attributeName + ' attribute was modified.'); 24 | } 25 | } 26 | }); 27 | observer.observe($target, { 28 | attributes: true, 29 | childList: true, 30 | subtree: true, 31 | }); 32 | } -------------------------------------------------------------------------------- /src/utils/util.ts: -------------------------------------------------------------------------------- 1 | import { isObj, isArray } from './index'; 2 | import { RangeIndexes } from '../type'; 3 | // 旧值依次挂在新值上 4 | // 与Object.assign的区别是:不会因为newObj没有x属性而直接替换oldObj后使得oldObj.x为空 5 | export function _merge | any>(oldObj: T, newObj: T): T { 6 | const resObj: T = {} as T; 7 | for (const key in oldObj) { 8 | if (oldObj[key] !== undefined) { 9 | resObj[key] = oldObj[key]; 10 | } 11 | } 12 | 13 | for (const key in newObj) { 14 | if (newObj[key] === undefined) { 15 | continue; 16 | } 17 | const oldval = resObj[key]; 18 | const newval = newObj[key]; 19 | if (isObj(oldval) && isObj(newval)) { 20 | resObj[key] = _merge(oldval, newval); 21 | } 22 | else { 23 | resObj[key] = newval; 24 | } 25 | } 26 | return resObj; 27 | } 28 | 29 | export function getRangeKey(rowkey: string, colkey: string): string { 30 | // return `[[${rowkey},${colkey}],[${rowkey},${colkey}]]`; 31 | return JSON.stringify({ sri: +rowkey, sci: +colkey, eri: +rowkey, eci: +colkey }); 32 | } 33 | 34 | export function parseRangeKey(rangekey: string): RangeIndexes { 35 | return JSON.parse(rangekey); 36 | } 37 | 38 | export function each(elements: any[] | object, func: (v: any, k: any) => any): void { 39 | if (!elements) { 40 | return; 41 | } 42 | if (isArray(elements)) { 43 | for (let i = 0, len = elements.length; i < len; i++) { 44 | func(elements[i], i); 45 | } 46 | } 47 | else if (isObj(elements)) { 48 | for (const k in elements) { 49 | if (elements.hasOwnProperty(k)) { 50 | func(elements[k], k); 51 | } 52 | } 53 | } 54 | } 55 | 56 | export const isBetween = (value: number, min: number, max: number) => value >= min && value <= max; -------------------------------------------------------------------------------- /src/view/editor/index.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @file 单元格上的输入框 3 | * - 附着于selector框上,纯UI组件,行为由selector控制 4 | */ 5 | import { modifyCSS } from '../../utils'; 6 | import { RectOffset } from '../../type'; 7 | export class Editor { 8 | protected text: string; 9 | protected $editor: HTMLElement; 10 | onEdit: (acur: string, prev: string) => void; 11 | createRender() { 12 | return ` 13 | 16 | `; 17 | } 18 | show(rect) { 19 | const $editor = document.querySelector('.xexcel-editor') as HTMLElement; 20 | this.$editor = $editor; 21 | this.initVal(rect.text); 22 | modifyCSS($editor, { visibility: 'visible' }); 23 | modifyCSS($editor, rect); 24 | } 25 | changeOffset(rect: RectOffset) { 26 | modifyCSS(this.$editor, rect); 27 | } 28 | hide() { 29 | const $editor = document.querySelector('.xexcel-editor') as HTMLElement; 30 | modifyCSS($editor, { visibility: 'hidden' }); 31 | } 32 | initEvent() { 33 | const $textarea = document.querySelector('.xexcel-editor textarea') as HTMLElement; 34 | $textarea.addEventListener('input', (ev: Event) => { 35 | const curstr = (ev.target).value as string; 36 | this.onEdit(curstr, this.text); 37 | this.text = curstr; 38 | }) 39 | } 40 | move(scrollx: number, scrolly: number) { 41 | modifyCSS(this.$editor, { 42 | transform: `translate3d(-${scrollx}px, -${scrolly}px, 0)` 43 | }); 44 | } 45 | initVal(val: string) { 46 | this.text = val; 47 | const $textarea = document.querySelector('.xexcel-editor textarea') as HTMLElement; 48 | $textarea.value = val; 49 | this._setTextareaRange(); 50 | } 51 | // 光标聚焦在文字末尾 52 | private _setTextareaRange() { 53 | const posi = this.text?.length || 0; 54 | const $textarea = document.querySelector('.xexcel-editor textarea') as unknown as HTMLTextAreaElement; 55 | $textarea.setSelectionRange(posi, posi); 56 | setTimeout(() => { 57 | $textarea.focus(); 58 | }, 0); 59 | } 60 | } -------------------------------------------------------------------------------- /src/view/index.ts: -------------------------------------------------------------------------------- 1 | export { CanvasRender } from './render/canvas'; 2 | export { DomRender } from './render/dom'; -------------------------------------------------------------------------------- /src/view/rangeman/fixedheader-range.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @file 固定的行列索引栏 3 | * - init: 根据gridmap绘制 4 | * - resizer:行列伸缩 5 | * - select:选中 对应行列的表头块高亮 6 | * - mousedown:辅助线 7 | */ 8 | import { GridIdxToOffsetMap, RectOffset, ScrollIndexes, Point } from '../../type'; 9 | import { RangeRenderController } from './index'; 10 | import { BaseRange } from '../../abstract/range-base'; 11 | import { TextRange } from './text-range'; 12 | import alphabet from '../../config/alphabet'; 13 | import { FIXEDHEADERMARGIN } from '../../model/mdata'; 14 | 15 | export class FixedHeaderRange extends BaseRange { 16 | namespace: string; 17 | // 画布全局状态:滚动距离、画布大小 18 | private _scrollIdexes: ScrollIndexes; 19 | private _rowheadrect: RectOffset; // 绘制的区域 20 | private _colheadrect: RectOffset; // 绘制的区域 21 | private _fixedheadermargin: { left: number; top: number; }; 22 | private _gridmap: GridIdxToOffsetMap; 23 | private _rect: RectOffset; // 绘制的区域 24 | private _textRange: TextRange; // 绘制文字的工具类 25 | 26 | getDefaultCfg() { 27 | return { 28 | style: { 29 | bgcolor: '#f4f5f8', 30 | linewidth: .5, 31 | linecolor: '#d0d0d0', 32 | text: { 33 | fontColor: '#585757', 34 | fontSize: 14, 35 | fontFamily: 'sans-serif', 36 | }, 37 | }, 38 | }; 39 | } 40 | constructor( 41 | rangecontroller: RangeRenderController 42 | ) { 43 | super(rangecontroller); 44 | this._fixedheadermargin = FIXEDHEADERMARGIN; 45 | this._textRange = new TextRange(rangecontroller, this._cfg.style.text); 46 | } 47 | _resetdata() { 48 | this._gridmap = this._canvas.$store.gridmap; 49 | this._scrollIdexes = this._canvas.$store.scrollIdexes; 50 | this._rect = this._canvas.getViewRange(); 51 | this._rowheadrect = { 52 | left: this._fixedheadermargin.left, 53 | top: 0, 54 | width: this._rect.width, 55 | height: this._fixedheadermargin.top, 56 | }; 57 | this._colheadrect = { 58 | left: 0, 59 | top: this._fixedheadermargin.top, 60 | width: this._fixedheadermargin.left, 61 | height: this._rect.height, 62 | }; 63 | } 64 | render() { 65 | this._resetdata(); 66 | this._ctx.save(); 67 | this._ctx.translate(-this._fixedheadermargin.left, -this._fixedheadermargin.top); 68 | this._canvas.drawRegion(this._rowheadrect, this._renderHeader.bind(this, true)); 69 | this._canvas.drawRegion(this._colheadrect, this._renderHeader.bind(this, false)); 70 | this._ctx.restore(); 71 | } 72 | _renderHeader(isRow: boolean) { 73 | const { left, top, width, height } = this[`${isRow ? '_rowheadrect' : '_colheadrect'}`]; 74 | const col = this._gridmap[`${isRow ? 'col' : 'row'}`]; 75 | const startIdx = this._scrollIdexes[`${isRow ? 'ci' : 'ri'}`]; 76 | const context = this._ctx; 77 | context.save(); 78 | this._canvas.applyAttrToCtx({ ...this._style }); 79 | context.fillRect(left, top, width, height); 80 | let curx = isRow ? left : top; 81 | const key = isRow ? 'width' : 'height'; 82 | for (let idx = 0; idx < col.length; idx++) { 83 | const item = col[idx]; 84 | const fixedSize = item[key]; 85 | if (isRow) { 86 | this._canvas.drawLine({ x: curx, y: top }, { x: curx, y: top + height }); 87 | this._textRange.draw({ 88 | left: curx, 89 | top: top, 90 | width: fixedSize, 91 | height: height, 92 | }, this._getText(true, idx + startIdx)); 93 | } 94 | else { 95 | this._canvas.drawLine({ x: left, y: curx }, { x: left + width, y: curx }); 96 | this._textRange.draw({ 97 | left: left, 98 | top: curx, 99 | width: width, 100 | height: fixedSize, 101 | }, this._getText(false, idx + startIdx)); 102 | } 103 | curx += fixedSize; 104 | } 105 | context.restore(); 106 | } 107 | _getText(isRow: boolean, idx: number): string { 108 | if (!isRow) return (idx + 1).toString(); 109 | return alphabet.stringAt(idx); 110 | } 111 | } -------------------------------------------------------------------------------- /src/view/rangeman/grid-range.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @file: 表格底层网格绘制 3 | * 用于: 4 | * - 首次渲染表格网格全局绘制 5 | * - 局部range刷新时,clearRect后,重绘range范围的grid 6 | * TODO:api:command层 7 | * setRange(RangeIndexes).source(gridRangeViewData).gridRender(); // 直接用全局的 opts 8 | * setRange(RangeIndexes).gridRender({linewidth:2});// 局部渲染 & 修改部分设置 9 | * setRange(sri:-1, sci:-1).gridRender() // 全局重绘 10 | */ 11 | import { GridIdxToOffsetMap, RectOffset, RangeIndexes } from '../../type'; 12 | import { RangeRenderController } from './index'; 13 | import { BaseRange } from '../../abstract/range-base'; 14 | import { draw } from '../../utils'; 15 | import { FIXEDHEADERMARGIN } from '../../model/mdata'; 16 | interface IGridRange { 17 | render: (gridmap: GridIdxToOffsetMap) => void; 18 | } 19 | 20 | export class GridRange extends BaseRange implements IGridRange { 21 | readonly namespace = 'grid-range'; 22 | // 画布全局状态:滚动距离、画布大小 23 | private _fixedheadermargin: { left: number; top: number; }; // 为索引栏绘制预留区域 24 | 25 | private _gridmap: GridIdxToOffsetMap; 26 | private _rect: RectOffset; // 绘制的区域 27 | private _range: RangeIndexes; // 当前range绘制的所在gridmap的逻辑索引 28 | getDefaultCfg() { 29 | return { 30 | style: { 31 | bgcolor: '#fff', 32 | linewidth: 1, 33 | linecolor: '#333333', 34 | }, 35 | }; 36 | } 37 | constructor(rangecontroller: RangeRenderController) { 38 | super(rangecontroller); 39 | this._fixedheadermargin = FIXEDHEADERMARGIN; 40 | } 41 | // 在render处初始化 this上的数据:rect、gridmap 42 | render(): void { 43 | this._gridmap = this._canvas.$store.gridmap; 44 | // 局部重绘grid 45 | // if (range) { 46 | // this._range = range; 47 | // this._rect = draw.getRangeOffsetByIdxes(this._gridmap, range); 48 | // this._canvas.drawRegion(this._rect, this._renderDetail.bind(this)); 49 | // } 50 | this._range = { sri: 0, eri: this._gridmap.row.length - 2, sci: 0, eci: this._gridmap.col.length - 2 }; 51 | this._rect = this._canvas.getViewRange(); 52 | this._renderAll(); 53 | } 54 | private _renderAll() { 55 | this._ctx.translate(this._fixedheadermargin.left, this._fixedheadermargin.top); 56 | this._renderDetail(); 57 | } 58 | // range不传是全部重绘 59 | private _renderDetail() { 60 | this._renderGridBg(); 61 | this._renderGridLines(); 62 | } 63 | private _renderGridBg() { 64 | const context = this._ctx; 65 | context.save(); 66 | const { left, top, width, height } = this._rect; 67 | this._canvas.clearRect(left, top, width, height); 68 | this._canvas.applyAttrToCtx({ bgcolor: this._style.bgcolor }); 69 | context.fillRect(left, top, width, height); 70 | context.restore(); 71 | } 72 | private _renderGridLines() { 73 | const context = this._ctx; 74 | context.save(); 75 | this._canvas.applyAttrToCtx({ 76 | linecolor: this._style.linecolor, 77 | linewidth: this._style.linewidth, 78 | }); 79 | // 开始遍历画线 80 | this._drawLines(); 81 | context.restore(); 82 | } 83 | // canvas绘制grid棋盘格:先画多行的横线,再画多列的竖线 84 | private _drawLines() { 85 | const renderange = this._range; 86 | const { sri, sci, eci, eri } = renderange; 87 | const { left, top, right, bottom, width, height } = draw.getRangeOffsetByIdxes(this._gridmap, renderange); 88 | // this._canvas.clearRect(left, top, width, height); 89 | // 画横线 90 | for (let i = sri; i <= eri + 1; i++) { 91 | const rowy = this._gridmap.row[i].top; 92 | this._canvas.drawLine({ x: left, y: rowy }, { x: right, y: rowy }); 93 | } 94 | // 画竖线 95 | for (let i = sci; i <= eci + 1; i++) { 96 | const colx = this._gridmap.col[i].left; 97 | this._canvas.drawLine({ x: colx, y: top }, { x: colx, y: bottom }); 98 | } 99 | } 100 | } -------------------------------------------------------------------------------- /src/view/rangeman/index.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @file 表格的渲染能力:可跨cell 与command维护的rangemm一一对应 3 | * rangeman是一个分层概念,将具体的绘制动作拆解,而不是都写在rendercontent里 4 | * RangeRenderController是对单独的range,进行管理的功能,注入细节range需要的变量,以及控制range的绘制方式(全局or局部) 5 | * - 网格: grid-range = gridrange + fixedheader-range 6 | * - 单元格样式内容 cell-range = stylerange + textrange 7 | * - 选区 selector-range 8 | * - 合并单元格 merge-range 9 | * - 条件格式 formula-range 10 | * - 格式刷 copypaint-range 11 | */ 12 | import { GridRange } from './grid-range'; 13 | import { FixedHeaderRange } from './fixedheader-range'; 14 | import { TextRange } from './text-range'; 15 | import { StyleRange } from './style-range'; 16 | import { _merge, draw } from '../../utils'; 17 | import { Cell, ViewDataSource } from '../../type'; 18 | import { CanvasRender } from '..'; 19 | 20 | export class RangeRenderController { 21 | private _gridRange: GridRange; 22 | private _fixedHeaderRange: FixedHeaderRange; 23 | private _textRange: TextRange; 24 | private _styleRange: StyleRange; 25 | 26 | canvas: CanvasRender; 27 | public viewdata: ViewDataSource; 28 | constructor(canvas: CanvasRender) { 29 | this.canvas = canvas; 30 | // 子range设计成可以拿父实例是为了:1.可以直接使用笔触 2.直接使用父处理好的数据 3.向上通知父 31 | this._gridRange = new GridRange(this); 32 | this._fixedHeaderRange = new FixedHeaderRange(this); 33 | this._textRange = new TextRange(this); 34 | this._styleRange = new StyleRange(this); 35 | } 36 | render() { 37 | this.viewdata = this.canvas.$store; 38 | this._renderGrid(); 39 | this._renderCells(); // cellmm 40 | // this.renderMerge(); 41 | } 42 | _renderGrid() { 43 | const renderList = [this._gridRange, this._fixedHeaderRange]; 44 | for (const range of renderList) { 45 | range.render(); 46 | } 47 | } 48 | _renderCells() { 49 | const cellmm = this.viewdata.cellmm; 50 | const { ri: scrollIdxY, ci: scrollIdxX } = this.viewdata.scrollIdexes; 51 | // 绝对逻辑索引去拿单元格信息,相对逻辑索引找位置 52 | for (const rowkey in cellmm) { 53 | const colMaps = cellmm[rowkey]; 54 | const renderkeyri = +rowkey - scrollIdxY; 55 | if (renderkeyri >= 0 && colMaps) { 56 | if (colMaps) { 57 | for (const colkey in colMaps) { 58 | const renderContent = colMaps[colkey]; 59 | const renderkeyci = +colkey - scrollIdxX; 60 | // TODO: 结合merge变量 综合 range的起始 61 | // const rangekey = getRangeKey(rowkey, colkey); 62 | // this.cellmm[rangekey]存的是当前range有的所有特殊属性 63 | // 默认属性的 保留在range实例里无需单独设置 64 | if (renderkeyci >= 0 && renderContent) { 65 | this._renderCellmm(renderkeyri, renderkeyci, renderContent); 66 | } 67 | } 68 | } 69 | } 70 | } 71 | } 72 | _renderCellmm(rowkey: number, colkey: number, rangedata: Cell) { 73 | const { gridmap } = this.viewdata; 74 | const rectOffset = draw.getOffsetByIdx(gridmap, rowkey, colkey); 75 | // action:注册behavior、registerview 76 | // 性能优化:首屏渲染离线优化 77 | this.canvas.drawRegion(rectOffset, () => { 78 | this._styleRange.render(rectOffset, rangedata); 79 | this._textRange.render(rectOffset, rangedata); 80 | }); 81 | } 82 | } -------------------------------------------------------------------------------- /src/view/rangeman/style-range.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @file 单元格样式 3 | * - border 4 | * - bgcolor 5 | * 绘制方式:传入当前cell的全部style属性,即为newcell的style 6 | * 全局绘制style:border、bg每次render都重新绘制 7 | */ 8 | 9 | // 功能: 10 | // 给指定range加border、bg 11 | // 设计纠结点: 12 | // 需不需要开放 当前range去访问 cell已有属性的能力? 13 | // 还是说由controller层 拿到最终的cell上的属性注入给range进行渲染即可? 14 | // 那如果是当成纯ui组件设计的话:text、style基本是这样的 15 | // 不过text的时候 renderbox大小是减去了border的,应该修改下 16 | 17 | import { RectOffset, CellStyle } from '../../type'; 18 | import { RangeRenderController } from './index'; 19 | import { BaseRange } from '../../abstract/range-base'; 20 | 21 | export const MAX_BORDER_SIZE = 3; 22 | interface IStyleRange { 23 | render: ( 24 | rect: RectOffset, 25 | style: CellStyle, 26 | ) => void; 27 | } 28 | 29 | export class StyleRange extends BaseRange implements IStyleRange { 30 | readonly namespace = 'style-range'; 31 | private _rect: RectOffset; 32 | // 普通单元格的style 33 | getDefaultCfg() { 34 | return { 35 | bordersize: 1, 36 | bordercolor: '#333333', 37 | borderstyle: 'solid', 38 | bgcolor: '#fff', 39 | }; 40 | } 41 | constructor( 42 | rangecontroller: RangeRenderController, 43 | cfg?: CellStyle 44 | ) { 45 | super(rangecontroller, cfg); 46 | } 47 | // 传入range的idxes,遍历挨个cell加style 48 | // renderCell() 49 | // 由于无法知道增量,所以每次style都是全局重绘:包括border和bg 50 | render( 51 | rect: RectOffset, 52 | style: CellStyle, // 最终单元格的样式 53 | ): void { 54 | this._rect = rect; 55 | this._setObj(style); 56 | // TODO: 将底层图形drawImage得到 57 | this._draw(); 58 | // this._canvas.drawRegion(rect, this._draw.bind(this), { clear: true }); 59 | // 恢复原始设置 避免污染下次draw笔触 60 | this._setObj(this.getDefaultCfg()); 61 | } 62 | _draw() { 63 | const bordersize = this.get('bordersize') > MAX_BORDER_SIZE ? MAX_BORDER_SIZE : this.get('bordersize'); 64 | const border = [ 65 | bordersize, 66 | this.get('borderstyle'), 67 | this.get('bordercolor'), 68 | ].join(' '); 69 | this._canvas.drawRect(this._rect, this.get('bgcolor'), border); 70 | } 71 | } -------------------------------------------------------------------------------- /src/view/rangeman/text-range.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @file: 在指定range绘制文字 3 | * 文字渲染规则:超出则换行,若最后一行仍超出box则省略号 4 | */ 5 | 6 | // setRange(xxx).fontColor = 'xx'; 就会触发 该range的textrange重绘 7 | import { RectOffset, CellText } from '../../type'; 8 | import { RangeRenderController } from './index'; 9 | import { BaseRange } from '../../abstract/range-base'; 10 | import { assembleFont, getTextWidth } from '../../utils'; 11 | import { MAX_BORDER_SIZE } from './style-range'; 12 | 13 | type UpdateText = CellText & { text?: string }; 14 | interface ITextRange { 15 | render: ( 16 | rect: RectOffset, 17 | params: UpdateText, 18 | ) => void; 19 | draw: ( 20 | rect: RectOffset, 21 | text: string, 22 | ) => void; 23 | } 24 | 25 | export class TextRange extends BaseRange implements ITextRange { 26 | readonly namespace = 'text-range'; 27 | // todo: 删除线、下划线 貌似要自己画线 28 | getDefaultCfg() { 29 | return { 30 | fontColor: '#000', 31 | fontSize: 12, 32 | fontFamily: 'sans-serif', 33 | fontWeight: 'normal', 34 | fontStyle: 'normal', 35 | // textAlign: 'center', 36 | // textBaseline: 'middle', 37 | cellPadding: 4, 38 | lineHeight: 12 * 0.14, 39 | }; 40 | } 41 | constructor( 42 | rangecontroller: RangeRenderController, 43 | cfg?: CellText 44 | ) { 45 | super(rangecontroller, cfg); 46 | this._setFont(); 47 | } 48 | // render():index索引、clip裁剪、apply属性设置等等 assembleFont 49 | // 可以只改属性 也可以只改文字 50 | render( 51 | rect: RectOffset, 52 | params: UpdateText = { text: '' }, 53 | ): void { 54 | // 上面将idx对应的text、坐标等拿到传下来 55 | const { text } = params; 56 | if (!text) return; 57 | this._setObj(params); 58 | this._setFont(); 59 | // 减去border的影响 60 | const contentRect = { 61 | left: rect.left + MAX_BORDER_SIZE, 62 | top: rect.top + MAX_BORDER_SIZE, 63 | width: rect.width - MAX_BORDER_SIZE * 2, 64 | height: rect.height - MAX_BORDER_SIZE * 2, 65 | } 66 | this.draw(rect, text); 67 | // this._canvas.drawRegion(contentRect, this.draw.bind(this, rect, text)); 68 | this._setObj(this.getDefaultCfg()); 69 | } 70 | // draw():header时 new的时候的cfg已设置 直接draw文字 71 | draw( 72 | rect: RectOffset, 73 | text: string, 74 | ): void { 75 | const context = this._ctx; 76 | const padding = this.get('cellPadding'); 77 | const { left, top, width: boxWidth, height: boxHeight } = rect; 78 | const [boxContentWidth, boxContentHeight] = [boxWidth - padding * 2, boxHeight - padding * 2]; 79 | const font = this.get('font'); 80 | this._canvas.applyAttrToCtx(this._cfg); 81 | const onelineWidth = Math.ceil(getTextWidth(text, font)); 82 | // 当一行文字的时候,直接针对单元格垂直居中对齐 83 | if (onelineWidth < boxWidth) { 84 | this._canvas.applyAttrToCtx({ 85 | ...this._cfg, 86 | textAlign: 'center', 87 | textBaseline: 'middle', 88 | }); 89 | const [tx, ty] = [left + (boxWidth / 2), top + (boxHeight / 2)]; 90 | context.fillText(text, tx, ty); 91 | return; 92 | } 93 | // 当多行文字的时候,需要计算的方式对齐,所以需要先将文字修改为左上对齐 94 | // 修改对齐方式 95 | this._canvas.applyAttrToCtx({ 96 | ...this._cfg, 97 | textAlign: 'left', 98 | textBaseline: 'top', 99 | }); 100 | const fontsize = this.get('fontSize'); 101 | const lineHeight = this.get('lineHeight'); 102 | // len:当前串的像素长度 start:新一行以哪个字符开始 103 | const textLine = { len: 0, start: 0, height: 0, count: 0 }; 104 | const textLineHeight = fontsize + lineHeight; 105 | const maxlineCount = ~~(boxContentHeight / textLineHeight); // 最多绘制行数 106 | const tx = left + padding; 107 | let ty = top + padding; 108 | for (let i = 0; i < text.length && textLine.count < maxlineCount; i++) { 109 | const char = text[i]; 110 | textLine.len += getTextWidth(char, font); 111 | if (textLine.len > boxContentWidth) { // 字符累积到一行的宽度 开始渲染并换行 112 | textLine.count += 1; 113 | let cutTxt = ''; 114 | if (textLine.count === maxlineCount) { 115 | // i-1用两个字符代替省略号 116 | cutTxt = text.substring(textLine.start, i - 1) + '...'; 117 | } else { 118 | cutTxt = text.substring(textLine.start, i + 1); 119 | } 120 | context.fillText(cutTxt, tx, ty); 121 | ty += textLineHeight; 122 | textLine.len = 0; 123 | textLine.height += textLineHeight; 124 | textLine.start = i + 1; 125 | } 126 | } 127 | if (textLine.len > 0) { 128 | context.fillText(text.substring(textLine.start), tx, ty); 129 | } 130 | } 131 | _setFont() { 132 | const font = assembleFont(this._cfg as CellText); 133 | this.set('font', font); 134 | } 135 | } -------------------------------------------------------------------------------- /src/view/render/canvas.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @file canvas 表格主体渲染器 3 | * - 表格的大部分绘制由rangeman管理:基础网格、文字、样式等 4 | * - 绘制提供三种方式:render全局绘制、基于某单元格的局部绘制、外部接管canvas绘制(如辅助线) 5 | * - 外部接管canvas进行绘制,需要借助 saveDrawingSurface、restoreDrawingSurface 6 | */ 7 | import { CanvasChangeType, ViewTableSize, RectOffset, ViewDataSource } from '../../type'; 8 | import { defaultCanvasOption } from '../../config/engineoption'; 9 | import { RangeRenderController } from '../rangeman'; 10 | import { AbstraCanvas } from '../../abstract/canvas'; 11 | 12 | interface ICanvas { 13 | render: () => void; 14 | // 将选中区域的单元格 局部绘制 15 | // renderRange: () => void; 16 | saveDrawingSurface: () => void; 17 | restoreDrawingSurface: () => void; 18 | } 19 | 20 | export class CanvasRender extends AbstraCanvas implements ICanvas { 21 | // canvasCoord: Point; // canvas自身坐标系 取值为整数 22 | clientRect: RectOffset; // FIXME: 为啥 grid-range拿不到这里的公共的数据? 23 | private _rangeRenderController: RangeRenderController; // canvas的绘制任务由range控制 24 | private _curImageData: string | null; 25 | changeType: CanvasChangeType; 26 | $store: ViewDataSource; 27 | getDefaultCfg() { 28 | return defaultCanvasOption; 29 | } 30 | constructor( 31 | container: HTMLElement, 32 | viewopt: ViewTableSize 33 | ) { 34 | super(container); 35 | this._rangeRenderController = new RangeRenderController(this); 36 | this.set('width', viewopt.viewWidth); 37 | this.set('height', viewopt.viewHeight); 38 | this._setDOMSize(); 39 | } 40 | // 初始化 41 | render() { 42 | this.set('width', this.$store.viewWidth); 43 | this.set('height', this.$store.viewHeight); 44 | this._setDOMSize(); 45 | this._rangeRenderController.render(); 46 | } 47 | saveDrawingSurface() { 48 | const context = this.get('context'); 49 | const pixelRatio = this._getPixelRatio(); 50 | this._curImageData = context.getImageData(0, 0, this.get('width') * pixelRatio, this.get('height') * pixelRatio); 51 | } 52 | restoreDrawingSurface() { 53 | if (!this._curImageData) return; 54 | const context = this.get('context'); 55 | context.putImageData(this._curImageData, 0, 0); 56 | } 57 | protected _setDOMSize() { 58 | super._setDOMSize(); 59 | // 获取canvas绘制在dom上的:相对视窗的位置、大小 60 | setTimeout(() => { 61 | const bbox = this.get('el').getBoundingClientRect(); 62 | const clientRect = { 63 | left: bbox.left, 64 | top: bbox.top, 65 | width: bbox.width, 66 | height: bbox.height, 67 | }; 68 | this.clientRect = clientRect; 69 | }); 70 | } 71 | // 改写基类方法 72 | protected _createDom(): HTMLElement { 73 | const $canvas = super._createDom(); 74 | $canvas.id = 'xexcel-canvas'; 75 | return $canvas; 76 | } 77 | } -------------------------------------------------------------------------------- /src/view/render/dom.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @file 除了canvas元素以外的dom管理器 3 | * - 入口文件的index.ts注入dom,RegisterView(ToolBar, 'toolbar'); 4 | * - 对注入的dom进行实例化控制,以及具体插入到哪个dom里的控制 5 | */ 6 | import { EngineOption, Point, Cursor } from '../../type'; 7 | import { Base } from '../../abstract/base'; 8 | import { modifyCSS, createDom } from '../../utils'; 9 | import { Engine } from '../../engine'; 10 | import { FIXEDHEADERMARGIN, BUFFERPADDING } from '../../model/mdata'; 11 | 12 | const PREFIX_DOM_NAME = 'xexcel'; 13 | export class DomRender extends Base { 14 | private _domroot = null; 15 | private _domrootInner = null; 16 | private _engine: Engine; 17 | shapeList: any[]; 18 | // 当前视图里有的dom 19 | constructor( 20 | Engine: Engine, 21 | engineOpt: EngineOption 22 | ) { 23 | super(engineOpt); 24 | this._engine = Engine; 25 | this.shapeList = []; 26 | // 先把container 设置为viewopt大小 27 | const el = engineOpt.container; 28 | this.set('el', el); 29 | const vw = this.get('viewOption.viewWidth') + 'px'; 30 | const vh = this.get('viewOption.viewHeight') + 'px'; 31 | modifyCSS(el, { width: vw, height: vh, overflow: 'hidden' }); 32 | this._domrootInner = createDom(`
`); 33 | // 视配置决定渲染某些自定义view 34 | this.createShape('toolbar'); 35 | this.createShape('selector'); 36 | this.createShape('scrollbar'); 37 | this.initContainer(); 38 | Promise.resolve().then(this.initEvent.bind(this)); 39 | } 40 | createShape(shapeValue: string) { 41 | let shapeConstruct = null; 42 | let shapeName: string = null; 43 | if (typeof shapeValue === 'string') { 44 | shapeConstruct = Engine.ViewDomMap[shapeValue]; 45 | shapeName = shapeValue; 46 | } 47 | if (!shapeConstruct) return; 48 | // 创建图形 49 | const shape = new shapeConstruct(this._engine); 50 | this.shapeList.push(shape); 51 | const domstr = shape.createRender(); 52 | const $dom = createDom(domstr); 53 | if (shapeName === 'toolbar') { 54 | this.get('container').append($dom); 55 | } else { 56 | this._domrootInner.append($dom); 57 | } 58 | } 59 | initContainer() { 60 | this._domroot = createDom(`
`); 61 | this._domroot.append(this._domrootInner); 62 | const innerLeft = (FIXEDHEADERMARGIN.left - BUFFERPADDING) + 'px'; 63 | const innerTop = (FIXEDHEADERMARGIN.top - BUFFERPADDING) + 'px'; 64 | modifyCSS(this._domrootInner, { 65 | left: innerLeft, 66 | top: innerTop, 67 | width: `calc(100% - ${innerLeft}`, 68 | height: `calc(100% - ${innerTop}` 69 | }); 70 | this.get('container').append(this._domroot); 71 | this.set('el', this._domroot); 72 | } 73 | initEvent() { 74 | for (const shape of this.shapeList) { 75 | shape.initEvent(); 76 | } 77 | } 78 | getPointByClient(clientX: number, clientY: number): Point { 79 | const el = this.get('el'); 80 | const bbox = el.getBoundingClientRect(); 81 | return { 82 | x: clientX - bbox.left, 83 | y: clientY - bbox.top, 84 | }; 85 | } 86 | changeCursor(type: Cursor) { 87 | type = type || 'auto'; 88 | modifyCSS(this.get('el'), { cursor: type }); 89 | } 90 | } -------------------------------------------------------------------------------- /src/view/scrollbar/index.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @file 滚动条 3 | * - 提供滚动条,响应滚动条上的滚动事件,实现canvas的滚动 4 | * - 响应鼠标的滚轮滚动事件 5 | */ 6 | import { Shape } from '../../abstract/shape-base'; 7 | import { modifyCSS, addEventListener } from '../../utils'; 8 | export class ScrollBar extends Shape { 9 | private $vertical: HTMLElement; 10 | private $horizontal: HTMLElement; 11 | initEvent() { 12 | const $vertical = document.querySelector('.xexcel-scrollbar-wrapper .vertical') as HTMLElement; 13 | const $horizontal = document.querySelector('.xexcel-scrollbar-wrapper .horizontal') as HTMLElement; 14 | this.$vertical = $vertical; 15 | this.$horizontal = $horizontal; 16 | this.initSize(); 17 | addEventListener($vertical, 'scroll', (evt) => this.onScroll(true, evt)); 18 | addEventListener($horizontal, 'scroll', (evt) => this.onScroll(false, evt)); 19 | 20 | // 鼠标滚轮滚动, 计算出鼠标滚动的距离,滚一点计算成一格,让scrollbar滚动, 触发onScroll事件 21 | // 通过转移的方式让效果归一化 22 | this.engine.on('wheel', (evt) => { 23 | const { scroll } = this.engine.getStatus(); 24 | if (evt.deltaY > 0) { // up 25 | const nextScrollTop = scroll.offsetY + this.engine.dataModel.getRowHeight(scroll.ri); 26 | this.$vertical.scroll({ top: nextScrollTop }); 27 | } else {// down 28 | const nextScrollTop = scroll.ri === 0 ? 0 : scroll.offsetY - 50; 29 | this.$vertical.scroll({ top: nextScrollTop }); 30 | } 31 | }); 32 | } 33 | createRender() { 34 | return ` 35 |
36 | 39 | 42 |
43 | ` 44 | } 45 | initSize() { 46 | const $verticalInner = this.$vertical.querySelector('.inner') as HTMLElement; 47 | const $horizontalInner = this.$horizontal.querySelector('.inner') as HTMLElement; 48 | 49 | const { viewH, viewW, contentH, contentW } = this.engine.getBoxSize(); 50 | const verticalShow = contentH > viewH; 51 | const horizontalShow = contentW > viewW; 52 | modifyCSS(this.$vertical, { 53 | height: viewH + 'px', 54 | display: verticalShow ? 'block' : 'none', 55 | }); 56 | modifyCSS($verticalInner, { 57 | height: contentH + 'px', 58 | }); 59 | 60 | modifyCSS(this.$horizontal, { 61 | width: viewW + 'px', 62 | display: horizontalShow ? 'block' : 'none', 63 | }); 64 | modifyCSS($horizontalInner, { 65 | width: contentW + 'px', 66 | }); 67 | } 68 | onScroll(isVertical: boolean, evt: Event) { 69 | // eslint-disable-next-line @typescript-eslint/ban-ts-comment 70 | // @ts-ignore 71 | const { scrollTop, scrollLeft } = evt.target; 72 | const distance = isVertical ? scrollTop : scrollLeft; 73 | 74 | this.engine.dataModel.command({ 75 | type: 'scrollView', 76 | distance, 77 | isVertical, 78 | }); 79 | } 80 | } -------------------------------------------------------------------------------- /src/view/selector/index.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @file 选区 3 | * - 响应用户从event传上来的:单元格单选cellclick、单元格框选select,实现selector的位置布局 4 | * - 响应gridmap上的变化:scroll、resize,动态调整位置布局 5 | * - editor编辑框的控制能力,editor与selector共享位置区域 6 | */ 7 | import { modifyCSS } from '../../utils'; 8 | import { Shape } from '../../abstract/shape-base'; 9 | import { Range, Rect, RectOffset } from '../../type'; 10 | import { Editor } from '../editor'; 11 | import { Engine } from '../../engine'; 12 | import { LooseObject } from '../../interface'; 13 | 14 | export class Selector extends Shape { 15 | private _cellOffset; 16 | private _editorText: string; 17 | private $selector: HTMLElement; 18 | protected editor: Editor; 19 | protected isEditing = false; 20 | protected isSelectWhole = false; // 行选 or 列选 21 | constructor(Engine: Engine, cfg?: LooseObject) { 22 | super(Engine, cfg); 23 | this.editor = new Editor(); 24 | this.editor.onEdit = this.handleEdit.bind(this); 25 | } 26 | initEvent() { 27 | const $selector = document.querySelector('.xexcel-selector .xexcel-selector-area') as HTMLElement; 28 | this.$selector = $selector; 29 | this.engine 30 | .on('canvas:cellclick', (rect: Rect) => { 31 | if (this.isEditing) { 32 | this.isEditing = false; 33 | this.editor.hide(); 34 | this.engine.dataModel.command({ 35 | type: 'setRange', 36 | properties: { text: this._editorText } 37 | }); 38 | this._editorText = ''; 39 | } 40 | this.handleSelect({ 41 | sri: rect.ri, 42 | sci: rect.ci, 43 | eri: rect.ri, 44 | eci: rect.ci, 45 | ...rect, 46 | }); 47 | }) 48 | .on('canvas:select', (rect: Range) => { 49 | this.isEditing = false; 50 | this.editor.hide(); 51 | // this.engine.changeCursor('crosshair'); 52 | this.handleSelect(rect); 53 | }) 54 | .on('canvas:dblclick', (rect: Rect) => { 55 | if (this.isSelectWhole) return; 56 | const cellmm = this.engine.getCell({ ri: rect.ri, ci: rect.ci }) || {}; 57 | this._editorText = cellmm.text || ''; 58 | this.isEditing = true; 59 | this.editor.show({ 60 | ...this._cellOffset, 61 | text: this._editorText 62 | }); 63 | }) 64 | .on('canvas:scroll', scroll => { 65 | if (!this._cellOffset) return; 66 | this._cellOffset.top += scroll.y; 67 | this._cellOffset.left += scroll.x; 68 | modifyCSS(this.$selector, { 69 | transform: `translate3d(-${scroll.x}px, -${scroll.y}px, 0)` 70 | }); 71 | this.editor.move(scroll.x, scroll.y); 72 | }) 73 | .on('canvas:resize', resize => { 74 | if (!this._cellOffset) return; 75 | const range = this.engine.getRange(); 76 | this.changeSelectOffset(range); 77 | }); 78 | this.editor.initEvent(); 79 | } 80 | createRender() { 81 | return ` 82 |
83 | 86 | ${this.editor.createRender()} 87 |
88 | `; 89 | } 90 | handleSelect(rect: Range) { 91 | this.editor.move(0, 0); 92 | modifyCSS(this.$selector, { 93 | transform: `translate3d(0, 0, 0)` 94 | }); 95 | this.isSelectWhole = false; 96 | // TODO: 高亮对应的索引栏 97 | const { sri, sci, eri, eci } = rect; 98 | if (sri === -1 && sci === -1) { 99 | modifyCSS(this.$selector, { display: 'none' }); 100 | return; 101 | } 102 | // 行选 103 | if (sci === -1 && eri >= 0) { 104 | rect.width = this.engine.getSumWidth(); 105 | this.isSelectWhole = true; 106 | } 107 | // 列选 108 | if (sri === -1 && eci >= 0) { 109 | rect.height = this.engine.getSumHeight(); 110 | this.isSelectWhole = true; 111 | } 112 | this.changeSelectOffset(rect); 113 | this.engine.dataModel.setSelect({ 114 | sri: rect.sri, 115 | sci: rect.sci, 116 | eri: rect.eri, 117 | eci: rect.eci, 118 | }); 119 | } 120 | changeSelectOffset(rectOffset: RectOffset) { 121 | const borderpadding = 2; 122 | const curOffset = { 123 | width: rectOffset.width, 124 | height: rectOffset.height, 125 | left: rectOffset.left - 2 * borderpadding, 126 | top: rectOffset.top - borderpadding, 127 | display: 'block' 128 | }; 129 | this._cellOffset = curOffset; 130 | modifyCSS(this.$selector, curOffset); 131 | if (this.isEditing) { 132 | this.editor.changeOffset(curOffset); 133 | } 134 | } 135 | handleEdit(cur, prev) { 136 | this._editorText = cur; 137 | } 138 | } -------------------------------------------------------------------------------- /src/view/toolbar/index.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @file 工具栏 3 | * - 基于当前selector选中框,改变单元格xxx属性 4 | * - 反向高亮:对于选中的单元格,反向高亮命中的属性 如当前cell文字已加粗则B要高亮 5 | * - xExcel.toolbar(['redo', 'undo', 'bold', 'font']) 可配置显现 todo 6 | * - 插入图表等能力 todo 7 | */ 8 | import { each, isNil } from '../../utils'; 9 | import { Shape } from '../../abstract/shape-base'; 10 | export class ToolBar extends Shape { 11 | // TODO: 选中选区,toolbar要高亮 // 接受engine发出的 .on('cellselect', ); 12 | initEvent() { 13 | // eslint-disable-next-line @typescript-eslint/no-this-alias 14 | const self = this; 15 | this.watchHighlight(); 16 | // 加粗 17 | document.getElementById('toolbar-bold').addEventListener('click', function (e) { 18 | const isActive = this.classList.contains('active'); 19 | self.engine.dataModel.command({ 20 | type: 'setRange', 21 | properties: { 22 | fontWeight: isActive ? 'normal' : 'bold' 23 | } 24 | }); 25 | this.classList.toggle('active'); 26 | }); 27 | // 字号 28 | document.getElementById('toolbar-fontsize').addEventListener('change', function (e) { 29 | const size = this.value; 30 | self.engine.dataModel.command({ 31 | type: 'setRange', 32 | properties: { 33 | fontSize: +size 34 | } 35 | }); 36 | }); 37 | // 字体颜色 38 | document.getElementById('toolbar-cellmmfontcolor').addEventListener('change', function (e) { 39 | const color = this.value; 40 | self.engine.dataModel.command({ 41 | type: 'setRange', 42 | properties: { 43 | fontColor: color 44 | } 45 | }); 46 | }); 47 | // 单元格背景颜色 48 | document.getElementById('toolbar-cellmmbgcolor').addEventListener('change', function (e) { 49 | const color = this.value; 50 | self.engine.dataModel.command({ 51 | type: 'setRange', 52 | properties: { 53 | bgcolor: color 54 | } 55 | }); 56 | }); 57 | } 58 | //
  • 59 | //
    格式刷
    60 | //
  • 61 | //
  • 62 | //
    清除格式
    63 | //
  • 64 | createRender() { 65 | return ` 66 |
    67 |
      68 |
    • 69 | 75 |
    • 76 |
    • 77 |
      B
      78 |
    • 79 |
    • 80 | 字体颜色 81 | 82 |
    • 83 |
    • 84 | 单元格背景颜色 85 | 86 |
    • 87 |
    88 |
    89 | ` 90 | } 91 | watchHighlight() { 92 | this.engine.on('canvas:cellclick', rect => { 93 | const cellmm = this.engine.getCell(rect); 94 | const $bold = document.getElementById('toolbar-bold'); 95 | $bold.classList.remove('active'); 96 | if (!isNil(cellmm)) { 97 | each(cellmm, (val, key) => { 98 | if (key === 'fontWeight' && val === 'bold') { 99 | $bold.classList.toggle('active'); 100 | } 101 | }); 102 | } 103 | }); 104 | } 105 | } -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2015", 4 | "module": "commonJS", 5 | "removeComments": true, 6 | "sourceMap": false, 7 | "declaration": true, 8 | "declarationMap": false, 9 | // Compile to lib 10 | "rootDir": "src", 11 | "importHelpers": true, 12 | "pretty": true, 13 | "baseUrl": "src", 14 | "paths": { 15 | "@config/*": ["config/*"], 16 | "@core/*": ["core/*"], 17 | "@utils/*": ["utils/*"], 18 | "@type/*": ["type/*"], 19 | "@interface/*": ["interface/*"], 20 | "@model/*": ["model/*"], 21 | } 22 | }, 23 | "include": [ 24 | "src/*" 25 | ], 26 | "exclude": ["node_modules"] 27 | } -------------------------------------------------------------------------------- /webpack.config.js: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | const HtmlWebpackPlugin = require('html-webpack-plugin'); 3 | const MiniCssExtractPlugin = require('mini-css-extract-plugin'); 4 | 5 | const isProd = process.env.NODE_ENV === 'prod'; 6 | const isDev = process.env.NODE_ENV === 'dev'; 7 | 8 | module.exports = { 9 | mode: isProd ? 'production' : 'development', 10 | entry: { 11 | excel: './src/index.ts' 12 | }, 13 | plugins: [ 14 | new HtmlWebpackPlugin({ 15 | title: '个人在线excel项目', 16 | template: './index.html', 17 | }), 18 | new MiniCssExtractPlugin({ 19 | filename: '[name].[contenthash].css', 20 | chunkFilename: isDev ? '[id].[hash].css' : '[id].css', 21 | }) 22 | ], 23 | devtool: 'inline-source-map', 24 | devServer: { 25 | // contentBase: './dist', 26 | compress: true, 27 | port: 8081, 28 | host: 'localhost', 29 | }, 30 | devtool: 'inline-source-map', 31 | module: { 32 | rules: [ 33 | { 34 | test: /\.ts?$/, 35 | use: 'ts-loader', 36 | exclude: /node_modules/, 37 | }, 38 | { 39 | test: /\.css$/i, 40 | use: [ 41 | MiniCssExtractPlugin.loader, 42 | 'style-loader', 43 | 'css-loader' 44 | ], 45 | }, 46 | { 47 | test: /\.less$/, 48 | use: [ 49 | MiniCssExtractPlugin.loader, 50 | 'css-loader', 51 | 'less-loader', 52 | ], 53 | }, 54 | ], 55 | }, 56 | resolve: { 57 | extensions: ['.tsx', '.ts', '.js'], 58 | }, 59 | output: { 60 | filename: '[name].bundle.js', 61 | path: path.resolve(__dirname, 'dist'), 62 | clean: true, 63 | }, 64 | }; --------------------------------------------------------------------------------