├── .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 |
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): 相对于