Browse Source

在线白板

huangyan 11 tháng trước cách đây
commit
95eaf9cf9e
63 tập tin đã thay đổi với 17018 bổ sung0 xóa
  1. 24 0
      .gitignore
  2. 3 0
      .vscode/extensions.json
  3. 1315 0
      README.md
  4. 5 0
      app/.prettierignore
  5. 5 0
      app/.prettierrc
  6. 15 0
      app/index.html
  7. 2527 0
      app/package-lock.json
  8. 24 0
      app/package.json
  9. BIN
      app/public/favicon.ico
  10. 1 0
      app/public/libs/icons.svg
  11. 107 0
      app/public/libs/jsonTree.css
  12. 819 0
      app/public/libs/jsonTree.js
  13. 1022 0
      app/src/App.vue
  14. 132 0
      app/src/components/ColorPicker.vue
  15. 218 0
      app/src/components/Contextmenu.vue
  16. 120 0
      app/src/constants.js
  17. 4 0
      app/src/main.js
  18. 25 0
      app/vite.config.js
  19. BIN
      assets/1.jpg
  20. 6 0
      package-lock.json
  21. 4 0
      tiny-whiteboard/.prettierignore
  22. 5 0
      tiny-whiteboard/.prettierrc
  23. 4515 0
      tiny-whiteboard/dist/tiny-whiteboard.es.js
  24. 0 0
      tiny-whiteboard/dist/tiny-whiteboard.umd.js
  25. 581 0
      tiny-whiteboard/package-lock.json
  26. 33 0
      tiny-whiteboard/package.json
  27. 35 0
      tiny-whiteboard/src/Background.js
  28. 18 0
      tiny-whiteboard/src/Canvas.js
  29. 117 0
      tiny-whiteboard/src/Coordinate.js
  30. 72 0
      tiny-whiteboard/src/Cursor.js
  31. 477 0
      tiny-whiteboard/src/Elements.js
  32. 192 0
      tiny-whiteboard/src/Event.js
  33. 159 0
      tiny-whiteboard/src/Export.js
  34. 155 0
      tiny-whiteboard/src/Grid.js
  35. 123 0
      tiny-whiteboard/src/Group.js
  36. 63 0
      tiny-whiteboard/src/History.js
  37. 121 0
      tiny-whiteboard/src/ImageEdit.js
  38. 131 0
      tiny-whiteboard/src/KeyCommand.js
  39. 69 0
      tiny-whiteboard/src/Mode.js
  40. 385 0
      tiny-whiteboard/src/Render.js
  41. 317 0
      tiny-whiteboard/src/Selection.js
  42. 103 0
      tiny-whiteboard/src/TextEdit.js
  43. 20 0
      tiny-whiteboard/src/constants.js
  44. 48 0
      tiny-whiteboard/src/elements/Arrow.js
  45. 270 0
      tiny-whiteboard/src/elements/BaseElement.js
  46. 134 0
      tiny-whiteboard/src/elements/BaseMultiPointElement.js
  47. 33 0
      tiny-whiteboard/src/elements/Circle.js
  48. 50 0
      tiny-whiteboard/src/elements/Diamond.js
  49. 529 0
      tiny-whiteboard/src/elements/DragElement.js
  50. 45 0
      tiny-whiteboard/src/elements/Freedraw.js
  51. 45 0
      tiny-whiteboard/src/elements/Image.js
  52. 50 0
      tiny-whiteboard/src/elements/Line.js
  53. 109 0
      tiny-whiteboard/src/elements/MultiSelectElement.js
  54. 31 0
      tiny-whiteboard/src/elements/Rectangle.js
  55. 73 0
      tiny-whiteboard/src/elements/Text.js
  56. 49 0
      tiny-whiteboard/src/elements/Triangle.js
  57. 29 0
      tiny-whiteboard/src/elements/index.js
  58. 631 0
      tiny-whiteboard/src/index.js
  59. 107 0
      tiny-whiteboard/src/utils/checkHit.js
  60. 191 0
      tiny-whiteboard/src/utils/draw.js
  61. 431 0
      tiny-whiteboard/src/utils/index.js
  62. 71 0
      tiny-whiteboard/src/utils/keyMap.js
  63. 25 0
      tiny-whiteboard/vite.config.js

+ 24 - 0
.gitignore

@@ -0,0 +1,24 @@
+# Logs
+logs
+*.log
+npm-debug.log*
+yarn-debug.log*
+yarn-error.log*
+pnpm-debug.log*
+lerna-debug.log*
+
+node_modules
+app/dist
+dist-ssr
+*.local
+
+# Editor directories and files
+.vscode/*
+!.vscode/extensions.json
+.idea
+.DS_Store
+*.suo
+*.ntvs*
+*.njsproj
+*.sln
+*.sw?

+ 3 - 0
.vscode/extensions.json

@@ -0,0 +1,3 @@
+{
+  "recommendations": ["johnsoncodehk.volar"]
+}

+ 1315 - 0
README.md

@@ -0,0 +1,1315 @@
+![界面截图1](./assets/1.jpg)
+
+# 一个在线小白板
+
+- [x] 支持绘制矩形、菱形、三角形、圆形、线段、箭头、自由画笔、文字、图片
+
+- [x] 绘制的图形支持拖动、缩放、旋转、编组
+
+- [x] 支持垂直方向无限滚动,一块无限高度的白板
+
+- [x] 支持放大缩小
+
+- [x] 支持样式设置
+
+- [x] 橡皮擦功能
+
+- [x] 支持导入、导出
+
+- [x] 支持前进回退
+
+- [x] 本地存储
+
+- [x] 滚动超出后支持一键回到内容
+
+- [x] 支持多选
+
+- [x] 支持网格模式
+
+- [x] 支持只读模式,只读模式支持随意拖拽
+
+- [x] 支持快捷键
+
+# 相关文章
+
+[我做了一个在线白板!](https://juejin.cn/post/7091276963146530847)
+
+[我做了一个在线白板(二)](https://juejin.cn/post/7129140994011824141)
+
+# 目录简介
+
+1.`/tiny-whiteboard`
+
+在线白板工具库,不依赖任何框架。
+
+2.`/app`
+
+使用`tiny-whiteboard`工具库,基于`vue3.x`、`ElementPlus`搭建的在线`demo`。
+
+# 本地开发
+
+## 开发
+
+```bash
+git clone https://github.com/wanglin2/tiny_whiteboard.git
+cd tiny_whiteboard
+
+cd tiny-whiteboard
+```
+
+将`package.json`文件里的下列字段删除:
+
+```
+"main": "./dist/tiny-whiteboard.umd.js",
+"module": "./dist/tiny-whiteboard.es.js",
+"exports": {
+    ".": {
+        "import": "./dist/tiny-whiteboard.es.js",
+        "require": "./dist/tiny-whiteboard.umd.js"
+    }
+}
+```
+
+替换为:
+
+```
+"main": "./src/index.js",
+```
+
+然后执行:
+
+```bash
+npm i
+npm link
+
+cd ..
+cd app
+npm i
+npm link tiny-whiteboard
+npm run dev
+```
+
+这样修改`tiny-whiteboard`的代码即可实时生效进行调试。
+
+## 打包demo
+
+```bash
+cd app
+npm run build
+```
+
+## 打包tiny-whiteboard
+
+```bash
+cd tiny-whiteboard
+npm run build
+```
+
+即可生成`dist`目录,如果要将包的入口指向打包后的文件,请恢复前面对`package.json`文件做的操作。
+
+# 安装
+
+```bash
+npm i tiny-whiteboard
+```
+
+# 使用
+
+提供一个宽高不为`0`的且定位不为`static`的容器元素,然后实例化`tiny-whiteboard`。
+
+```js
+import TinyWhiteboard from "tiny-whiteboard";
+
+// 创建实例
+let app = new TinyWhiteboard({
+    container: container
+});
+// 接下来可以调用实例`app`的各种方法或者监听各种事件。
+// 具体可以参考/app项目的示例。
+```
+
+# 限制
+
+1.因实现方式原因,元素多了可能会存在性能和卡顿问题,请三思而后用。
+
+2.因元素点击检测实现方式原因,目前不支持贝塞尔曲线或椭圆之类的图形。
+
+# 坐标转换相关
+
+项目内涉及到坐标转换相关的比较复杂和凌乱,如果没有搞清楚很容易迷失。大体上有以下这些:
+
+1.鼠标坐标是相对屏幕的,需要转换成相对容器的,也就是鼠标坐标的`x`、`y`需要减去容器和屏幕左侧及上方的距离。
+
+2.元素的坐标都是相对容器坐标系的,也就是屏幕坐标系,原点在左上角,向右及向下为正方向,而画布的坐标在中心位置,也就是容器的中心点,也是向右及向下为正方向,所以绘制元素时需要把元素坐标转换成画布坐标,具体来说就是元素的`x`、`y`坐标需要容器宽高的`1/2`。
+
+3.画布滚动后,鼠标滚动时只支持垂直方向滚动,只读模式下可以鼠标按住画布进行任意拖动,滚动只是单纯记录一个滚动偏移量`scrollX`和`scrollY`,元素的实际坐标是没有变化的,只是在绘制元素的时候加上了`scrollX`和`scrollY`,向上和向右滚动时`scroll`值为正,向下和向左滚动为负,元素的`x`、`y`坐标需要减去`scrollX`、`scrollY`。
+
+4.画布缩放后,缩放是应用在画布整体上,元素的实际位置和大小是没有变化的,所以当检测位置时鼠标的位置需要反向缩放才能对应到元素的实际坐标,具体来说就是鼠标坐标先转成画布坐标,然后除以缩放值,最后再转换屏幕坐标。
+
+5.当元素旋转后,元素的大小和位置的值是没有变化的,只是通过`rotate`值进行了旋转,所以当通过鼠标位置检测元素时,鼠标的位置需要以元素的中心为旋转中心,反向进行旋转,然后再进行计算。
+
+6.当开启网格时,坐标会和网格对齐,也就是坐标需要对网格的大小取余,然后减去该余数。
+
+# 文档
+
+## 1.实例化
+
+```html
+<div id="container"></div>
+```
+
+```js
+import TinyWhiteboard from "tiny-whiteboard";
+
+// 创建实例
+let app = new TinyWhiteboard({
+    container: document.getElementById('container')
+});
+```
+
+### 实例化选项
+
+| 字段名称             | 类型    | 默认值           | 描述                                                         | 是否必填 |
+| -------------------- | ------- | ---------------- | ------------------------------------------------------------ | -------- |
+| container | Element |                  | 容器元素 | 是 |
+| drawType | String  | selection | 当前画布的绘制模式,比如选择模式、矩形绘制模式、自由画笔模式等等 | 否 |
+| state | Object  | {} | 画布状态,对象,具体的属性请参考表格1-1 | 否 |
+
+### 表格1-1 画布状态对象state的属性
+
+| 字段名称                    | 类型    | 默认值                                         | 描述                                                         |
+| --------------------------- | ------- | ---------------------------------------------- | ------------------------------------------------------------ |
+| scale                       | Number  | 1                                              | 画布的缩放值,0-1                                            |
+| scrollX                     | Number  | 0                                              | 画布水平方向的滚动偏移量                                     |
+| scrollY                     | Number  | 0                                              | 画布垂直方向的滚动偏移量                                     |
+| scrollStep                  | Number  | 50                                             | 画布滚动步长                                                 |
+| backgroundColor             | String  |                                                | 画布背景颜色                                                 |
+| strokeStyle(v0.1.11+)     | String  | \#000000                                       | 默认线条颜色                                                 |
+| fillStyle(v0.1.11+)       | String  | transparent                                    | 默认填充颜色                                                 |
+| fontFamily(v0.1.11+)      | String  | 微软雅黑, Microsoft YaHei                      | 默认文字字体                                                 |
+| fontSize(v0.1.11+)        | Number  | 18                                             | 默认文字字号                                                 |
+| dragStrokeStyle(v0.1.11+) | String  | \#666                                          | 选中元素的拖拽元素的默认线条颜色                             |
+| showGrid                    | Boolean | false                                          | 画布是否显示网格                                             |
+| readonly                    | Boolean | false                                          | 画布是否是只读模式                                           |
+| gridConfig                  | Object  | {size: 20,strokeStyle: "#dfe0e1",lineWidth: 1} | 画布网格配置,size(网格大小)、strokeStyle(网格线条颜色)、lineWidth(网格线条宽度) |
+
+### 实例属性
+
+| 属性名称  | 类型    | 描述                   |
+| --------- | ------- | ---------------------- |
+| opts      | Object  | 实例化选项             |
+| container | Element | 容器元素               |
+| drawType  | String  | 画布当前绘制模式       |
+| canvas    | Element | 主画布元素             |
+| ctx       | Object  | 主画布元素的绘图上下文 |
+| state     | Object  | 画布当前状态           |
+
+### 实例方法
+
+#### `undo()`
+
+回退。
+
+#### `redo()`
+
+前进。
+
+#### `setActiveElementStyle(style)`
+
+为画布当前激活的元素设置样式。
+
+- `style`:样式对象,`object`类型,具体属性请参考表格1-2。
+
+#### 表格1-2 元素样式对象style属性
+
+| 属性名          | 类型   | 默认值                    | 描述                                                |
+| --------------- | ------ | ------------------------- | --------------------------------------------------- |
+| strokeStyle     | String | \#000000                  | 线条颜色                                            |
+| fillStyle       | String | transparent               | *填充颜色*                                          |
+| lineWidth       | String | small                     | 线条宽度,small(2px)、middle(4px)、large(6px) |
+| lineDash        | Number | 0                         | 线条虚线大小                                        |
+| globalAlpha     | Number | 1                         | 透明度                                              |
+| fontSize        | Number | 18                        | 字号,文本元素特有样式                              |
+| lineHeightRatio | Number | 1.5                       | 行高,文本元素特有样式                              |
+| fontFamily      | String | 微软雅黑, Microsoft YaHei | 字体,文本元素特有样式                              |
+
+#### `setSelectedElementStyle(style)`
+
+为当前多选元素设置样式。
+
+- `style`:样式对象,可参考表格1-2。
+
+#### `setCurrentElementsStyle(style)`
+
+为当前激活或选中的元素设置样式。
+
+- `style`:样式对象,可参考表格1-2。
+
+#### `cancelActiveElement()`
+
+移除当前激活元素,即取消当前激活元素的激活状态。
+
+#### `deleteActiveElement()`
+
+从画布中删除当前的激活元素。
+
+#### `deleteCurrentElements()`
+
+从画布中删除当前激活或选中的元素。
+
+#### `copyCurrentElement()`
+
+复制当前激活或选中的元素。只是复制操作,如果需要粘贴需要调用下面的`pasteCurrentElement()`方法。
+
+#### `cutCurrentElement()`
+
+剪切当前激活或选中的元素。只是剪切操作,如果需要粘贴需要调用下面的`pasteCurrentElement()`方法。
+
+#### `pasteCurrentElement(useCurrentEventPos = false)`
+
+粘贴被复制或剪切的元素。
+
+- `useCurrentEventPos`:是否使用鼠标当前的位置,默认为`false`,偏移原图形`20px`,传`true`则粘贴到鼠标当前所在的位置。
+
+#### `copyPasteCurrentElements()`
+
+复制粘贴当前元素。
+
+#### `exportImage(opt)`
+
+导出为图片
+
+- `opt`:导出选项,`Object`,具体属性如下:
+
+  - `opt.type`:导出图片类型,`String`,默认为`image/png`;
+
+  - `opt.renderBg`:是否显示背景,`Boolean`,默认为`true`;
+
+  - `opt.useBlob`:是否以`blob`类型导出,`Boolean`,默认为`DataURL`类型,以`blob`类型导出时函数的返回值是一个`promise`;
+
+  - `opt.paddingX`:绘制的水平内边距,`Number`,默认为`10`像素;
+
+  - `opt.paddingY`:绘制的垂直内边距,`Number`,默认为`10`像素;
+
+  - `opt.onlySelected`:(v0.1.7+)。是否仅导出被选中的元素,`Boolean`,默认为`false`;
+
+#### `exportJson()`
+
+导出为`json`数据。
+
+#### `showGrid()`
+
+显示网格。
+
+#### `hideGrid()`
+
+隐藏网格。
+
+#### `updateGrid(config)`
+
+更新网格配置。
+
+- `config`:配置对象,`Object`,具体配置可参考表格1-1 的`gridConfig`属性。
+
+#### `setEditMode()`
+
+设置为编辑模式。
+
+#### `setReadonlyMode()`
+
+设置为只读模式。
+
+#### `setData(data, noEmitChange)`
+
+设置画布数据,包括状态数据及元素数据。
+
+- `data`:数据,一般是通过调用`getData()`方法获取到的数据进行回填,`Object`,包括以下两个字段:
+
+  - `data.state`:画布状态数据,`Object`,详情请参考表格1-1。
+  - `data.elements`:画布上的元素数据,`Array`。
+
+  - `noEmitChange`:禁止触发历史记录收集及`change`事件触发,`Boolean`,默认为`false`,某些场景下需要设置为`true`避免无限循环。
+
+#### `resize()`
+
+当容器的大小变化后需要调用该方法。
+
+#### `updateState(data)`
+
+更新画布状态数据,只是更新状态数据,不会触发重新渲染,如有需要重新渲染或其他操作需要自行调用相关方法。
+
+- `data`: 画布状态,`Object`,详情可参考表格1-1。
+
+#### `updateCurrentType(drawType)`
+
+更新画布当前绘制模式。
+
+- `drawType`:绘制模式,`String`,可选值如下表格所示:
+
+| 值        | 描述                                                         |
+| --------- | ------------------------------------------------------------ |
+| selection | 选择模式,该模式下可以单击某个元素进行激活元素,或进行多选元素操作 |
+| rectangle | 矩形绘制模式                                                 |
+| diamond   | 菱形绘制模式                                                 |
+| triangle  | 三角形绘制模式                                               |
+| circle    | 圆形绘制模式                                                 |
+| line      | 线段绘制模式                                                 |
+| arrow     | 箭头绘制模式                                                 |
+| freedraw  | 自由画笔绘制模式                                             |
+| text      | 文字绘制模式                                                 |
+| image     | 图片绘制模式                                                 |
+| eraser    | 橡皮擦模式                                                   |
+
+
+#### `deleteElement(element)`
+
+从画布删除某个元素。
+
+#### `copyElement(element, notActive = false, pos)`
+
+复制指定的元素。
+
+- `notActive`:只复制而不激活,`Boolean`,默认为`false`。
+
+- `pos`:是否指定坐标,否则会偏移原位置`20`像素,`Object`,格式为`{x, y}`。
+
+#### `empty()`
+
+清空元素。
+
+#### `zoomIn(num)`
+
+放大。
+
+- `num`:放大值,`Number`,默认为`0.1`。
+
+#### `zoomOut(num)`
+
+缩小。
+
+- `num`:缩小值,`Number`,默认为`0.1`。
+
+#### `setZoom(zoom)`
+
+设置指定缩放值。
+
+- `zoom`:`Number`,`0-1`。
+
+#### `fit()`
+
+缩放移动合适的值以适应所有元素。
+
+#### `setBackgroundColor(color)`
+
+设置背景颜色。
+
+#### `getData()`
+
+获取数据,包括状态数据及元素数据,可进行持久化。
+
+####  `scrollTo(scrollX, scrollY)`
+
+滚动至指定位置。
+
+- `scrollX, scrollY`:滚动距离,`Number`。
+
+#### `scrollToCenter()`
+
+滚动至中心,即定位到所有元素的中心位置。
+
+#### `resetCurrentType()`
+
+复位当前画布绘制模式到选择模式。
+
+#### `selectAll()`
+
+选中所有元素。
+
+#### `on(eventName, callback, context)`
+
+监听事件。事件请见下方表格:
+
+| 事件名称            | 描述                             | 回调参数                                             |
+| ------------------- | -------------------------------- | ---------------------------------------------------- |
+| zoomChange          | 缩放时触发                       | scale(当前缩放值)                                  |
+| scrollChange        | 滚动时触发                       | scrollX(当前水平滚动值)、scrollY(当前垂直滚动值) |
+| currentTypeChange   | 绘制模式变化时触发               | drawType(当前绘制模式)                             |
+| change              | 画布状态数据变化或元素变化时触发 | data(状态和元素数据)                               |
+| shuttle             | 前进后退时触发                   | index(当前指针)、length(当前历史记录数量)        |
+| activeElementChange | 激活元素变化事件                 | activeElement(当前激活的元素)                     |
+| multiSelectChange   | 多选元素选择完成时触发           | selectedElementList(当前被多选选中的元素)          |
+| contextmenu(v0.1.5+)   | 右键菜单事件           | e(事件对象)、elements(当前激活或选中的元素)          |
+
+#### `emit(eventName, ...args)`
+
+触发事件。
+
+#### `off(eventName, callback?)`
+
+解绑事件。
+
+#### `selectAll()`
+
+选中所有元素。
+
+#### `fit()`
+
+缩放以适应所有元素。
+
+#### `updateActiveElementPosition(x, y)`
+
+v0.1.4+。更新当前激活元素的位置。
+
+#### `updateActiveElementSize(width, height)`
+
+v0.1.4+。更新当前激活元素的尺寸。
+
+#### `updateActiveElementRotate(rotate)`
+
+v0.1.4+。更新当前激活元素的旋转角度。
+
+#### `moveUpCurrentElement()`
+
+v0.1.5+。将当前元素上移一层。
+
+#### `moveDownCurrentElement()`
+
+v0.1.5+。将当前元素下移一层。
+
+#### `moveTopCurrentElement()`
+
+v0.1.5+。将当前元素置于顶层。
+
+#### `moveBottomCurrentElement()`
+
+v0.1.5+。将当前元素置于底层。
+
+## 2.elements元素管理实例
+
+可通过`app.elements`获取到该实例。
+
+### 实例属性
+
+| 属性名称          | 类型    | 描述               |
+| ----------------- | ------- | ------------------ |
+| elementList | Array | 当前画布中的所有元素列表 |
+| activeElement    | Object | 当前激活的元素     |
+| isCreatingElement | Boolean | 当前是否正在创建新元素 |
+| isResizing        | Boolean | 当前是否正在调整元素 |
+| resizingElement | Object | 当前正在调整的元素 |
+
+### 实例方法
+
+#### `hasElements()`
+
+当前画布上是否有元素。
+
+#### `getElementsNum()`
+
+v0.1.5+。获取当前画布上的元素数量。
+
+#### `addElement(element)`
+
+添加元素。不会触发渲染,需要手动调用`app.render.render()`方法重新渲染。
+
+#### `unshiftElement(element)`
+
+v0.1.5+。向前添加元素。
+
+#### `insertElement(element, index)`
+
+v0.1.5+。添加元素到指定位置。
+
+#### `getElementIndex(element)`
+
+v0.1.5+。获取元素在元素列表里的索引。
+
+#### `deleteElement(element)`
+
+删除元素。不会触发渲染,需要手动调用`app.render.render()`方法重新渲染。
+
+#### `deleteAllElements()`
+
+删除全部元素。不会触发渲染,需要手动调用`app.render.render()`方法重新渲染。
+
+#### `hasActiveElement()`
+
+是否存在激活元素。
+
+#### `setActiveElement(element)`
+
+替换激活的元素。不会触发渲染,需要手动调用`app.render.render()`方法重新渲染。
+
+#### `cancelActiveElement()`
+
+取消当前激活元素。不会触发渲染,需要手动调用`app.render.render()`方法重新渲染。
+
+#### `createElement(opts = {}, callback = () => {}, ctx = null, notActive)`
+
+创建元素。不会触发渲染,需要手动调用`app.render.render()`方法重新渲染。
+
+- `opts`:创建元素的选项;
+
+- `callback`:回调函数,回调参数:element(创建的元素);
+
+- `ctx`:回调函数的上下文对象;
+
+- `notActive`:是否不要激活该新创建的元素,默认为`false`;
+
+#### `serialize(stringify)`
+
+序列化当前画布上的所有元素,可用来持久化数据。
+
+- `stringify`:默认为`false`,返回`json`对象类型,传`true`则返回字符串类型。
+
+#### `createElementsFromData(elements = [])`
+
+根据元素数据创建元素,即根据持久化的数据反向创建元素到画布上。不会触发渲染,需要手动调用`app.render.render()`方法重新渲染。
+
+
+
+## 3.render渲染类
+
+可通过`app.render`获取到该实例。
+
+该实例的所有方法都已代理到`app`实例上,可以直接通过`app.xxx`调用。
+
+
+
+## 4.coordinate坐标转换实例
+
+可通过`app.coordinate`获取到该实例。
+
+### 实例方法
+
+#### `addScrollY(y)`
+
+加上画布当前垂直的滚动距离。
+
+#### `addScrollX(x)`
+
+加上画布当前水平的滚动距离。
+
+#### `subScrollY(y)`
+
+减去画布当前垂直的滚动距离。
+
+#### `subScrollX(x)`
+
+减去画布当前水平的滚动距离。
+
+#### `transformToCanvasCoordinate(x, y)`
+
+屏幕坐标转换成画布坐标。
+
+#### `transformToScreenCoordinate(x, y)`
+
+画布转换转屏幕坐标。
+
+#### `transform(x, y)`
+
+综合转换,屏幕坐标转画布坐标,再减去滚动值。
+
+#### `windowToContainer(x, y)`
+
+相对窗口的坐标转换成相对容器的,用于当容器非全屏的时候。
+
+#### `containerToWindow(x, y)`
+
+相对容器的坐标转换成相对窗口的,用于当容器非全屏的时候。
+
+#### `scale(x, y)`
+
+屏幕坐标在应用画布缩放后的位置。
+
+#### `reverseScale(x, y)`
+
+屏幕坐标在反向应用画布缩放后的位置。
+
+#### `gridAdsorbent(x, y)`
+
+网格吸附。
+
+
+
+## 5.event事件实例
+
+可通过`app.event`获取该实例。
+
+### 实例方法
+
+#### `on(eventName, callback, context)`
+
+监听事件。事件请见下方表格:
+
+| 事件名称   | 描述         | 回调参数                                  |
+| ---------- | ------------ | ----------------------------------------- |
+| mousedown  | 鼠标按下事件 | e、事件实例对象event                      |
+| mousemove  | 鼠标移动事件 | e、事件实例对象event                      |
+| mouseup    | 鼠标松开事件 | e、事件实例对象event                      |
+| dblclick   | 双击事件     | e、事件实例对象event                      |
+| mousewheel | 鼠标滚动事件 | dir(滚动方向,down代表向下,up代表向上) |
+| keydown    | 按键按下事件 | e(原始事件对象)、事件实例对象event      |
+| keyup      | 按键松开事件 | e(原始事件对象)、事件实例对象event      |
+
+- `e`:事件对象,非原始事件对象,为处理后的事件对象,格式如下:
+
+  ```js
+  {
+        originEvent: e,// 原始事件对象
+        unGridClientX,
+        unGridClientY,
+        clientX,
+        clientY
+  }
+  ```
+
+  `clientX`和`clientY`是经过了一系列转换的:
+
+  >1.将相当于浏览器窗口左上角的坐标转换成相对容器左上角
+  >
+  >2.如果画布进行了缩放,那么鼠标坐标要反向进行缩放
+  >
+  >3.`x`、`y`坐标加上了画布水平和垂直的滚动距离`scrollX`和`scrollY`
+  >
+  >4.如果开启了网格,那么坐标要吸附到网格上
+
+  `unGridClientX`、`unGridClientY`和`clientX`、`clientY`的区别是没有经过第四步的处理。用于位置检测等不需要吸附的场景。
+
+  对于第三步来说,向下滚动`scroll`值为正,而`canvas`坐标系向下为正,所以要造成元素向上滚动的效果显示的时候元素的y坐标需要减去`scroll`值,但是元素真实的y值并未改变,所以对于鼠标坐标来说需要加上`scroll`值,这样才能匹配元素真实的`y`坐标,水平方向也是一样的。
+
+- `event`:事件类的实例对象,存在以下属性:
+
+  | 属性名称      | 类型    | 描述                                                     |
+  | ------------- | ------- | -------------------------------------------------------- |
+  | isMousedown   | Boolean | 鼠标是否按下                                             |
+  | mousedownPos  | Object  | 按下时的鼠标位置,格式见下方。                           |
+  | mouseOffset   | Object  | 鼠标当前位置和按下时位置的差值,格式见下方。             |
+  | lastMousePos  | Object  | 记录上一时刻的鼠标位置,格式为`{x, y}`,经过处理的坐标。 |
+  | mouseDistance | Number  | 前一瞬间的鼠标移动距离。                                 |
+  | lastMouseTime | Number  | 记录上一时刻的时间,时间戳格式。                         |
+  | mouseDuration | Number  | 前一瞬间经过的时间,单位为毫秒。                         |
+  | mouseSpeed    | Number  | 前一瞬间的鼠标移动速度,单位为px/ms。                    |
+
+  - `mousedownPos`属性格式:
+
+    ```js
+    {
+          x: 0,// 经过了以上4步转换后的坐标
+          y: 0,
+          unGridClientX: 0,// 经过了除第4步的坐标
+          unGridClientY: 0,
+          originClientX: 0,// 原始没有经过任何处理的坐标
+          originClientY: 0
+    }
+    ```
+
+  - `mouseOffset`属性格式:
+
+    ```js
+    {
+          x: 0,// 经过了以上4步转换后的坐标差数据
+          y: 0,
+          originX: 0,// 原始没有经过任何处理的坐标差数据
+          originY: 0
+    }
+    ```
+
+#### `emit(eventName, ...args)`
+
+触发事件。
+
+#### `off(eventName, callback?)`
+
+解绑事件。
+
+
+
+## 6.cursor鼠标样式实例
+
+可通过`app.cursor`获取该实例。
+
+### 实例方法
+
+#### `set(type = "default")`
+
+设置鼠标指针样式。
+
+#### `hide()`
+
+隐藏鼠标指针。
+
+#### `reset()`
+
+复位鼠标指针。
+
+#### `setCrosshair()`
+
+设置为 ✚ 字型。
+
+#### `setMove()`
+
+设置为 可移动 状态。
+
+#### `setEraser()`
+
+设置为橡皮擦样式。
+
+
+
+## 7.history历史记录管理实例
+
+可通过`app.history`获取该实例。
+
+### 实例方法
+
+#### `undo()`
+
+后退。
+
+#### `redo()`
+
+前进。
+
+#### `add(data)`
+
+添加一个历史记录数据,`data`一般是通过`app.getData()`获取到的数据。
+
+#### `clear()`
+
+清空历史记录数据。
+
+
+
+## 8.export导入导出实例
+
+可通过`app.export`获取该实例。
+
+### 实例方法
+
+#### `exportImage(opt)`
+
+导出为图片,参数详见前面的文档。
+
+#### `exportJson()`
+
+导出为`json`数据。
+
+
+
+## 9.background背景设置实例
+
+可通过`app.background`获取该实例。
+
+### 实例方法
+
+#### `addBackgroundColor()`
+
+添加背景颜色,背景色值为`app.state.backgroundColor`。
+
+#### `remove()`
+
+移除背景。
+
+#### `canvasAddBackgroundColor(ctx, width, height, backgroundColor)`
+
+给一个`canvas`设置背景颜色,非`css`样式。
+
+- `ctx`:`canvas`绘图上下文。
+
+
+
+## 10.selection多选实例
+
+可通过`app.selection`获取该实例。
+
+### 实例方法
+
+#### `selectElements(elements = [])`
+
+选中指定元素。
+
+#### `copySelectionElements(pos)`
+
+复制粘贴当前选中的元素。
+
+- `pos`:是否指定粘贴的坐标,否则会偏移原位置`20`像素,`Object`,格式为`{x, y}`。
+
+#### `getSelectionElements()`
+
+获取当前被选中的元素。
+
+#### `hasSelectionElements()`
+
+当前是否存在被选中元素。
+
+#### `deleteSelectedElements()`
+
+从画布删除当前选中的元素。
+
+#### `setSelectedElementStyle(style)`
+
+为当前选中的元素设置样式。
+
+
+
+## 11.mode模式实例
+
+可通过`app.mode`获取该实例。
+
+### 实例方法
+
+#### `setEditMode()`
+
+设置为编辑模式。
+
+#### `setReadonlyMode()`
+
+设置为只读模式。
+
+
+
+## 12.imageEdit图片选择实例
+
+可通过`app.imageEdit`获取该实例。
+
+
+
+## 13.textEdit文字编辑实例
+
+可通过`app.textEdit`获取该实例。
+
+
+
+## 14.keyCommand快捷键实例
+
+可通过`app.keyCommand`获取该实例。
+
+### 实例属性
+
+| 属性名称 | 类型   | 描述                        |
+| -------- | ------ | --------------------------- |
+| keyMap   | Object | 按键的名称到`keyCode`映射。 |
+
+### 实例方法
+
+#### `bindEvent()`
+
+绑定`keydown`事件。
+
+#### `unBindEvent()`
+
+解绑`keydown`事件。如果你的事件会和快捷键冲突,那么可以暂时调用该方法解绑快捷键的`keydown`事件。
+
+#### `addShortcut(key, fn, ctx)`
+
+添加快捷键命令。
+
+- `key`:快捷键,允许三种组合方式:单个按键(如`Enter`)、或(`Tab | Insert`)、与(`Shift + a`),具体的按键名称可以`app.keyCommand.keyMap`查看。
+
+- `fn`:快捷键对应的执行函数。
+
+- `ctx`:函数的上下文。
+
+#### `removeShortcut(key, fn)`
+
+移除快捷键命令。
+
+
+
+## 15.工具方法
+
+可以通过如下方式获取到内置的工具方法:
+
+```js
+import TinyWhiteboard from "tiny-whiteboard";
+
+TinyWhiteboard.utils.xxx
+```
+
+### `createCanvas(width,height,opt = { noStyle: false, noTranslate: false, className: '' })`
+
+创建`canvas`元素。
+
+- `width`:宽度
+
+- `height`:高度
+
+- `opt`:选项
+  - `opt.noStyle`:是否不需要设置样式,如果需要的话,那么会给`canvas`元素加上`left=0`和`top=0`的绝对定位样式;
+  - `noTranslate`:是否不需要将画布原点移动到画布中心;
+  - `className`:添加到画布元素上的类名;
+
+### `getTowPointDistance(x1, y1, x2, y2)`
+
+计算两点之间的距离。
+
+### `getPointToLineDistance(x, y, x1, y1, x2, y2)`
+
+计算点到直线的距离。
+
+### `checkIsAtSegment(x, y, x1, y1, x2, y2, dis = 10)`
+
+检查是否点击到了一条线段。
+
+- `dis`:点距离线段多少距离内都认为是点击到了;
+
+### `radToDeg(rad)`
+
+弧度转角度。
+
+### `degToRad(deg)`
+
+角度转弧度。
+
+### `getTowPointRotate(cx, cy, tx, ty, fx, fy)`
+
+计算中心点相同的两个坐标相差的角度数。
+
+### `getRotatedPoint(x, y, cx, cy, rotate)`
+
+获取坐标经指定中心点旋转指定角度的坐标,顺时针还是逆时针`rotate`传正负即可。
+
+### `getElementCenterPoint(element)`
+
+获取元素的中心点坐标。
+
+- `element`:元素,`Object`,必要的字段如为`{ x, y, width, height }`;
+
+### `getElementCornerPoint(element, dir)`
+
+获取元素的四个角坐标。
+
+- `element`:元素,`Object`,必要的字段如为`{ x, y, width, height }`;
+
+- `dir`:要获取的哪个角,可选项为:`topLeft`、`topRight`、`bottomRight`、`bottomLeft`;
+
+### `getElementRotatedCornerPoint(element, dir)`
+
+获取元素旋转后的四个角坐标。参数同上。
+
+### `checkPointIsInRectangle(x, y, rx, ry, rw, rh)`
+
+判断一个坐标是否在一个矩形内。第三个参数可以直接传一个带有`x`、`y`、`width`、`height`属性的元素对象。
+
+### `getBoundingRect(pointArr = [], returnCorners = false)`
+
+获取多个点的外包围框。
+
+返回数据:
+
+```js
+{
+  x,
+  y,
+  width,
+  height,
+}
+```
+
+- `pointArr`:点数组,数组的每一项的格式为:`[x, y]`;
+
+- `returnCorners`:v0.1.4+。以四个角坐标的形式返回:
+
+```js
+[
+  [x0, y0],
+  [x1, y1],
+  [x2, y2],
+  [x3, y3],
+]
+```
+
+### `deepCopy(obj)`
+
+简单深拷贝,只能用于拷贝纯粹的对象。
+
+### `getFontString(fontSize, fontFamily)`
+
+拼接`canvas`文字字体字号字符串。
+
+### `splitTextLines(text)`
+
+将文本切割成行,返回一个数组。
+
+### `getTextActWidth(text, style)`
+
+计算文本的实际渲染宽度。
+
+- `style`:对象类型,包含`fontSize`和`fontFamily`两个属性;
+
+### `getWrapTextMaxRowTextNumber(text)`
+
+计算换行文本的最长一行的文字数量。
+
+### `throttle(fn, ctx, time = 100)`
+
+节流函数。
+
+### `downloadFile(file, fileName)`
+
+下载文件。
+
+### `getElementCorners(element)`
+
+获取元素的四个角的坐标,应用了旋转之后的,返回一个数组。
+
+### `getMultiElementRectInfo(elementList = [])`
+
+获取多个元素的最外层包围框信息。返回`{minx,maxx,miny,maxy};`
+
+#### `createImageObj(url)`
+
+创建图片对象,即`new Image()`的图片对象,返回`promise`。
+
+
+
+## 16.图形绘制工具方法
+
+可以通过如下方式获取到内置的图形绘制工具方法:
+
+```js
+import TinyWhiteboard from "tiny-whiteboard";
+
+TinyWhiteboard.draw.xxx
+```
+
+### `drawRect(ctx, x, y, width, height, fill = false)`
+
+绘制矩形。
+
+- `ctx`:`canvas`绘图上下文。
+
+- `fill`:是否进行填充。
+
+### `drawDiamond(ctx, x, y, width, height, fill = false)`
+
+绘制菱形。
+
+### `drawTriangle(ctx, x, y, width, height, fill = false)`
+
+绘制三角形。
+
+### `drawCircle(ctx, x, y, r, fill = false)`
+
+绘制圆形。
+
+### `drawLine(ctx, points)`
+
+绘制折线。
+
+- `points`:折线的端点列表。`Array`,数组的每一项为`[x, y]`。
+
+### `drawArrow(ctx, pointArr)`
+
+绘制箭头。
+
+`pointArr`:箭头的首尾两个顶点坐标,`Array`,数组的每一项为`[x, y]`。
+
+### `drawLineSegment(ctx, mx, my, tx, ty, lineWidth = 0)`
+
+绘制线段。
+
+- `lineWidth`:线宽
+
+### `drawText(ctx, textObj, x, y)`
+
+绘制文字。
+
+- `textObj`:文本数据,`Object`,字段如下:
+  - `textObj.text`:文本字符串;
+  - `textObj.style`:文本属性,`Object`,需包含三个字段:`fontSize`、`fontFamily`、`lineHeightRatio(行高,需为数字类型,比如1.5,代表行高1.5倍)`;
+
+### `drawImage(ctx, element, x, y, width, height)`
+
+绘制图片。
+
+`element`:图片数据,`Object`,字段如下:
+
+`element.ratio`:图片的长宽比;
+
+`element.imageObj`:要绘制到`canvas`上的图片对象或其他能被`canvas`绘制的对象;
+
+
+
+## 17.图形点击检测工具方法
+
+可以通过如下方式获取到内置的图形点击检测工具方法:
+
+```js
+import TinyWhiteboard from "tiny-whiteboard";
+
+TinyWhiteboard.checkHit.xxx
+```
+
+### `checkIsAtMultiSegment(segments, rp)`
+
+检测是否点击到折线上。
+
+- `segments`:折线的端点数组,`Array`,数组的每一项为`[fx, fy, tx, ty]`,即线段的首尾两个坐标;
+
+- `rp`:要检测的点,格式为`{x, y}`;
+
+### `checkIsAtRectangleEdge(element, rp)`
+
+检测是否点击到矩形边缘。
+
+- `element`:`Object`,需包含字段`{ x, y, width, height }`
+
+### `checkIsAtRectangleInner(element, rp)`
+
+检测是否点击到矩形内部。
+
+### `checkIsAtCircleEdge(element, rp)`
+
+检测是否点击到圆的边缘,圆的半径为`width`和`height`中的较小值的一半。
+
+### `checkIsAtLineEdge(element, rp)`
+
+检测是否点击到线段边缘。
+
+- `element`:`Object`,格式如下:
+
+```js
+{
+    pointArr: [
+        [x, y],
+        ...
+    ]
+}
+```
+
+### `checkIsAtFreedrawLineEdge(element, rp)`
+
+检测是否点击到自由画笔图形边缘。`element`字段同上。
+
+### `checkIsAtDiamondEdge(element, rp)`
+
+检测是否点击到菱形边缘。
+
+- `element`:`Object`,需包含字段`{ x, y, width, height }`
+
+### `checkIsAtTriangleEdge(element, rp)`
+
+检测是否点击到三角形边缘。
+
+- `element`:`Object`,需包含字段`{ x, y, width, height }`
+
+### `checkIsAtArrowEdge(element, rp)`
+
+检测是否点击到箭头边缘。
+
+- `element`:`Object`,格式如下:
+
+```js
+{
+    pointArr: [
+        [x1, y1],
+        [x2, y2]
+    ]
+}
+```
+
+
+
+## 18.内置元素类
+
+可以通过以下方式获取到某个元素类:
+
+```js
+import TinyWhiteboard from "tiny-whiteboard";
+
+TinyWhiteboard.elements.xxx
+```
+
+目前存在以下元素类:
+
+| 类名                  | 简介                                                         |
+| --------------------- | ------------------------------------------------------------ |
+| BaseElement           | 基础元素类,不用来实例化                                     |
+| Arrow                 | 箭头元素类,继承自`BaseElement`                              |
+| Circle                | 正圆元素类,继承自`BaseElement`                              |
+| Diamond               | 菱形元素类,继承自`BaseElement`                              |
+| Image                 | 图片元素类,继承自`BaseElement`                              |
+| Rectangle             | 矩形元素类,继承自`BaseElement`                              |
+| Text                  | 文本元素类,继承自`BaseElement`                              |
+| Triangle              | 三角形元素类,继承自`BaseElement`                            |
+| BaseMultiPointElement | 由多个坐标组成的元素的基础类,继承自`BaseElement`,不用来实例化 |
+| Freedraw              | 自由画笔元素类,继承自`BaseMultiPointElement`                |
+| Line                  | 线段/折线元素类,继承自`BaseMultiPointElement`               |
+| DragElement           | 拖拽元素类,继承自`BaseElement`,每个元素都会实例化一个该类,用来当元素激活时显示拖拽框及进行元素调整操作 |
+| MultiSelectElement    | 用于多选情况下的虚拟元素类,继承自`BaseElement`              |
+
+### 基础元素实例属性
+
+| 属性名称      | 类型    | 描述                                                         |
+| ------------- | ------- | ------------------------------------------------------------ |
+| type          | String  | 元素类型                                                     |
+| isActive      | Boolean | 是否被激活                                                   |
+| isSelected    | Boolean | 是否被多选选中                                               |
+| x、y          | Number  | 元素的位置                                                   |
+| width、height | Number  | 元素的宽高                                                   |
+| rotate        | Number  | 元素的旋转角度                                               |
+| style         | Object  | 元素的样式对象                                               |
+| pointArr      | Array   | 由多个点组成的元素(Arrow、Line、Freedraw)的特有属性。组成元素的点坐标数组。 |
+
+### 基础元素实例方法
+
+#### `on(eventName, callback, context)`
+
+v0.1.4+。监听元素事件。事件请见下方表格:
+
+| 事件名称            | 描述                             | 回调参数                                             |
+| ------------------- | -------------------------------- | ---------------------------------------------------- |
+| elementPositionChange   | 元素x、y坐标发生变化时触发          | x、y                                  |
+| elementSizeChange       | 元素width、height大小发生变化时触发 | width、height                         |
+| elementRotateChange     | 元素rotate旋转角度发生变化时触发    | rotate                                |
+
+#### serialize()
+
+序列化元素,返回的数据可用于进行持久化及回显。
+
+#### render()
+
+渲染元素。
+
+#### setStyle(style = {})
+
+设置元素的绘图样式。
+
+#### move(ox, oy)
+
+移动元素,在元素当前的位置上累加`ox`、`oy`。
+
+#### updatePos(x, y)
+
+更新元素的位置。
+
+#### updateSize(width, height)
+
+更新元素的宽高。
+
+#### updateRect(x, y, width, height)
+
+更新元素的位置及宽高。
+
+#### offsetRotate(or)
+
+旋转元素,在元素当前的旋转角度上累加`or`角度。
+
+#### rotateByCenter(rotate, cx, cy)
+
+根据指定中心点旋转元素的各个点。对于由多个点坐标组成的元素来说是修改其`pointArr`坐标,对于其他元素来说是修改其`x、y`坐标。
+
+#### isHit(x, y)
+
+检测该坐标是否能击中该元素。
+
+#### getEndpointList()
+
+v0.1.4+。获取图形应用了旋转之后的端点列表。可用于计算元素的外包围框数据。
+
+# License
+
+[MIT](https://opensource.org/licenses/MIT)
+

+ 5 - 0
app/.prettierignore

@@ -0,0 +1,5 @@
+dist
+node_modules
+package-lock.json
+package.json
+public

+ 5 - 0
app/.prettierrc

@@ -0,0 +1,5 @@
+semi: false
+singleQuote: true
+printWidth: 80
+trailingComma: 'none'
+arrowParens: 'avoid'

+ 15 - 0
app/index.html

@@ -0,0 +1,15 @@
+<!DOCTYPE html>
+<html lang="en">
+  <head>
+    <meta charset="UTF-8" />
+    <link rel="icon" href="/favicon.ico" />
+    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
+    <title>在线小白板</title>
+    <link href="/libs/jsonTree.css" rel="stylesheet" />
+  </head>
+  <body>
+    <div id="app"></div>
+    <script src="/libs/jsonTree.js"></script>
+    <script type="module" src="/src/main.js"></script>
+  </body>
+</html>

+ 2527 - 0
app/package-lock.json

@@ -0,0 +1,2527 @@
+{
+  "name": "tiny_whiteboard_demo",
+  "version": "0.0.0",
+  "lockfileVersion": 2,
+  "requires": true,
+  "packages": {
+    "": {
+      "name": "tiny_whiteboard_demo",
+      "version": "0.0.0",
+      "dependencies": {
+        "element-plus": "^2.1.6",
+        "vue": "^3.2.25"
+      },
+      "devDependencies": {
+        "@vitejs/plugin-vue": "^2.2.0",
+        "less": "^4.1.2",
+        "less-loader": "^10.2.0",
+        "prettier": "^2.7.1",
+        "unplugin-auto-import": "^0.6.6",
+        "unplugin-vue-components": "^0.18.5",
+        "vite": "^2.8.0"
+      }
+    },
+    "node_modules/@antfu/utils": {
+      "version": "0.5.0",
+      "resolved": "https://registry.npmjs.org/@antfu/utils/-/utils-0.5.0.tgz",
+      "integrity": "sha512-MrAQ/MrPSxbh1bBrmwJjORfJymw4IqSHFBXqvxaga3ZdDM+/zokYF8DjyJpSjY2QmpmgQrajDUBJOWrYeARfzA==",
+      "dev": true
+    },
+    "node_modules/@babel/parser": {
+      "version": "7.17.8",
+      "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.17.8.tgz",
+      "integrity": "sha512-BoHhDJrJXqcg+ZL16Xv39H9n+AqJ4pcDrQBGZN+wHxIysrLZ3/ECwCBUch/1zUNhnsXULcONU3Ei5Hmkfk6kiQ==",
+      "bin": {
+        "parser": "bin/babel-parser.js"
+      },
+      "engines": {
+        "node": ">=6.0.0"
+      }
+    },
+    "node_modules/@ctrl/tinycolor": {
+      "version": "3.4.0",
+      "resolved": "https://registry.npmjs.org/@ctrl/tinycolor/-/tinycolor-3.4.0.tgz",
+      "integrity": "sha512-JZButFdZ1+/xAfpguQHoabIXkcqRRKpMrWKBkpEZZyxfY9C1DpADFB8PEqGSTeFr135SaTRfKqGKx5xSCLI7ZQ==",
+      "engines": {
+        "node": ">=10"
+      }
+    },
+    "node_modules/@element-plus/icons-vue": {
+      "version": "1.1.4",
+      "resolved": "https://registry.npmjs.org/@element-plus/icons-vue/-/icons-vue-1.1.4.tgz",
+      "integrity": "sha512-Iz/nHqdp1sFPmdzRwHkEQQA3lKvoObk8azgABZ81QUOpW9s/lUyQVUSh0tNtEPZXQlKwlSh7SPgoVxzrE0uuVQ=="
+    },
+    "node_modules/@floating-ui/core": {
+      "version": "0.6.1",
+      "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-0.6.1.tgz",
+      "integrity": "sha512-Y30eVMcZva8o84c0HcXAtDO4BEzPJMvF6+B7x7urL2xbAqVsGJhojOyHLaoQHQYjb6OkqRq5kO+zeySycQwKqg=="
+    },
+    "node_modules/@floating-ui/dom": {
+      "version": "0.4.2",
+      "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-0.4.2.tgz",
+      "integrity": "sha512-2/4vOhMAujkMmGqGX1Gut84phe5MNfk1kGeM+RSTJCpeR0SWR2/RR+/f1r1msOvTQa28wn7HEhxGe71CjYY/vw==",
+      "dependencies": {
+        "@floating-ui/core": "^0.6.1"
+      }
+    },
+    "node_modules/@nodelib/fs.scandir": {
+      "version": "2.1.5",
+      "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz",
+      "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==",
+      "dev": true,
+      "dependencies": {
+        "@nodelib/fs.stat": "2.0.5",
+        "run-parallel": "^1.1.9"
+      },
+      "engines": {
+        "node": ">= 8"
+      }
+    },
+    "node_modules/@nodelib/fs.stat": {
+      "version": "2.0.5",
+      "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz",
+      "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==",
+      "dev": true,
+      "engines": {
+        "node": ">= 8"
+      }
+    },
+    "node_modules/@nodelib/fs.walk": {
+      "version": "1.2.8",
+      "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz",
+      "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==",
+      "dev": true,
+      "dependencies": {
+        "@nodelib/fs.scandir": "2.1.5",
+        "fastq": "^1.6.0"
+      },
+      "engines": {
+        "node": ">= 8"
+      }
+    },
+    "node_modules/@popperjs/core": {
+      "version": "2.11.4",
+      "resolved": "https://registry.npmjs.org/@popperjs/core/-/core-2.11.4.tgz",
+      "integrity": "sha512-q/ytXxO5NKvyT37pmisQAItCFqA7FD/vNb8dgaJy3/630Fsc+Mz9/9f2SziBoIZ30TJooXyTwZmhi1zjXmObYg=="
+    },
+    "node_modules/@rollup/pluginutils": {
+      "version": "4.2.0",
+      "resolved": "https://registry.npmjs.org/@rollup/pluginutils/-/pluginutils-4.2.0.tgz",
+      "integrity": "sha512-2WUyJNRkyH5p487pGnn4tWAsxhEFKN/pT8CMgHshd5H+IXkOnKvKZwsz5ZWz+YCXkleZRAU5kwbfgF8CPfDRqA==",
+      "dev": true,
+      "dependencies": {
+        "estree-walker": "^2.0.1",
+        "picomatch": "^2.2.2"
+      },
+      "engines": {
+        "node": ">= 8.0.0"
+      }
+    },
+    "node_modules/@vitejs/plugin-vue": {
+      "version": "2.3.1",
+      "resolved": "https://registry.npmjs.org/@vitejs/plugin-vue/-/plugin-vue-2.3.1.tgz",
+      "integrity": "sha512-YNzBt8+jt6bSwpt7LP890U1UcTOIZZxfpE5WOJ638PNxSEKOqAi0+FSKS0nVeukfdZ0Ai/H7AFd6k3hayfGZqQ==",
+      "dev": true,
+      "engines": {
+        "node": ">=12.0.0"
+      }
+    },
+    "node_modules/@vue/compiler-core": {
+      "version": "3.2.31",
+      "resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.2.31.tgz",
+      "integrity": "sha512-aKno00qoA4o+V/kR6i/pE+aP+esng5siNAVQ422TkBNM6qA4veXiZbSe8OTXHXquEi/f6Akc+nLfB4JGfe4/WQ==",
+      "dependencies": {
+        "@babel/parser": "^7.16.4",
+        "@vue/shared": "3.2.31",
+        "estree-walker": "^2.0.2",
+        "source-map": "^0.6.1"
+      }
+    },
+    "node_modules/@vue/compiler-dom": {
+      "version": "3.2.31",
+      "resolved": "https://registry.npmjs.org/@vue/compiler-dom/-/compiler-dom-3.2.31.tgz",
+      "integrity": "sha512-60zIlFfzIDf3u91cqfqy9KhCKIJgPeqxgveH2L+87RcGU/alT6BRrk5JtUso0OibH3O7NXuNOQ0cDc9beT0wrg==",
+      "dependencies": {
+        "@vue/compiler-core": "3.2.31",
+        "@vue/shared": "3.2.31"
+      }
+    },
+    "node_modules/@vue/compiler-sfc": {
+      "version": "3.2.31",
+      "resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-3.2.31.tgz",
+      "integrity": "sha512-748adc9msSPGzXgibHiO6T7RWgfnDcVQD+VVwYgSsyyY8Ans64tALHZANrKtOzvkwznV/F4H7OAod/jIlp/dkQ==",
+      "dependencies": {
+        "@babel/parser": "^7.16.4",
+        "@vue/compiler-core": "3.2.31",
+        "@vue/compiler-dom": "3.2.31",
+        "@vue/compiler-ssr": "3.2.31",
+        "@vue/reactivity-transform": "3.2.31",
+        "@vue/shared": "3.2.31",
+        "estree-walker": "^2.0.2",
+        "magic-string": "^0.25.7",
+        "postcss": "^8.1.10",
+        "source-map": "^0.6.1"
+      }
+    },
+    "node_modules/@vue/compiler-ssr": {
+      "version": "3.2.31",
+      "resolved": "https://registry.npmjs.org/@vue/compiler-ssr/-/compiler-ssr-3.2.31.tgz",
+      "integrity": "sha512-mjN0rqig+A8TVDnsGPYJM5dpbjlXeHUm2oZHZwGyMYiGT/F4fhJf/cXy8QpjnLQK4Y9Et4GWzHn9PS8AHUnSkw==",
+      "dependencies": {
+        "@vue/compiler-dom": "3.2.31",
+        "@vue/shared": "3.2.31"
+      }
+    },
+    "node_modules/@vue/reactivity": {
+      "version": "3.2.31",
+      "resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.2.31.tgz",
+      "integrity": "sha512-HVr0l211gbhpEKYr2hYe7hRsV91uIVGFYNHj73njbARVGHQvIojkImKMaZNDdoDZOIkMsBc9a1sMqR+WZwfSCw==",
+      "dependencies": {
+        "@vue/shared": "3.2.31"
+      }
+    },
+    "node_modules/@vue/reactivity-transform": {
+      "version": "3.2.31",
+      "resolved": "https://registry.npmjs.org/@vue/reactivity-transform/-/reactivity-transform-3.2.31.tgz",
+      "integrity": "sha512-uS4l4z/W7wXdI+Va5pgVxBJ345wyGFKvpPYtdSgvfJfX/x2Ymm6ophQlXXB6acqGHtXuBqNyyO3zVp9b1r0MOA==",
+      "dependencies": {
+        "@babel/parser": "^7.16.4",
+        "@vue/compiler-core": "3.2.31",
+        "@vue/shared": "3.2.31",
+        "estree-walker": "^2.0.2",
+        "magic-string": "^0.25.7"
+      }
+    },
+    "node_modules/@vue/runtime-core": {
+      "version": "3.2.31",
+      "resolved": "https://registry.npmjs.org/@vue/runtime-core/-/runtime-core-3.2.31.tgz",
+      "integrity": "sha512-Kcog5XmSY7VHFEMuk4+Gap8gUssYMZ2+w+cmGI6OpZWYOEIcbE0TPzzPHi+8XTzAgx1w/ZxDFcXhZeXN5eKWsA==",
+      "dependencies": {
+        "@vue/reactivity": "3.2.31",
+        "@vue/shared": "3.2.31"
+      }
+    },
+    "node_modules/@vue/runtime-dom": {
+      "version": "3.2.31",
+      "resolved": "https://registry.npmjs.org/@vue/runtime-dom/-/runtime-dom-3.2.31.tgz",
+      "integrity": "sha512-N+o0sICVLScUjfLG7u9u5XCjvmsexAiPt17GNnaWHJUfsKed5e85/A3SWgKxzlxx2SW/Hw7RQxzxbXez9PtY3g==",
+      "dependencies": {
+        "@vue/runtime-core": "3.2.31",
+        "@vue/shared": "3.2.31",
+        "csstype": "^2.6.8"
+      }
+    },
+    "node_modules/@vue/server-renderer": {
+      "version": "3.2.31",
+      "resolved": "https://registry.npmjs.org/@vue/server-renderer/-/server-renderer-3.2.31.tgz",
+      "integrity": "sha512-8CN3Zj2HyR2LQQBHZ61HexF5NReqngLT3oahyiVRfSSvak+oAvVmu8iNLSu6XR77Ili2AOpnAt1y8ywjjqtmkg==",
+      "dependencies": {
+        "@vue/compiler-ssr": "3.2.31",
+        "@vue/shared": "3.2.31"
+      }
+    },
+    "node_modules/@vue/shared": {
+      "version": "3.2.31",
+      "resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.2.31.tgz",
+      "integrity": "sha512-ymN2pj6zEjiKJZbrf98UM2pfDd6F2H7ksKw7NDt/ZZ1fh5Ei39X5tABugtT03ZRlWd9imccoK0hE8hpjpU7irQ=="
+    },
+    "node_modules/@vueuse/core": {
+      "version": "8.2.3",
+      "resolved": "https://registry.npmjs.org/@vueuse/core/-/core-8.2.3.tgz",
+      "integrity": "sha512-bz6XroFRFOIGzhAHcnTfjtAQNkNcSELKPoSSUGROwYfOgTnaVyq7iKhgwdeRNom3y9q+a60RlhD35oJaGsXDHA==",
+      "dependencies": {
+        "@vueuse/metadata": "8.2.3",
+        "@vueuse/shared": "8.2.3",
+        "vue-demi": "*"
+      }
+    },
+    "node_modules/@vueuse/metadata": {
+      "version": "8.2.3",
+      "resolved": "https://registry.npmjs.org/@vueuse/metadata/-/metadata-8.2.3.tgz",
+      "integrity": "sha512-xH5256Bn6hBYvQWoaCBagpeCrzJmeEeWnyDDkwVFhx7+pLOe4I6gsMHie3yJoowK9AN/D7JLTtEBbWvvBi5TOA=="
+    },
+    "node_modules/@vueuse/shared": {
+      "version": "8.2.3",
+      "resolved": "https://registry.npmjs.org/@vueuse/shared/-/shared-8.2.3.tgz",
+      "integrity": "sha512-4Cd3s+x8ZwzzAf7o8jS7mEj9pQ1Zsf9aiNBAFg4rHcWeDY0S3XMgvN4ae2hBul7jmi+Ab4REAqiqYWyYqU86qg==",
+      "dependencies": {
+        "vue-demi": "*"
+      }
+    },
+    "node_modules/anymatch": {
+      "version": "3.1.2",
+      "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.2.tgz",
+      "integrity": "sha512-P43ePfOAIupkguHUycrc4qJ9kz8ZiuOUijaETwX7THt0Y/GNK7v0aa8rY816xWjZ7rJdA5XdMcpVFTKMq+RvWg==",
+      "dev": true,
+      "dependencies": {
+        "normalize-path": "^3.0.0",
+        "picomatch": "^2.0.4"
+      },
+      "engines": {
+        "node": ">= 8"
+      }
+    },
+    "node_modules/async-validator": {
+      "version": "4.0.7",
+      "resolved": "https://registry.npmjs.org/async-validator/-/async-validator-4.0.7.tgz",
+      "integrity": "sha512-Pj2IR7u8hmUEDOwB++su6baaRi+QvsgajuFB9j95foM1N2gy5HM4z60hfusIO0fBPG5uLAEl6yCJr1jNSVugEQ=="
+    },
+    "node_modules/balanced-match": {
+      "version": "1.0.2",
+      "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
+      "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==",
+      "dev": true
+    },
+    "node_modules/binary-extensions": {
+      "version": "2.2.0",
+      "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.2.0.tgz",
+      "integrity": "sha512-jDctJ/IVQbZoJykoeHbhXpOlNBqGNcwXJKJog42E5HDPUwQTSdjCHdihjj0DlnheQ7blbT6dHOafNAiS8ooQKA==",
+      "dev": true,
+      "engines": {
+        "node": ">=8"
+      }
+    },
+    "node_modules/brace-expansion": {
+      "version": "2.0.1",
+      "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz",
+      "integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==",
+      "dev": true,
+      "dependencies": {
+        "balanced-match": "^1.0.0"
+      }
+    },
+    "node_modules/braces": {
+      "version": "3.0.2",
+      "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.2.tgz",
+      "integrity": "sha512-b8um+L1RzM3WDSzvhm6gIz1yfTbBt6YTlcEKAvsmqCZZFw46z626lVj9j1yEPW33H5H+lBQpZMP1k8l+78Ha0A==",
+      "dev": true,
+      "dependencies": {
+        "fill-range": "^7.0.1"
+      },
+      "engines": {
+        "node": ">=8"
+      }
+    },
+    "node_modules/chokidar": {
+      "version": "3.5.3",
+      "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.5.3.tgz",
+      "integrity": "sha512-Dr3sfKRP6oTcjf2JmUmFJfeVMvXBdegxB0iVQ5eb2V10uFJUCAS8OByZdVAyVb8xXNz3GjjTgj9kLWsZTqE6kw==",
+      "dev": true,
+      "dependencies": {
+        "anymatch": "~3.1.2",
+        "braces": "~3.0.2",
+        "glob-parent": "~5.1.2",
+        "is-binary-path": "~2.1.0",
+        "is-glob": "~4.0.1",
+        "normalize-path": "~3.0.0",
+        "readdirp": "~3.6.0"
+      },
+      "engines": {
+        "node": ">= 8.10.0"
+      },
+      "optionalDependencies": {
+        "fsevents": "~2.3.2"
+      }
+    },
+    "node_modules/copy-anything": {
+      "version": "2.0.6",
+      "resolved": "https://registry.npmjs.org/copy-anything/-/copy-anything-2.0.6.tgz",
+      "integrity": "sha512-1j20GZTsvKNkc4BY3NpMOM8tt///wY3FpIzozTOFO2ffuZcV61nojHXVKIy3WM+7ADCy5FVhdZYHYDdgTU0yJw==",
+      "dev": true,
+      "dependencies": {
+        "is-what": "^3.14.1"
+      }
+    },
+    "node_modules/csstype": {
+      "version": "2.6.20",
+      "resolved": "https://registry.npmjs.org/csstype/-/csstype-2.6.20.tgz",
+      "integrity": "sha512-/WwNkdXfckNgw6S5R125rrW8ez139lBHWouiBvX8dfMFtcn6V81REDqnH7+CRpRipfYlyU1CmOnOxrmGcFOjeA=="
+    },
+    "node_modules/dayjs": {
+      "version": "1.11.0",
+      "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.0.tgz",
+      "integrity": "sha512-JLC809s6Y948/FuCZPm5IX8rRhQwOiyMb2TfVVQEixG7P8Lm/gt5S7yoQZmC8x1UehI9Pb7sksEt4xx14m+7Ug=="
+    },
+    "node_modules/debug": {
+      "version": "3.2.7",
+      "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz",
+      "integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==",
+      "dev": true,
+      "optional": true,
+      "dependencies": {
+        "ms": "^2.1.1"
+      }
+    },
+    "node_modules/element-plus": {
+      "version": "2.1.7",
+      "resolved": "https://registry.npmjs.org/element-plus/-/element-plus-2.1.7.tgz",
+      "integrity": "sha512-jamE9F/a2rfAQJwf3kLaDfWXxhjXuAJAvrto76SLJsJfr3iIUAzC849RHdn6h7tNJy9Yanq8GlQAsdBe5lJmrA==",
+      "dependencies": {
+        "@ctrl/tinycolor": "^3.4.0",
+        "@element-plus/icons-vue": "^1.1.4",
+        "@floating-ui/dom": "^0.4.1",
+        "@popperjs/core": "^2.11.4",
+        "@vueuse/core": "^8.1.2",
+        "async-validator": "^4.0.7",
+        "dayjs": "^1.11.0",
+        "escape-html": "^1.0.3",
+        "lodash": "^4.17.21",
+        "lodash-es": "^4.17.21",
+        "lodash-unified": "^1.0.2",
+        "memoize-one": "^6.0.0",
+        "normalize-wheel-es": "^1.1.1"
+      }
+    },
+    "node_modules/errno": {
+      "version": "0.1.8",
+      "resolved": "https://registry.npmjs.org/errno/-/errno-0.1.8.tgz",
+      "integrity": "sha512-dJ6oBr5SQ1VSd9qkk7ByRgb/1SH4JZjCHSW/mr63/QcXO9zLVxvJ6Oy13nio03rxpSnVDDjFor75SjVeZWPW/A==",
+      "dev": true,
+      "optional": true,
+      "dependencies": {
+        "prr": "~1.0.1"
+      },
+      "bin": {
+        "errno": "cli.js"
+      }
+    },
+    "node_modules/esbuild": {
+      "version": "0.14.30",
+      "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.14.30.tgz",
+      "integrity": "sha512-wCecQSBkIjp2xjuXY+wcXS/PpOQo9rFh4NAKPh4Pm9f3fuLcnxkR0rDzA+mYP88FtXIUcXUyYmaIgfrzRl55jA==",
+      "dev": true,
+      "hasInstallScript": true,
+      "bin": {
+        "esbuild": "bin/esbuild"
+      },
+      "engines": {
+        "node": ">=12"
+      },
+      "optionalDependencies": {
+        "esbuild-android-64": "0.14.30",
+        "esbuild-android-arm64": "0.14.30",
+        "esbuild-darwin-64": "0.14.30",
+        "esbuild-darwin-arm64": "0.14.30",
+        "esbuild-freebsd-64": "0.14.30",
+        "esbuild-freebsd-arm64": "0.14.30",
+        "esbuild-linux-32": "0.14.30",
+        "esbuild-linux-64": "0.14.30",
+        "esbuild-linux-arm": "0.14.30",
+        "esbuild-linux-arm64": "0.14.30",
+        "esbuild-linux-mips64le": "0.14.30",
+        "esbuild-linux-ppc64le": "0.14.30",
+        "esbuild-linux-riscv64": "0.14.30",
+        "esbuild-linux-s390x": "0.14.30",
+        "esbuild-netbsd-64": "0.14.30",
+        "esbuild-openbsd-64": "0.14.30",
+        "esbuild-sunos-64": "0.14.30",
+        "esbuild-windows-32": "0.14.30",
+        "esbuild-windows-64": "0.14.30",
+        "esbuild-windows-arm64": "0.14.30"
+      }
+    },
+    "node_modules/esbuild-android-64": {
+      "version": "0.14.30",
+      "resolved": "https://registry.npmjs.org/esbuild-android-64/-/esbuild-android-64-0.14.30.tgz",
+      "integrity": "sha512-vdJ7t8A8msPfKpYUGUV/KaTQRiZ0vDa2XSTlzXVkGGVHLKPeb85PBUtYJcEgw3htW3IdX5i1t1IMdQCwJJgNAg==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "optional": true,
+      "os": [
+        "android"
+      ],
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/esbuild-android-arm64": {
+      "version": "0.14.30",
+      "resolved": "https://registry.npmjs.org/esbuild-android-arm64/-/esbuild-android-arm64-0.14.30.tgz",
+      "integrity": "sha512-BdgGfxeA5hBQNErLr7BWJUA8xjflEfyaARICy8e0OJYNSAwDbEzOf8LyiKWSrDcgV129mWhi3VpbNQvOIDEHcg==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "optional": true,
+      "os": [
+        "android"
+      ],
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/esbuild-darwin-64": {
+      "version": "0.14.30",
+      "resolved": "https://registry.npmjs.org/esbuild-darwin-64/-/esbuild-darwin-64-0.14.30.tgz",
+      "integrity": "sha512-VRaOXMMrsG5n53pl4qFZQdXy2+E0NoLP/QH3aDUI0+bQP+ZHDmbINKcDy2IX7GVFI9kqPS18iJNAs5a6/G2LZg==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "optional": true,
+      "os": [
+        "darwin"
+      ],
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/esbuild-darwin-arm64": {
+      "version": "0.14.30",
+      "resolved": "https://registry.npmjs.org/esbuild-darwin-arm64/-/esbuild-darwin-arm64-0.14.30.tgz",
+      "integrity": "sha512-qDez+fHMOrO9Oc9qjt/x+sy09RJVh62kik5tVybKRLmezeV4qczM9/sAYY57YN0aWLdHbcCj2YqJUWYJNsgKnw==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "optional": true,
+      "os": [
+        "darwin"
+      ],
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/esbuild-freebsd-64": {
+      "version": "0.14.30",
+      "resolved": "https://registry.npmjs.org/esbuild-freebsd-64/-/esbuild-freebsd-64-0.14.30.tgz",
+      "integrity": "sha512-mec1jENcImVVagddZlGWsdAUwBnzR5cgnhzCxv+9fSMxKbx1uZYLLUAnLPp8m/i934zrumR1xGjJ5VoWdPlI2w==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "optional": true,
+      "os": [
+        "freebsd"
+      ],
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/esbuild-freebsd-arm64": {
+      "version": "0.14.30",
+      "resolved": "https://registry.npmjs.org/esbuild-freebsd-arm64/-/esbuild-freebsd-arm64-0.14.30.tgz",
+      "integrity": "sha512-cpjbTs6Iok/AfeB0JgTzyUJTMStC1SQULmany5nHx6S4GTkSgaAHuJzZO0GcVWqghI4e0YL/bjXAhN5Mn6feNw==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "optional": true,
+      "os": [
+        "freebsd"
+      ],
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/esbuild-linux-32": {
+      "version": "0.14.30",
+      "resolved": "https://registry.npmjs.org/esbuild-linux-32/-/esbuild-linux-32-0.14.30.tgz",
+      "integrity": "sha512-liIONVT4F2kZmOMwtwASqZ8WkIjb5HHBR9HUffdHiuotSTF3CyZO+EJf+Og+SYYuuVIvt0qHNSFjBA/iSESteQ==",
+      "cpu": [
+        "ia32"
+      ],
+      "dev": true,
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/esbuild-linux-64": {
+      "version": "0.14.30",
+      "resolved": "https://registry.npmjs.org/esbuild-linux-64/-/esbuild-linux-64-0.14.30.tgz",
+      "integrity": "sha512-LUnpzoMpRqFON5En4qEj6NWiyH6a1K+Y2qYNKrCy5qPTjDoG/EWeqMz69n8Uv7pRuvDKl3FNGJ1dufTrA5i0sw==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/esbuild-linux-arm": {
+      "version": "0.14.30",
+      "resolved": "https://registry.npmjs.org/esbuild-linux-arm/-/esbuild-linux-arm-0.14.30.tgz",
+      "integrity": "sha512-97T+bbXnpqf7mfIG49UR7ZSJFGgvc22byn74qw3Kx2GDCBSQoVFjyWuKOHGXp8nXk3XYrdFF+mQ8yQ7aNsgQvg==",
+      "cpu": [
+        "arm"
+      ],
+      "dev": true,
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/esbuild-linux-arm64": {
+      "version": "0.14.30",
+      "resolved": "https://registry.npmjs.org/esbuild-linux-arm64/-/esbuild-linux-arm64-0.14.30.tgz",
+      "integrity": "sha512-DHZHn6FK5q/KL0fpNT/0jE38Nnyk2rXxKE9WENi95EXtqfOLPgE8tzjTZQNgpr61R95QX4ymQU26ni3IZk8buQ==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/esbuild-linux-mips64le": {
+      "version": "0.14.30",
+      "resolved": "https://registry.npmjs.org/esbuild-linux-mips64le/-/esbuild-linux-mips64le-0.14.30.tgz",
+      "integrity": "sha512-fLUzTFZ7uknC0aPTk7/lM7NmaG/9ZqE3SaHEphcaM009SZK/mDOvZugWi1ss6WGNhk13dUrhkfHcc4FSb9hYhg==",
+      "cpu": [
+        "mips64el"
+      ],
+      "dev": true,
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/esbuild-linux-ppc64le": {
+      "version": "0.14.30",
+      "resolved": "https://registry.npmjs.org/esbuild-linux-ppc64le/-/esbuild-linux-ppc64le-0.14.30.tgz",
+      "integrity": "sha512-2Oudm2WEfj0dNU9bzIl5L/LrsMEmHWsOsYgJJqu8fDyUDgER+J1d33qz3cUdjsJk7gAENayIxDSpsuCszx0w3A==",
+      "cpu": [
+        "ppc64"
+      ],
+      "dev": true,
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/esbuild-linux-riscv64": {
+      "version": "0.14.30",
+      "resolved": "https://registry.npmjs.org/esbuild-linux-riscv64/-/esbuild-linux-riscv64-0.14.30.tgz",
+      "integrity": "sha512-RPMucPW47rV4t2jlelaE948iCRtbZf5RhifxSwzlpM1Mqdyu99MMNK0w4jFreGTmLN+oGomxIOxD6n+2E/XqHw==",
+      "cpu": [
+        "riscv64"
+      ],
+      "dev": true,
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/esbuild-linux-s390x": {
+      "version": "0.14.30",
+      "resolved": "https://registry.npmjs.org/esbuild-linux-s390x/-/esbuild-linux-s390x-0.14.30.tgz",
+      "integrity": "sha512-OZ68r7ok6qO7hdwrwQn2p5jbIRRcUcVaAykB7e0uCA0ODwfeGunILM6phJtq2Oz4dlEEFvd+tSuma3paQKwt+A==",
+      "cpu": [
+        "s390x"
+      ],
+      "dev": true,
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/esbuild-netbsd-64": {
+      "version": "0.14.30",
+      "resolved": "https://registry.npmjs.org/esbuild-netbsd-64/-/esbuild-netbsd-64-0.14.30.tgz",
+      "integrity": "sha512-iyejQUKn0TzpPkufq8pSCxOg9NheycQbMbPCmjefTe9wYuUlBt1TcHvdoJnYbQzsAhAh1BNq+s0ycRsIJFZzaQ==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "optional": true,
+      "os": [
+        "netbsd"
+      ],
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/esbuild-openbsd-64": {
+      "version": "0.14.30",
+      "resolved": "https://registry.npmjs.org/esbuild-openbsd-64/-/esbuild-openbsd-64-0.14.30.tgz",
+      "integrity": "sha512-UyK1MTMcy4j5fH260fsE1o6MVgWNhb62eCK2yCKCRazZv8Nqdc2WiP9ygjWidmEdCDS+A6MuVp9ozk9uoQtQpA==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "optional": true,
+      "os": [
+        "openbsd"
+      ],
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/esbuild-sunos-64": {
+      "version": "0.14.30",
+      "resolved": "https://registry.npmjs.org/esbuild-sunos-64/-/esbuild-sunos-64-0.14.30.tgz",
+      "integrity": "sha512-aQRtRTNKHB4YuG+xXATe5AoRTNY48IJg5vjE8ElxfmjO9+KdX7MHFkTLhlKevCD6rNANtB3qOlSIeAiXTwHNqw==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "optional": true,
+      "os": [
+        "sunos"
+      ],
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/esbuild-windows-32": {
+      "version": "0.14.30",
+      "resolved": "https://registry.npmjs.org/esbuild-windows-32/-/esbuild-windows-32-0.14.30.tgz",
+      "integrity": "sha512-9/fb1tPtpacMqxAXp3fGHowUDg/l9dVch5hKmCLEZC6PdGljh6h372zMdJwYfH0Bd5CCPT0Wx95uycBLJiqpXA==",
+      "cpu": [
+        "ia32"
+      ],
+      "dev": true,
+      "optional": true,
+      "os": [
+        "win32"
+      ],
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/esbuild-windows-64": {
+      "version": "0.14.30",
+      "resolved": "https://registry.npmjs.org/esbuild-windows-64/-/esbuild-windows-64-0.14.30.tgz",
+      "integrity": "sha512-DHgITeUhPAnN9I5O6QBa1GVyPOhiYCn4S4TtQr7sO4+X0LNyqnlmA1M0qmGkUdDC1QQfjI8uQ4G/whdWb2pWIQ==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "optional": true,
+      "os": [
+        "win32"
+      ],
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/esbuild-windows-arm64": {
+      "version": "0.14.30",
+      "resolved": "https://registry.npmjs.org/esbuild-windows-arm64/-/esbuild-windows-arm64-0.14.30.tgz",
+      "integrity": "sha512-F1kLyQH7zSgjh5eLxogGZN7C9+KNs9m+s7Q6WZoMmCWT/6j998zlaoECHyM8izJRRfsvw2eZlEa1jO6/IOU1AQ==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "optional": true,
+      "os": [
+        "win32"
+      ],
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/escape-html": {
+      "version": "1.0.3",
+      "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz",
+      "integrity": "sha1-Aljq5NPQwJdN4cFpGI7wBR0dGYg="
+    },
+    "node_modules/estree-walker": {
+      "version": "2.0.2",
+      "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz",
+      "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w=="
+    },
+    "node_modules/fast-glob": {
+      "version": "3.2.11",
+      "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.2.11.tgz",
+      "integrity": "sha512-xrO3+1bxSo3ZVHAnqzyuewYT6aMFHRAd4Kcs92MAonjwQZLsK9d0SF1IyQ3k5PoirxTW0Oe/RqFgMQ6TcNE5Ew==",
+      "dev": true,
+      "dependencies": {
+        "@nodelib/fs.stat": "^2.0.2",
+        "@nodelib/fs.walk": "^1.2.3",
+        "glob-parent": "^5.1.2",
+        "merge2": "^1.3.0",
+        "micromatch": "^4.0.4"
+      },
+      "engines": {
+        "node": ">=8.6.0"
+      }
+    },
+    "node_modules/fastq": {
+      "version": "1.13.0",
+      "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.13.0.tgz",
+      "integrity": "sha512-YpkpUnK8od0o1hmeSc7UUs/eB/vIPWJYjKck2QKIzAf71Vm1AAQ3EbuZB3g2JIy+pg+ERD0vqI79KyZiB2e2Nw==",
+      "dev": true,
+      "dependencies": {
+        "reusify": "^1.0.4"
+      }
+    },
+    "node_modules/fill-range": {
+      "version": "7.0.1",
+      "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.0.1.tgz",
+      "integrity": "sha512-qOo9F+dMUmC2Lcb4BbVvnKJxTPjCm+RRpe4gDuGrzkL7mEVl/djYSu2OdQ2Pa302N4oqkSg9ir6jaLWJ2USVpQ==",
+      "dev": true,
+      "dependencies": {
+        "to-regex-range": "^5.0.1"
+      },
+      "engines": {
+        "node": ">=8"
+      }
+    },
+    "node_modules/fsevents": {
+      "version": "2.3.2",
+      "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
+      "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
+      "dev": true,
+      "optional": true,
+      "os": [
+        "darwin"
+      ],
+      "engines": {
+        "node": "^8.16.0 || ^10.6.0 || >=11.0.0"
+      }
+    },
+    "node_modules/function-bind": {
+      "version": "1.1.1",
+      "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.1.tgz",
+      "integrity": "sha512-yIovAzMX49sF8Yl58fSCWJ5svSLuaibPxXQJFLmBObTuCr0Mf1KiPopGM9NiFjiYBCbfaa2Fh6breQ6ANVTI0A==",
+      "dev": true
+    },
+    "node_modules/glob-parent": {
+      "version": "5.1.2",
+      "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz",
+      "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==",
+      "dev": true,
+      "dependencies": {
+        "is-glob": "^4.0.1"
+      },
+      "engines": {
+        "node": ">= 6"
+      }
+    },
+    "node_modules/graceful-fs": {
+      "version": "4.2.9",
+      "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.9.tgz",
+      "integrity": "sha512-NtNxqUcXgpW2iMrfqSfR73Glt39K+BLwWsPs94yR63v45T0Wbej7eRmL5cWfwEgqXnmjQp3zaJTshdRW/qC2ZQ==",
+      "dev": true,
+      "optional": true
+    },
+    "node_modules/has": {
+      "version": "1.0.3",
+      "resolved": "https://registry.npmjs.org/has/-/has-1.0.3.tgz",
+      "integrity": "sha512-f2dvO0VU6Oej7RkWJGrehjbzMAjFp5/VKPp5tTpWIV4JHHZK1/BxbFRtf/siA2SWTe09caDmVtYYzWEIbBS4zw==",
+      "dev": true,
+      "dependencies": {
+        "function-bind": "^1.1.1"
+      },
+      "engines": {
+        "node": ">= 0.4.0"
+      }
+    },
+    "node_modules/iconv-lite": {
+      "version": "0.4.24",
+      "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz",
+      "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==",
+      "dev": true,
+      "optional": true,
+      "dependencies": {
+        "safer-buffer": ">= 2.1.2 < 3"
+      },
+      "engines": {
+        "node": ">=0.10.0"
+      }
+    },
+    "node_modules/image-size": {
+      "version": "0.5.5",
+      "resolved": "https://registry.npmjs.org/image-size/-/image-size-0.5.5.tgz",
+      "integrity": "sha1-Cd/Uq50g4p6xw+gLiZA3jfnjy5w=",
+      "dev": true,
+      "optional": true,
+      "bin": {
+        "image-size": "bin/image-size.js"
+      },
+      "engines": {
+        "node": ">=0.10.0"
+      }
+    },
+    "node_modules/is-binary-path": {
+      "version": "2.1.0",
+      "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz",
+      "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==",
+      "dev": true,
+      "dependencies": {
+        "binary-extensions": "^2.0.0"
+      },
+      "engines": {
+        "node": ">=8"
+      }
+    },
+    "node_modules/is-core-module": {
+      "version": "2.8.1",
+      "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.8.1.tgz",
+      "integrity": "sha512-SdNCUs284hr40hFTFP6l0IfZ/RSrMXF3qgoRHd3/79unUTvrFO/JoXwkGm+5J/Oe3E/b5GsnG330uUNgRpu1PA==",
+      "dev": true,
+      "dependencies": {
+        "has": "^1.0.3"
+      }
+    },
+    "node_modules/is-extglob": {
+      "version": "2.1.1",
+      "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz",
+      "integrity": "sha1-qIwCU1eR8C7TfHahueqXc8gz+MI=",
+      "dev": true,
+      "engines": {
+        "node": ">=0.10.0"
+      }
+    },
+    "node_modules/is-glob": {
+      "version": "4.0.3",
+      "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz",
+      "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==",
+      "dev": true,
+      "dependencies": {
+        "is-extglob": "^2.1.1"
+      },
+      "engines": {
+        "node": ">=0.10.0"
+      }
+    },
+    "node_modules/is-number": {
+      "version": "7.0.0",
+      "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz",
+      "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==",
+      "dev": true,
+      "engines": {
+        "node": ">=0.12.0"
+      }
+    },
+    "node_modules/is-what": {
+      "version": "3.14.1",
+      "resolved": "https://registry.npmjs.org/is-what/-/is-what-3.14.1.tgz",
+      "integrity": "sha512-sNxgpk9793nzSs7bA6JQJGeIuRBQhAaNGG77kzYQgMkrID+lS6SlK07K5LaptscDlSaIgH+GPFzf+d75FVxozA==",
+      "dev": true
+    },
+    "node_modules/klona": {
+      "version": "2.0.5",
+      "resolved": "https://registry.npmjs.org/klona/-/klona-2.0.5.tgz",
+      "integrity": "sha512-pJiBpiXMbt7dkzXe8Ghj/u4FfXOOa98fPW+bihOJ4SjnoijweJrNThJfd3ifXpXhREjpoF2mZVH1GfS9LV3kHQ==",
+      "dev": true,
+      "engines": {
+        "node": ">= 8"
+      }
+    },
+    "node_modules/less": {
+      "version": "4.1.2",
+      "resolved": "https://registry.npmjs.org/less/-/less-4.1.2.tgz",
+      "integrity": "sha512-EoQp/Et7OSOVu0aJknJOtlXZsnr8XE8KwuzTHOLeVSEx8pVWUICc8Q0VYRHgzyjX78nMEyC/oztWFbgyhtNfDA==",
+      "dev": true,
+      "dependencies": {
+        "copy-anything": "^2.0.1",
+        "parse-node-version": "^1.0.1",
+        "source-map": "~0.6.0",
+        "tslib": "^2.3.0"
+      },
+      "bin": {
+        "lessc": "bin/lessc"
+      },
+      "engines": {
+        "node": ">=6"
+      },
+      "optionalDependencies": {
+        "errno": "^0.1.1",
+        "graceful-fs": "^4.1.2",
+        "image-size": "~0.5.0",
+        "make-dir": "^2.1.0",
+        "mime": "^1.4.1",
+        "needle": "^2.5.2"
+      }
+    },
+    "node_modules/less-loader": {
+      "version": "10.2.0",
+      "resolved": "https://registry.npmjs.org/less-loader/-/less-loader-10.2.0.tgz",
+      "integrity": "sha512-AV5KHWvCezW27GT90WATaDnfXBv99llDbtaj4bshq6DvAihMdNjaPDcUMa6EXKLRF+P2opFenJp89BXg91XLYg==",
+      "dev": true,
+      "dependencies": {
+        "klona": "^2.0.4"
+      },
+      "engines": {
+        "node": ">= 12.13.0"
+      }
+    },
+    "node_modules/local-pkg": {
+      "version": "0.4.1",
+      "resolved": "https://registry.npmjs.org/local-pkg/-/local-pkg-0.4.1.tgz",
+      "integrity": "sha512-lL87ytIGP2FU5PWwNDo0w3WhIo2gopIAxPg9RxDYF7m4rr5ahuZxP22xnJHIvaLTe4Z9P6uKKY2UHiwyB4pcrw==",
+      "dev": true,
+      "engines": {
+        "node": ">=14"
+      }
+    },
+    "node_modules/lodash": {
+      "version": "4.17.21",
+      "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz",
+      "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg=="
+    },
+    "node_modules/lodash-es": {
+      "version": "4.17.21",
+      "resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.17.21.tgz",
+      "integrity": "sha512-mKnC+QJ9pWVzv+C4/U3rRsHapFfHvQFoFB92e52xeyGMcX6/OlIl78je1u8vePzYZSkkogMPJ2yjxxsb89cxyw=="
+    },
+    "node_modules/lodash-unified": {
+      "version": "1.0.2",
+      "resolved": "https://registry.npmjs.org/lodash-unified/-/lodash-unified-1.0.2.tgz",
+      "integrity": "sha512-OGbEy+1P+UT26CYi4opY4gebD8cWRDxAT6MAObIVQMiqYdxZr1g3QHWCToVsm31x2NkLS4K3+MC2qInaRMa39g=="
+    },
+    "node_modules/magic-string": {
+      "version": "0.25.9",
+      "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.25.9.tgz",
+      "integrity": "sha512-RmF0AsMzgt25qzqqLc1+MbHmhdx0ojF2Fvs4XnOqz2ZOBXzzkEwc/dJQZCYHAn7v1jbVOjAZfK8msRn4BxO4VQ==",
+      "dependencies": {
+        "sourcemap-codec": "^1.4.8"
+      }
+    },
+    "node_modules/make-dir": {
+      "version": "2.1.0",
+      "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-2.1.0.tgz",
+      "integrity": "sha512-LS9X+dc8KLxXCb8dni79fLIIUA5VyZoyjSMCwTluaXA0o27cCK0bhXkpgw+sTXVpPy/lSO57ilRixqk0vDmtRA==",
+      "dev": true,
+      "optional": true,
+      "dependencies": {
+        "pify": "^4.0.1",
+        "semver": "^5.6.0"
+      },
+      "engines": {
+        "node": ">=6"
+      }
+    },
+    "node_modules/memoize-one": {
+      "version": "6.0.0",
+      "resolved": "https://registry.npmjs.org/memoize-one/-/memoize-one-6.0.0.tgz",
+      "integrity": "sha512-rkpe71W0N0c0Xz6QD0eJETuWAJGnJ9afsl1srmwPrI+yBCkge5EycXXbYRyvL29zZVUWQCY7InPRCv3GDXuZNw=="
+    },
+    "node_modules/merge2": {
+      "version": "1.4.1",
+      "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz",
+      "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==",
+      "dev": true,
+      "engines": {
+        "node": ">= 8"
+      }
+    },
+    "node_modules/micromatch": {
+      "version": "4.0.5",
+      "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.5.tgz",
+      "integrity": "sha512-DMy+ERcEW2q8Z2Po+WNXuw3c5YaUSFjAO5GsJqfEl7UjvtIuFKO6ZrKvcItdy98dwFI2N1tg3zNIdKaQT+aNdA==",
+      "dev": true,
+      "dependencies": {
+        "braces": "^3.0.2",
+        "picomatch": "^2.3.1"
+      },
+      "engines": {
+        "node": ">=8.6"
+      }
+    },
+    "node_modules/mime": {
+      "version": "1.6.0",
+      "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz",
+      "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==",
+      "dev": true,
+      "optional": true,
+      "bin": {
+        "mime": "cli.js"
+      },
+      "engines": {
+        "node": ">=4"
+      }
+    },
+    "node_modules/minimatch": {
+      "version": "5.0.1",
+      "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.0.1.tgz",
+      "integrity": "sha512-nLDxIFRyhDblz3qMuq+SoRZED4+miJ/G+tdDrjkkkRnjAsBexeGpgjLEQ0blJy7rHhR2b93rhQY4SvyWu9v03g==",
+      "dev": true,
+      "dependencies": {
+        "brace-expansion": "^2.0.1"
+      },
+      "engines": {
+        "node": ">=10"
+      }
+    },
+    "node_modules/ms": {
+      "version": "2.1.3",
+      "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
+      "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
+      "dev": true,
+      "optional": true
+    },
+    "node_modules/nanoid": {
+      "version": "3.3.2",
+      "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.2.tgz",
+      "integrity": "sha512-CuHBogktKwpm5g2sRgv83jEy2ijFzBwMoYA60orPDR7ynsLijJDqgsi4RDGj3OJpy3Ieb+LYwiRmIOGyytgITA==",
+      "bin": {
+        "nanoid": "bin/nanoid.cjs"
+      },
+      "engines": {
+        "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
+      }
+    },
+    "node_modules/needle": {
+      "version": "2.9.1",
+      "resolved": "https://registry.npmjs.org/needle/-/needle-2.9.1.tgz",
+      "integrity": "sha512-6R9fqJ5Zcmf+uYaFgdIHmLwNldn5HbK8L5ybn7Uz+ylX/rnOsSp1AHcvQSrCaFN+qNM1wpymHqD7mVasEOlHGQ==",
+      "dev": true,
+      "optional": true,
+      "dependencies": {
+        "debug": "^3.2.6",
+        "iconv-lite": "^0.4.4",
+        "sax": "^1.2.4"
+      },
+      "bin": {
+        "needle": "bin/needle"
+      },
+      "engines": {
+        "node": ">= 4.4.x"
+      }
+    },
+    "node_modules/normalize-path": {
+      "version": "3.0.0",
+      "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz",
+      "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==",
+      "dev": true,
+      "engines": {
+        "node": ">=0.10.0"
+      }
+    },
+    "node_modules/normalize-wheel-es": {
+      "version": "1.1.2",
+      "resolved": "https://registry.npmjs.org/normalize-wheel-es/-/normalize-wheel-es-1.1.2.tgz",
+      "integrity": "sha512-scX83plWJXYH1J4+BhAuIHadROzxX0UBF3+HuZNY2Ks8BciE7tSTQ+5JhTsvzjaO0/EJdm4JBGrfObKxFf3Png=="
+    },
+    "node_modules/parse-node-version": {
+      "version": "1.0.1",
+      "resolved": "https://registry.npmjs.org/parse-node-version/-/parse-node-version-1.0.1.tgz",
+      "integrity": "sha512-3YHlOa/JgH6Mnpr05jP9eDG254US9ek25LyIxZlDItp2iJtwyaXQb57lBYLdT3MowkUFYEV2XXNAYIPlESvJlA==",
+      "dev": true,
+      "engines": {
+        "node": ">= 0.10"
+      }
+    },
+    "node_modules/path-parse": {
+      "version": "1.0.7",
+      "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz",
+      "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==",
+      "dev": true
+    },
+    "node_modules/picocolors": {
+      "version": "1.0.0",
+      "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.0.0.tgz",
+      "integrity": "sha512-1fygroTLlHu66zi26VoTDv8yRgm0Fccecssto+MhsZ0D/DGW2sm8E8AjW7NU5VVTRt5GxbeZ5qBuJr+HyLYkjQ=="
+    },
+    "node_modules/picomatch": {
+      "version": "2.3.1",
+      "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz",
+      "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==",
+      "dev": true,
+      "engines": {
+        "node": ">=8.6"
+      }
+    },
+    "node_modules/pify": {
+      "version": "4.0.1",
+      "resolved": "https://registry.npmjs.org/pify/-/pify-4.0.1.tgz",
+      "integrity": "sha512-uB80kBFb/tfd68bVleG9T5GGsGPjJrLAUpR5PZIrhBnIaRTQRjqdJSsIKkOP6OAIFbj7GOrcudc5pNjZ+geV2g==",
+      "dev": true,
+      "optional": true,
+      "engines": {
+        "node": ">=6"
+      }
+    },
+    "node_modules/postcss": {
+      "version": "8.4.12",
+      "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.12.tgz",
+      "integrity": "sha512-lg6eITwYe9v6Hr5CncVbK70SoioNQIq81nsaG86ev5hAidQvmOeETBqs7jm43K2F5/Ley3ytDtriImV6TpNiSg==",
+      "dependencies": {
+        "nanoid": "^3.3.1",
+        "picocolors": "^1.0.0",
+        "source-map-js": "^1.0.2"
+      },
+      "engines": {
+        "node": "^10 || ^12 || >=14"
+      }
+    },
+    "node_modules/prettier": {
+      "version": "2.7.1",
+      "resolved": "https://registry.npmjs.org/prettier/-/prettier-2.7.1.tgz",
+      "integrity": "sha512-ujppO+MkdPqoVINuDFDRLClm7D78qbDt0/NR+wp5FqEZOoTNAjPHWj17QRhu7geIHJfcNhRk1XVQmF8Bp3ye+g==",
+      "dev": true,
+      "bin": {
+        "prettier": "bin-prettier.js"
+      },
+      "engines": {
+        "node": ">=10.13.0"
+      },
+      "funding": {
+        "url": "https://github.com/prettier/prettier?sponsor=1"
+      }
+    },
+    "node_modules/prr": {
+      "version": "1.0.1",
+      "resolved": "https://registry.npmjs.org/prr/-/prr-1.0.1.tgz",
+      "integrity": "sha1-0/wRS6BplaRexok/SEzrHXj19HY=",
+      "dev": true,
+      "optional": true
+    },
+    "node_modules/queue-microtask": {
+      "version": "1.2.3",
+      "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz",
+      "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==",
+      "dev": true
+    },
+    "node_modules/readdirp": {
+      "version": "3.6.0",
+      "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz",
+      "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==",
+      "dev": true,
+      "dependencies": {
+        "picomatch": "^2.2.1"
+      },
+      "engines": {
+        "node": ">=8.10.0"
+      }
+    },
+    "node_modules/resolve": {
+      "version": "1.22.0",
+      "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.0.tgz",
+      "integrity": "sha512-Hhtrw0nLeSrFQ7phPp4OOcVjLPIeMnRlr5mcnVuMe7M/7eBn98A3hmFRLoFo3DLZkivSYwhRUJTyPyWAk56WLw==",
+      "dev": true,
+      "dependencies": {
+        "is-core-module": "^2.8.1",
+        "path-parse": "^1.0.7",
+        "supports-preserve-symlinks-flag": "^1.0.0"
+      },
+      "bin": {
+        "resolve": "bin/resolve"
+      }
+    },
+    "node_modules/reusify": {
+      "version": "1.0.4",
+      "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.0.4.tgz",
+      "integrity": "sha512-U9nH88a3fc/ekCF1l0/UP1IosiuIjyTh7hBvXVMHYgVcfGvt897Xguj2UOLDeI5BG2m7/uwyaLVT6fbtCwTyzw==",
+      "dev": true,
+      "engines": {
+        "iojs": ">=1.0.0",
+        "node": ">=0.10.0"
+      }
+    },
+    "node_modules/rollup": {
+      "version": "2.70.1",
+      "resolved": "https://registry.npmjs.org/rollup/-/rollup-2.70.1.tgz",
+      "integrity": "sha512-CRYsI5EuzLbXdxC6RnYhOuRdtz4bhejPMSWjsFLfVM/7w/85n2szZv6yExqUXsBdz5KT8eoubeyDUDjhLHEslA==",
+      "dev": true,
+      "bin": {
+        "rollup": "dist/bin/rollup"
+      },
+      "engines": {
+        "node": ">=10.0.0"
+      },
+      "optionalDependencies": {
+        "fsevents": "~2.3.2"
+      }
+    },
+    "node_modules/run-parallel": {
+      "version": "1.2.0",
+      "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz",
+      "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==",
+      "dev": true,
+      "dependencies": {
+        "queue-microtask": "^1.2.2"
+      }
+    },
+    "node_modules/safer-buffer": {
+      "version": "2.1.2",
+      "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
+      "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
+      "dev": true,
+      "optional": true
+    },
+    "node_modules/sax": {
+      "version": "1.2.4",
+      "resolved": "https://registry.npmjs.org/sax/-/sax-1.2.4.tgz",
+      "integrity": "sha512-NqVDv9TpANUjFm0N8uM5GxL36UgKi9/atZw+x7YFnQ8ckwFGKrl4xX4yWtrey3UJm5nP1kUbnYgLopqWNSRhWw==",
+      "dev": true,
+      "optional": true
+    },
+    "node_modules/semver": {
+      "version": "5.7.1",
+      "resolved": "https://registry.npmjs.org/semver/-/semver-5.7.1.tgz",
+      "integrity": "sha512-sauaDf/PZdVgrLTNYHRtpXa1iRiKcaebiKQ1BJdpQlWH2lCvexQdX55snPFyK7QzpudqbCI0qXFfOasHdyNDGQ==",
+      "dev": true,
+      "optional": true,
+      "bin": {
+        "semver": "bin/semver"
+      }
+    },
+    "node_modules/source-map": {
+      "version": "0.6.1",
+      "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz",
+      "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==",
+      "engines": {
+        "node": ">=0.10.0"
+      }
+    },
+    "node_modules/source-map-js": {
+      "version": "1.0.2",
+      "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.0.2.tgz",
+      "integrity": "sha512-R0XvVJ9WusLiqTCEiGCmICCMplcCkIwwR11mOSD9CR5u+IXYdiseeEuXCVAjS54zqwkLcPNnmU4OeJ6tUrWhDw==",
+      "engines": {
+        "node": ">=0.10.0"
+      }
+    },
+    "node_modules/sourcemap-codec": {
+      "version": "1.4.8",
+      "resolved": "https://registry.npmjs.org/sourcemap-codec/-/sourcemap-codec-1.4.8.tgz",
+      "integrity": "sha512-9NykojV5Uih4lgo5So5dtw+f0JgJX30KCNI8gwhz2J9A15wD0Ml6tjHKwf6fTSa6fAdVBdZeNOs9eJ71qCk8vA=="
+    },
+    "node_modules/supports-preserve-symlinks-flag": {
+      "version": "1.0.0",
+      "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz",
+      "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==",
+      "dev": true,
+      "engines": {
+        "node": ">= 0.4"
+      }
+    },
+    "node_modules/to-regex-range": {
+      "version": "5.0.1",
+      "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz",
+      "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==",
+      "dev": true,
+      "dependencies": {
+        "is-number": "^7.0.0"
+      },
+      "engines": {
+        "node": ">=8.0"
+      }
+    },
+    "node_modules/tslib": {
+      "version": "2.3.1",
+      "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.3.1.tgz",
+      "integrity": "sha512-77EbyPPpMz+FRFRuAFlWMtmgUWGe9UOG2Z25NqCwiIjRhOf5iKGuzSe5P2w1laq+FkRy4p+PCuVkJSGkzTEKVw==",
+      "dev": true
+    },
+    "node_modules/unplugin": {
+      "version": "0.4.0",
+      "resolved": "https://registry.npmjs.org/unplugin/-/unplugin-0.4.0.tgz",
+      "integrity": "sha512-4ScITEmzlz1iZW3tkz+3L1V5k/xMQ6kjgm4lEXKxH0ozd8/OUWfiSA7RMRyrawsvq/t50JIzPpp1UyuSL/AXkA==",
+      "dev": true,
+      "dependencies": {
+        "chokidar": "^3.5.3",
+        "webpack-virtual-modules": "^0.4.3"
+      }
+    },
+    "node_modules/unplugin-auto-import": {
+      "version": "0.6.9",
+      "resolved": "https://registry.npmjs.org/unplugin-auto-import/-/unplugin-auto-import-0.6.9.tgz",
+      "integrity": "sha512-IqgT7AoRrNQwNhiF/wDH3sMEDX8SqCYBEgJzwdg5441b5aiC5VwZz0J0wYqkaKu89YkZE9DG6rQ2JpFfZv1iiQ==",
+      "dev": true,
+      "dependencies": {
+        "@antfu/utils": "^0.5.0",
+        "@rollup/pluginutils": "^4.2.0",
+        "local-pkg": "^0.4.1",
+        "magic-string": "^0.26.1",
+        "resolve": "^1.22.0",
+        "unplugin": "^0.4.0"
+      },
+      "engines": {
+        "node": ">=14"
+      }
+    },
+    "node_modules/unplugin-auto-import/node_modules/magic-string": {
+      "version": "0.26.1",
+      "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.26.1.tgz",
+      "integrity": "sha512-ndThHmvgtieXe8J/VGPjG+Apu7v7ItcD5mhEIvOscWjPF/ccOiLxHaSuCAS2G+3x4GKsAbT8u7zdyamupui8Tg==",
+      "dev": true,
+      "dependencies": {
+        "sourcemap-codec": "^1.4.8"
+      },
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/unplugin-vue-components": {
+      "version": "0.18.5",
+      "resolved": "https://registry.npmjs.org/unplugin-vue-components/-/unplugin-vue-components-0.18.5.tgz",
+      "integrity": "sha512-VPA6z/4pcKRDYtWu1H+FIpV0MADlFKG3q7YMVFzNFC3EhMVZ4WuBJ76490oKyauguNw1T1obLCuxNU9JzJ0oAQ==",
+      "dev": true,
+      "dependencies": {
+        "@antfu/utils": "^0.5.0",
+        "@rollup/pluginutils": "^4.2.0",
+        "chokidar": "^3.5.3",
+        "debug": "^4.3.3",
+        "fast-glob": "^3.2.11",
+        "local-pkg": "^0.4.1",
+        "magic-string": "^0.26.1",
+        "minimatch": "^5.0.1",
+        "resolve": "^1.22.0",
+        "unplugin": "^0.4.0"
+      },
+      "engines": {
+        "node": ">=14"
+      }
+    },
+    "node_modules/unplugin-vue-components/node_modules/debug": {
+      "version": "4.3.4",
+      "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz",
+      "integrity": "sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==",
+      "dev": true,
+      "dependencies": {
+        "ms": "2.1.2"
+      },
+      "engines": {
+        "node": ">=6.0"
+      }
+    },
+    "node_modules/unplugin-vue-components/node_modules/magic-string": {
+      "version": "0.26.1",
+      "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.26.1.tgz",
+      "integrity": "sha512-ndThHmvgtieXe8J/VGPjG+Apu7v7ItcD5mhEIvOscWjPF/ccOiLxHaSuCAS2G+3x4GKsAbT8u7zdyamupui8Tg==",
+      "dev": true,
+      "dependencies": {
+        "sourcemap-codec": "^1.4.8"
+      },
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/unplugin-vue-components/node_modules/ms": {
+      "version": "2.1.2",
+      "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz",
+      "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==",
+      "dev": true
+    },
+    "node_modules/vite": {
+      "version": "2.9.1",
+      "resolved": "https://registry.npmjs.org/vite/-/vite-2.9.1.tgz",
+      "integrity": "sha512-vSlsSdOYGcYEJfkQ/NeLXgnRv5zZfpAsdztkIrs7AZHV8RCMZQkwjo4DS5BnrYTqoWqLoUe1Cah4aVO4oNNqCQ==",
+      "dev": true,
+      "dependencies": {
+        "esbuild": "^0.14.27",
+        "postcss": "^8.4.12",
+        "resolve": "^1.22.0",
+        "rollup": "^2.59.0"
+      },
+      "bin": {
+        "vite": "bin/vite.js"
+      },
+      "engines": {
+        "node": ">=12.2.0"
+      },
+      "optionalDependencies": {
+        "fsevents": "~2.3.2"
+      }
+    },
+    "node_modules/vue": {
+      "version": "3.2.31",
+      "resolved": "https://registry.npmjs.org/vue/-/vue-3.2.31.tgz",
+      "integrity": "sha512-odT3W2tcffTiQCy57nOT93INw1auq5lYLLYtWpPYQQYQOOdHiqFct9Xhna6GJ+pJQaF67yZABraH47oywkJgFw==",
+      "dependencies": {
+        "@vue/compiler-dom": "3.2.31",
+        "@vue/compiler-sfc": "3.2.31",
+        "@vue/runtime-dom": "3.2.31",
+        "@vue/server-renderer": "3.2.31",
+        "@vue/shared": "3.2.31"
+      }
+    },
+    "node_modules/vue-demi": {
+      "version": "0.12.5",
+      "resolved": "https://registry.npmjs.org/vue-demi/-/vue-demi-0.12.5.tgz",
+      "integrity": "sha512-BREuTgTYlUr0zw0EZn3hnhC3I6gPWv+Kwh4MCih6QcAeaTlaIX0DwOVN0wHej7hSvDPecz4jygy/idsgKfW58Q==",
+      "hasInstallScript": true,
+      "bin": {
+        "vue-demi-fix": "bin/vue-demi-fix.js",
+        "vue-demi-switch": "bin/vue-demi-switch.js"
+      },
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/webpack-virtual-modules": {
+      "version": "0.4.3",
+      "resolved": "https://registry.npmjs.org/webpack-virtual-modules/-/webpack-virtual-modules-0.4.3.tgz",
+      "integrity": "sha512-5NUqC2JquIL2pBAAo/VfBP6KuGkHIZQXW/lNKupLPfhViwh8wNsu0BObtl09yuKZszeEUfbXz8xhrHvSG16Nqw==",
+      "dev": true
+    }
+  },
+  "dependencies": {
+    "@antfu/utils": {
+      "version": "0.5.0",
+      "resolved": "https://registry.npmjs.org/@antfu/utils/-/utils-0.5.0.tgz",
+      "integrity": "sha512-MrAQ/MrPSxbh1bBrmwJjORfJymw4IqSHFBXqvxaga3ZdDM+/zokYF8DjyJpSjY2QmpmgQrajDUBJOWrYeARfzA==",
+      "dev": true
+    },
+    "@babel/parser": {
+      "version": "7.17.8",
+      "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.17.8.tgz",
+      "integrity": "sha512-BoHhDJrJXqcg+ZL16Xv39H9n+AqJ4pcDrQBGZN+wHxIysrLZ3/ECwCBUch/1zUNhnsXULcONU3Ei5Hmkfk6kiQ=="
+    },
+    "@ctrl/tinycolor": {
+      "version": "3.4.0",
+      "resolved": "https://registry.npmjs.org/@ctrl/tinycolor/-/tinycolor-3.4.0.tgz",
+      "integrity": "sha512-JZButFdZ1+/xAfpguQHoabIXkcqRRKpMrWKBkpEZZyxfY9C1DpADFB8PEqGSTeFr135SaTRfKqGKx5xSCLI7ZQ=="
+    },
+    "@element-plus/icons-vue": {
+      "version": "1.1.4",
+      "resolved": "https://registry.npmjs.org/@element-plus/icons-vue/-/icons-vue-1.1.4.tgz",
+      "integrity": "sha512-Iz/nHqdp1sFPmdzRwHkEQQA3lKvoObk8azgABZ81QUOpW9s/lUyQVUSh0tNtEPZXQlKwlSh7SPgoVxzrE0uuVQ=="
+    },
+    "@floating-ui/core": {
+      "version": "0.6.1",
+      "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-0.6.1.tgz",
+      "integrity": "sha512-Y30eVMcZva8o84c0HcXAtDO4BEzPJMvF6+B7x7urL2xbAqVsGJhojOyHLaoQHQYjb6OkqRq5kO+zeySycQwKqg=="
+    },
+    "@floating-ui/dom": {
+      "version": "0.4.2",
+      "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-0.4.2.tgz",
+      "integrity": "sha512-2/4vOhMAujkMmGqGX1Gut84phe5MNfk1kGeM+RSTJCpeR0SWR2/RR+/f1r1msOvTQa28wn7HEhxGe71CjYY/vw==",
+      "requires": {
+        "@floating-ui/core": "^0.6.1"
+      }
+    },
+    "@nodelib/fs.scandir": {
+      "version": "2.1.5",
+      "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz",
+      "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==",
+      "dev": true,
+      "requires": {
+        "@nodelib/fs.stat": "2.0.5",
+        "run-parallel": "^1.1.9"
+      }
+    },
+    "@nodelib/fs.stat": {
+      "version": "2.0.5",
+      "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz",
+      "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==",
+      "dev": true
+    },
+    "@nodelib/fs.walk": {
+      "version": "1.2.8",
+      "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz",
+      "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==",
+      "dev": true,
+      "requires": {
+        "@nodelib/fs.scandir": "2.1.5",
+        "fastq": "^1.6.0"
+      }
+    },
+    "@popperjs/core": {
+      "version": "2.11.4",
+      "resolved": "https://registry.npmjs.org/@popperjs/core/-/core-2.11.4.tgz",
+      "integrity": "sha512-q/ytXxO5NKvyT37pmisQAItCFqA7FD/vNb8dgaJy3/630Fsc+Mz9/9f2SziBoIZ30TJooXyTwZmhi1zjXmObYg=="
+    },
+    "@rollup/pluginutils": {
+      "version": "4.2.0",
+      "resolved": "https://registry.npmjs.org/@rollup/pluginutils/-/pluginutils-4.2.0.tgz",
+      "integrity": "sha512-2WUyJNRkyH5p487pGnn4tWAsxhEFKN/pT8CMgHshd5H+IXkOnKvKZwsz5ZWz+YCXkleZRAU5kwbfgF8CPfDRqA==",
+      "dev": true,
+      "requires": {
+        "estree-walker": "^2.0.1",
+        "picomatch": "^2.2.2"
+      }
+    },
+    "@vitejs/plugin-vue": {
+      "version": "2.3.1",
+      "resolved": "https://registry.npmjs.org/@vitejs/plugin-vue/-/plugin-vue-2.3.1.tgz",
+      "integrity": "sha512-YNzBt8+jt6bSwpt7LP890U1UcTOIZZxfpE5WOJ638PNxSEKOqAi0+FSKS0nVeukfdZ0Ai/H7AFd6k3hayfGZqQ==",
+      "dev": true
+    },
+    "@vue/compiler-core": {
+      "version": "3.2.31",
+      "resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.2.31.tgz",
+      "integrity": "sha512-aKno00qoA4o+V/kR6i/pE+aP+esng5siNAVQ422TkBNM6qA4veXiZbSe8OTXHXquEi/f6Akc+nLfB4JGfe4/WQ==",
+      "requires": {
+        "@babel/parser": "^7.16.4",
+        "@vue/shared": "3.2.31",
+        "estree-walker": "^2.0.2",
+        "source-map": "^0.6.1"
+      }
+    },
+    "@vue/compiler-dom": {
+      "version": "3.2.31",
+      "resolved": "https://registry.npmjs.org/@vue/compiler-dom/-/compiler-dom-3.2.31.tgz",
+      "integrity": "sha512-60zIlFfzIDf3u91cqfqy9KhCKIJgPeqxgveH2L+87RcGU/alT6BRrk5JtUso0OibH3O7NXuNOQ0cDc9beT0wrg==",
+      "requires": {
+        "@vue/compiler-core": "3.2.31",
+        "@vue/shared": "3.2.31"
+      }
+    },
+    "@vue/compiler-sfc": {
+      "version": "3.2.31",
+      "resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-3.2.31.tgz",
+      "integrity": "sha512-748adc9msSPGzXgibHiO6T7RWgfnDcVQD+VVwYgSsyyY8Ans64tALHZANrKtOzvkwznV/F4H7OAod/jIlp/dkQ==",
+      "requires": {
+        "@babel/parser": "^7.16.4",
+        "@vue/compiler-core": "3.2.31",
+        "@vue/compiler-dom": "3.2.31",
+        "@vue/compiler-ssr": "3.2.31",
+        "@vue/reactivity-transform": "3.2.31",
+        "@vue/shared": "3.2.31",
+        "estree-walker": "^2.0.2",
+        "magic-string": "^0.25.7",
+        "postcss": "^8.1.10",
+        "source-map": "^0.6.1"
+      }
+    },
+    "@vue/compiler-ssr": {
+      "version": "3.2.31",
+      "resolved": "https://registry.npmjs.org/@vue/compiler-ssr/-/compiler-ssr-3.2.31.tgz",
+      "integrity": "sha512-mjN0rqig+A8TVDnsGPYJM5dpbjlXeHUm2oZHZwGyMYiGT/F4fhJf/cXy8QpjnLQK4Y9Et4GWzHn9PS8AHUnSkw==",
+      "requires": {
+        "@vue/compiler-dom": "3.2.31",
+        "@vue/shared": "3.2.31"
+      }
+    },
+    "@vue/reactivity": {
+      "version": "3.2.31",
+      "resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.2.31.tgz",
+      "integrity": "sha512-HVr0l211gbhpEKYr2hYe7hRsV91uIVGFYNHj73njbARVGHQvIojkImKMaZNDdoDZOIkMsBc9a1sMqR+WZwfSCw==",
+      "requires": {
+        "@vue/shared": "3.2.31"
+      }
+    },
+    "@vue/reactivity-transform": {
+      "version": "3.2.31",
+      "resolved": "https://registry.npmjs.org/@vue/reactivity-transform/-/reactivity-transform-3.2.31.tgz",
+      "integrity": "sha512-uS4l4z/W7wXdI+Va5pgVxBJ345wyGFKvpPYtdSgvfJfX/x2Ymm6ophQlXXB6acqGHtXuBqNyyO3zVp9b1r0MOA==",
+      "requires": {
+        "@babel/parser": "^7.16.4",
+        "@vue/compiler-core": "3.2.31",
+        "@vue/shared": "3.2.31",
+        "estree-walker": "^2.0.2",
+        "magic-string": "^0.25.7"
+      }
+    },
+    "@vue/runtime-core": {
+      "version": "3.2.31",
+      "resolved": "https://registry.npmjs.org/@vue/runtime-core/-/runtime-core-3.2.31.tgz",
+      "integrity": "sha512-Kcog5XmSY7VHFEMuk4+Gap8gUssYMZ2+w+cmGI6OpZWYOEIcbE0TPzzPHi+8XTzAgx1w/ZxDFcXhZeXN5eKWsA==",
+      "requires": {
+        "@vue/reactivity": "3.2.31",
+        "@vue/shared": "3.2.31"
+      }
+    },
+    "@vue/runtime-dom": {
+      "version": "3.2.31",
+      "resolved": "https://registry.npmjs.org/@vue/runtime-dom/-/runtime-dom-3.2.31.tgz",
+      "integrity": "sha512-N+o0sICVLScUjfLG7u9u5XCjvmsexAiPt17GNnaWHJUfsKed5e85/A3SWgKxzlxx2SW/Hw7RQxzxbXez9PtY3g==",
+      "requires": {
+        "@vue/runtime-core": "3.2.31",
+        "@vue/shared": "3.2.31",
+        "csstype": "^2.6.8"
+      }
+    },
+    "@vue/server-renderer": {
+      "version": "3.2.31",
+      "resolved": "https://registry.npmjs.org/@vue/server-renderer/-/server-renderer-3.2.31.tgz",
+      "integrity": "sha512-8CN3Zj2HyR2LQQBHZ61HexF5NReqngLT3oahyiVRfSSvak+oAvVmu8iNLSu6XR77Ili2AOpnAt1y8ywjjqtmkg==",
+      "requires": {
+        "@vue/compiler-ssr": "3.2.31",
+        "@vue/shared": "3.2.31"
+      }
+    },
+    "@vue/shared": {
+      "version": "3.2.31",
+      "resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.2.31.tgz",
+      "integrity": "sha512-ymN2pj6zEjiKJZbrf98UM2pfDd6F2H7ksKw7NDt/ZZ1fh5Ei39X5tABugtT03ZRlWd9imccoK0hE8hpjpU7irQ=="
+    },
+    "@vueuse/core": {
+      "version": "8.2.3",
+      "resolved": "https://registry.npmjs.org/@vueuse/core/-/core-8.2.3.tgz",
+      "integrity": "sha512-bz6XroFRFOIGzhAHcnTfjtAQNkNcSELKPoSSUGROwYfOgTnaVyq7iKhgwdeRNom3y9q+a60RlhD35oJaGsXDHA==",
+      "requires": {
+        "@vueuse/metadata": "8.2.3",
+        "@vueuse/shared": "8.2.3",
+        "vue-demi": "*"
+      }
+    },
+    "@vueuse/metadata": {
+      "version": "8.2.3",
+      "resolved": "https://registry.npmjs.org/@vueuse/metadata/-/metadata-8.2.3.tgz",
+      "integrity": "sha512-xH5256Bn6hBYvQWoaCBagpeCrzJmeEeWnyDDkwVFhx7+pLOe4I6gsMHie3yJoowK9AN/D7JLTtEBbWvvBi5TOA=="
+    },
+    "@vueuse/shared": {
+      "version": "8.2.3",
+      "resolved": "https://registry.npmjs.org/@vueuse/shared/-/shared-8.2.3.tgz",
+      "integrity": "sha512-4Cd3s+x8ZwzzAf7o8jS7mEj9pQ1Zsf9aiNBAFg4rHcWeDY0S3XMgvN4ae2hBul7jmi+Ab4REAqiqYWyYqU86qg==",
+      "requires": {
+        "vue-demi": "*"
+      }
+    },
+    "anymatch": {
+      "version": "3.1.2",
+      "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.2.tgz",
+      "integrity": "sha512-P43ePfOAIupkguHUycrc4qJ9kz8ZiuOUijaETwX7THt0Y/GNK7v0aa8rY816xWjZ7rJdA5XdMcpVFTKMq+RvWg==",
+      "dev": true,
+      "requires": {
+        "normalize-path": "^3.0.0",
+        "picomatch": "^2.0.4"
+      }
+    },
+    "async-validator": {
+      "version": "4.0.7",
+      "resolved": "https://registry.npmjs.org/async-validator/-/async-validator-4.0.7.tgz",
+      "integrity": "sha512-Pj2IR7u8hmUEDOwB++su6baaRi+QvsgajuFB9j95foM1N2gy5HM4z60hfusIO0fBPG5uLAEl6yCJr1jNSVugEQ=="
+    },
+    "balanced-match": {
+      "version": "1.0.2",
+      "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
+      "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==",
+      "dev": true
+    },
+    "binary-extensions": {
+      "version": "2.2.0",
+      "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.2.0.tgz",
+      "integrity": "sha512-jDctJ/IVQbZoJykoeHbhXpOlNBqGNcwXJKJog42E5HDPUwQTSdjCHdihjj0DlnheQ7blbT6dHOafNAiS8ooQKA==",
+      "dev": true
+    },
+    "brace-expansion": {
+      "version": "2.0.1",
+      "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz",
+      "integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==",
+      "dev": true,
+      "requires": {
+        "balanced-match": "^1.0.0"
+      }
+    },
+    "braces": {
+      "version": "3.0.2",
+      "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.2.tgz",
+      "integrity": "sha512-b8um+L1RzM3WDSzvhm6gIz1yfTbBt6YTlcEKAvsmqCZZFw46z626lVj9j1yEPW33H5H+lBQpZMP1k8l+78Ha0A==",
+      "dev": true,
+      "requires": {
+        "fill-range": "^7.0.1"
+      }
+    },
+    "chokidar": {
+      "version": "3.5.3",
+      "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.5.3.tgz",
+      "integrity": "sha512-Dr3sfKRP6oTcjf2JmUmFJfeVMvXBdegxB0iVQ5eb2V10uFJUCAS8OByZdVAyVb8xXNz3GjjTgj9kLWsZTqE6kw==",
+      "dev": true,
+      "requires": {
+        "anymatch": "~3.1.2",
+        "braces": "~3.0.2",
+        "fsevents": "~2.3.2",
+        "glob-parent": "~5.1.2",
+        "is-binary-path": "~2.1.0",
+        "is-glob": "~4.0.1",
+        "normalize-path": "~3.0.0",
+        "readdirp": "~3.6.0"
+      }
+    },
+    "copy-anything": {
+      "version": "2.0.6",
+      "resolved": "https://registry.npmjs.org/copy-anything/-/copy-anything-2.0.6.tgz",
+      "integrity": "sha512-1j20GZTsvKNkc4BY3NpMOM8tt///wY3FpIzozTOFO2ffuZcV61nojHXVKIy3WM+7ADCy5FVhdZYHYDdgTU0yJw==",
+      "dev": true,
+      "requires": {
+        "is-what": "^3.14.1"
+      }
+    },
+    "csstype": {
+      "version": "2.6.20",
+      "resolved": "https://registry.npmjs.org/csstype/-/csstype-2.6.20.tgz",
+      "integrity": "sha512-/WwNkdXfckNgw6S5R125rrW8ez139lBHWouiBvX8dfMFtcn6V81REDqnH7+CRpRipfYlyU1CmOnOxrmGcFOjeA=="
+    },
+    "dayjs": {
+      "version": "1.11.0",
+      "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.0.tgz",
+      "integrity": "sha512-JLC809s6Y948/FuCZPm5IX8rRhQwOiyMb2TfVVQEixG7P8Lm/gt5S7yoQZmC8x1UehI9Pb7sksEt4xx14m+7Ug=="
+    },
+    "debug": {
+      "version": "3.2.7",
+      "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz",
+      "integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==",
+      "dev": true,
+      "optional": true,
+      "requires": {
+        "ms": "^2.1.1"
+      }
+    },
+    "element-plus": {
+      "version": "2.1.7",
+      "resolved": "https://registry.npmjs.org/element-plus/-/element-plus-2.1.7.tgz",
+      "integrity": "sha512-jamE9F/a2rfAQJwf3kLaDfWXxhjXuAJAvrto76SLJsJfr3iIUAzC849RHdn6h7tNJy9Yanq8GlQAsdBe5lJmrA==",
+      "requires": {
+        "@ctrl/tinycolor": "^3.4.0",
+        "@element-plus/icons-vue": "^1.1.4",
+        "@floating-ui/dom": "^0.4.1",
+        "@popperjs/core": "^2.11.4",
+        "@vueuse/core": "^8.1.2",
+        "async-validator": "^4.0.7",
+        "dayjs": "^1.11.0",
+        "escape-html": "^1.0.3",
+        "lodash": "^4.17.21",
+        "lodash-es": "^4.17.21",
+        "lodash-unified": "^1.0.2",
+        "memoize-one": "^6.0.0",
+        "normalize-wheel-es": "^1.1.1"
+      }
+    },
+    "errno": {
+      "version": "0.1.8",
+      "resolved": "https://registry.npmjs.org/errno/-/errno-0.1.8.tgz",
+      "integrity": "sha512-dJ6oBr5SQ1VSd9qkk7ByRgb/1SH4JZjCHSW/mr63/QcXO9zLVxvJ6Oy13nio03rxpSnVDDjFor75SjVeZWPW/A==",
+      "dev": true,
+      "optional": true,
+      "requires": {
+        "prr": "~1.0.1"
+      }
+    },
+    "esbuild": {
+      "version": "0.14.30",
+      "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.14.30.tgz",
+      "integrity": "sha512-wCecQSBkIjp2xjuXY+wcXS/PpOQo9rFh4NAKPh4Pm9f3fuLcnxkR0rDzA+mYP88FtXIUcXUyYmaIgfrzRl55jA==",
+      "dev": true,
+      "requires": {
+        "esbuild-android-64": "0.14.30",
+        "esbuild-android-arm64": "0.14.30",
+        "esbuild-darwin-64": "0.14.30",
+        "esbuild-darwin-arm64": "0.14.30",
+        "esbuild-freebsd-64": "0.14.30",
+        "esbuild-freebsd-arm64": "0.14.30",
+        "esbuild-linux-32": "0.14.30",
+        "esbuild-linux-64": "0.14.30",
+        "esbuild-linux-arm": "0.14.30",
+        "esbuild-linux-arm64": "0.14.30",
+        "esbuild-linux-mips64le": "0.14.30",
+        "esbuild-linux-ppc64le": "0.14.30",
+        "esbuild-linux-riscv64": "0.14.30",
+        "esbuild-linux-s390x": "0.14.30",
+        "esbuild-netbsd-64": "0.14.30",
+        "esbuild-openbsd-64": "0.14.30",
+        "esbuild-sunos-64": "0.14.30",
+        "esbuild-windows-32": "0.14.30",
+        "esbuild-windows-64": "0.14.30",
+        "esbuild-windows-arm64": "0.14.30"
+      }
+    },
+    "esbuild-android-64": {
+      "version": "0.14.30",
+      "resolved": "https://registry.npmjs.org/esbuild-android-64/-/esbuild-android-64-0.14.30.tgz",
+      "integrity": "sha512-vdJ7t8A8msPfKpYUGUV/KaTQRiZ0vDa2XSTlzXVkGGVHLKPeb85PBUtYJcEgw3htW3IdX5i1t1IMdQCwJJgNAg==",
+      "dev": true,
+      "optional": true
+    },
+    "esbuild-android-arm64": {
+      "version": "0.14.30",
+      "resolved": "https://registry.npmjs.org/esbuild-android-arm64/-/esbuild-android-arm64-0.14.30.tgz",
+      "integrity": "sha512-BdgGfxeA5hBQNErLr7BWJUA8xjflEfyaARICy8e0OJYNSAwDbEzOf8LyiKWSrDcgV129mWhi3VpbNQvOIDEHcg==",
+      "dev": true,
+      "optional": true
+    },
+    "esbuild-darwin-64": {
+      "version": "0.14.30",
+      "resolved": "https://registry.npmjs.org/esbuild-darwin-64/-/esbuild-darwin-64-0.14.30.tgz",
+      "integrity": "sha512-VRaOXMMrsG5n53pl4qFZQdXy2+E0NoLP/QH3aDUI0+bQP+ZHDmbINKcDy2IX7GVFI9kqPS18iJNAs5a6/G2LZg==",
+      "dev": true,
+      "optional": true
+    },
+    "esbuild-darwin-arm64": {
+      "version": "0.14.30",
+      "resolved": "https://registry.npmjs.org/esbuild-darwin-arm64/-/esbuild-darwin-arm64-0.14.30.tgz",
+      "integrity": "sha512-qDez+fHMOrO9Oc9qjt/x+sy09RJVh62kik5tVybKRLmezeV4qczM9/sAYY57YN0aWLdHbcCj2YqJUWYJNsgKnw==",
+      "dev": true,
+      "optional": true
+    },
+    "esbuild-freebsd-64": {
+      "version": "0.14.30",
+      "resolved": "https://registry.npmjs.org/esbuild-freebsd-64/-/esbuild-freebsd-64-0.14.30.tgz",
+      "integrity": "sha512-mec1jENcImVVagddZlGWsdAUwBnzR5cgnhzCxv+9fSMxKbx1uZYLLUAnLPp8m/i934zrumR1xGjJ5VoWdPlI2w==",
+      "dev": true,
+      "optional": true
+    },
+    "esbuild-freebsd-arm64": {
+      "version": "0.14.30",
+      "resolved": "https://registry.npmjs.org/esbuild-freebsd-arm64/-/esbuild-freebsd-arm64-0.14.30.tgz",
+      "integrity": "sha512-cpjbTs6Iok/AfeB0JgTzyUJTMStC1SQULmany5nHx6S4GTkSgaAHuJzZO0GcVWqghI4e0YL/bjXAhN5Mn6feNw==",
+      "dev": true,
+      "optional": true
+    },
+    "esbuild-linux-32": {
+      "version": "0.14.30",
+      "resolved": "https://registry.npmjs.org/esbuild-linux-32/-/esbuild-linux-32-0.14.30.tgz",
+      "integrity": "sha512-liIONVT4F2kZmOMwtwASqZ8WkIjb5HHBR9HUffdHiuotSTF3CyZO+EJf+Og+SYYuuVIvt0qHNSFjBA/iSESteQ==",
+      "dev": true,
+      "optional": true
+    },
+    "esbuild-linux-64": {
+      "version": "0.14.30",
+      "resolved": "https://registry.npmjs.org/esbuild-linux-64/-/esbuild-linux-64-0.14.30.tgz",
+      "integrity": "sha512-LUnpzoMpRqFON5En4qEj6NWiyH6a1K+Y2qYNKrCy5qPTjDoG/EWeqMz69n8Uv7pRuvDKl3FNGJ1dufTrA5i0sw==",
+      "dev": true,
+      "optional": true
+    },
+    "esbuild-linux-arm": {
+      "version": "0.14.30",
+      "resolved": "https://registry.npmjs.org/esbuild-linux-arm/-/esbuild-linux-arm-0.14.30.tgz",
+      "integrity": "sha512-97T+bbXnpqf7mfIG49UR7ZSJFGgvc22byn74qw3Kx2GDCBSQoVFjyWuKOHGXp8nXk3XYrdFF+mQ8yQ7aNsgQvg==",
+      "dev": true,
+      "optional": true
+    },
+    "esbuild-linux-arm64": {
+      "version": "0.14.30",
+      "resolved": "https://registry.npmjs.org/esbuild-linux-arm64/-/esbuild-linux-arm64-0.14.30.tgz",
+      "integrity": "sha512-DHZHn6FK5q/KL0fpNT/0jE38Nnyk2rXxKE9WENi95EXtqfOLPgE8tzjTZQNgpr61R95QX4ymQU26ni3IZk8buQ==",
+      "dev": true,
+      "optional": true
+    },
+    "esbuild-linux-mips64le": {
+      "version": "0.14.30",
+      "resolved": "https://registry.npmjs.org/esbuild-linux-mips64le/-/esbuild-linux-mips64le-0.14.30.tgz",
+      "integrity": "sha512-fLUzTFZ7uknC0aPTk7/lM7NmaG/9ZqE3SaHEphcaM009SZK/mDOvZugWi1ss6WGNhk13dUrhkfHcc4FSb9hYhg==",
+      "dev": true,
+      "optional": true
+    },
+    "esbuild-linux-ppc64le": {
+      "version": "0.14.30",
+      "resolved": "https://registry.npmjs.org/esbuild-linux-ppc64le/-/esbuild-linux-ppc64le-0.14.30.tgz",
+      "integrity": "sha512-2Oudm2WEfj0dNU9bzIl5L/LrsMEmHWsOsYgJJqu8fDyUDgER+J1d33qz3cUdjsJk7gAENayIxDSpsuCszx0w3A==",
+      "dev": true,
+      "optional": true
+    },
+    "esbuild-linux-riscv64": {
+      "version": "0.14.30",
+      "resolved": "https://registry.npmjs.org/esbuild-linux-riscv64/-/esbuild-linux-riscv64-0.14.30.tgz",
+      "integrity": "sha512-RPMucPW47rV4t2jlelaE948iCRtbZf5RhifxSwzlpM1Mqdyu99MMNK0w4jFreGTmLN+oGomxIOxD6n+2E/XqHw==",
+      "dev": true,
+      "optional": true
+    },
+    "esbuild-linux-s390x": {
+      "version": "0.14.30",
+      "resolved": "https://registry.npmjs.org/esbuild-linux-s390x/-/esbuild-linux-s390x-0.14.30.tgz",
+      "integrity": "sha512-OZ68r7ok6qO7hdwrwQn2p5jbIRRcUcVaAykB7e0uCA0ODwfeGunILM6phJtq2Oz4dlEEFvd+tSuma3paQKwt+A==",
+      "dev": true,
+      "optional": true
+    },
+    "esbuild-netbsd-64": {
+      "version": "0.14.30",
+      "resolved": "https://registry.npmjs.org/esbuild-netbsd-64/-/esbuild-netbsd-64-0.14.30.tgz",
+      "integrity": "sha512-iyejQUKn0TzpPkufq8pSCxOg9NheycQbMbPCmjefTe9wYuUlBt1TcHvdoJnYbQzsAhAh1BNq+s0ycRsIJFZzaQ==",
+      "dev": true,
+      "optional": true
+    },
+    "esbuild-openbsd-64": {
+      "version": "0.14.30",
+      "resolved": "https://registry.npmjs.org/esbuild-openbsd-64/-/esbuild-openbsd-64-0.14.30.tgz",
+      "integrity": "sha512-UyK1MTMcy4j5fH260fsE1o6MVgWNhb62eCK2yCKCRazZv8Nqdc2WiP9ygjWidmEdCDS+A6MuVp9ozk9uoQtQpA==",
+      "dev": true,
+      "optional": true
+    },
+    "esbuild-sunos-64": {
+      "version": "0.14.30",
+      "resolved": "https://registry.npmjs.org/esbuild-sunos-64/-/esbuild-sunos-64-0.14.30.tgz",
+      "integrity": "sha512-aQRtRTNKHB4YuG+xXATe5AoRTNY48IJg5vjE8ElxfmjO9+KdX7MHFkTLhlKevCD6rNANtB3qOlSIeAiXTwHNqw==",
+      "dev": true,
+      "optional": true
+    },
+    "esbuild-windows-32": {
+      "version": "0.14.30",
+      "resolved": "https://registry.npmjs.org/esbuild-windows-32/-/esbuild-windows-32-0.14.30.tgz",
+      "integrity": "sha512-9/fb1tPtpacMqxAXp3fGHowUDg/l9dVch5hKmCLEZC6PdGljh6h372zMdJwYfH0Bd5CCPT0Wx95uycBLJiqpXA==",
+      "dev": true,
+      "optional": true
+    },
+    "esbuild-windows-64": {
+      "version": "0.14.30",
+      "resolved": "https://registry.npmjs.org/esbuild-windows-64/-/esbuild-windows-64-0.14.30.tgz",
+      "integrity": "sha512-DHgITeUhPAnN9I5O6QBa1GVyPOhiYCn4S4TtQr7sO4+X0LNyqnlmA1M0qmGkUdDC1QQfjI8uQ4G/whdWb2pWIQ==",
+      "dev": true,
+      "optional": true
+    },
+    "esbuild-windows-arm64": {
+      "version": "0.14.30",
+      "resolved": "https://registry.npmjs.org/esbuild-windows-arm64/-/esbuild-windows-arm64-0.14.30.tgz",
+      "integrity": "sha512-F1kLyQH7zSgjh5eLxogGZN7C9+KNs9m+s7Q6WZoMmCWT/6j998zlaoECHyM8izJRRfsvw2eZlEa1jO6/IOU1AQ==",
+      "dev": true,
+      "optional": true
+    },
+    "escape-html": {
+      "version": "1.0.3",
+      "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz",
+      "integrity": "sha1-Aljq5NPQwJdN4cFpGI7wBR0dGYg="
+    },
+    "estree-walker": {
+      "version": "2.0.2",
+      "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz",
+      "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w=="
+    },
+    "fast-glob": {
+      "version": "3.2.11",
+      "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.2.11.tgz",
+      "integrity": "sha512-xrO3+1bxSo3ZVHAnqzyuewYT6aMFHRAd4Kcs92MAonjwQZLsK9d0SF1IyQ3k5PoirxTW0Oe/RqFgMQ6TcNE5Ew==",
+      "dev": true,
+      "requires": {
+        "@nodelib/fs.stat": "^2.0.2",
+        "@nodelib/fs.walk": "^1.2.3",
+        "glob-parent": "^5.1.2",
+        "merge2": "^1.3.0",
+        "micromatch": "^4.0.4"
+      }
+    },
+    "fastq": {
+      "version": "1.13.0",
+      "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.13.0.tgz",
+      "integrity": "sha512-YpkpUnK8od0o1hmeSc7UUs/eB/vIPWJYjKck2QKIzAf71Vm1AAQ3EbuZB3g2JIy+pg+ERD0vqI79KyZiB2e2Nw==",
+      "dev": true,
+      "requires": {
+        "reusify": "^1.0.4"
+      }
+    },
+    "fill-range": {
+      "version": "7.0.1",
+      "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.0.1.tgz",
+      "integrity": "sha512-qOo9F+dMUmC2Lcb4BbVvnKJxTPjCm+RRpe4gDuGrzkL7mEVl/djYSu2OdQ2Pa302N4oqkSg9ir6jaLWJ2USVpQ==",
+      "dev": true,
+      "requires": {
+        "to-regex-range": "^5.0.1"
+      }
+    },
+    "fsevents": {
+      "version": "2.3.2",
+      "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
+      "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
+      "dev": true,
+      "optional": true
+    },
+    "function-bind": {
+      "version": "1.1.1",
+      "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.1.tgz",
+      "integrity": "sha512-yIovAzMX49sF8Yl58fSCWJ5svSLuaibPxXQJFLmBObTuCr0Mf1KiPopGM9NiFjiYBCbfaa2Fh6breQ6ANVTI0A==",
+      "dev": true
+    },
+    "glob-parent": {
+      "version": "5.1.2",
+      "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz",
+      "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==",
+      "dev": true,
+      "requires": {
+        "is-glob": "^4.0.1"
+      }
+    },
+    "graceful-fs": {
+      "version": "4.2.9",
+      "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.9.tgz",
+      "integrity": "sha512-NtNxqUcXgpW2iMrfqSfR73Glt39K+BLwWsPs94yR63v45T0Wbej7eRmL5cWfwEgqXnmjQp3zaJTshdRW/qC2ZQ==",
+      "dev": true,
+      "optional": true
+    },
+    "has": {
+      "version": "1.0.3",
+      "resolved": "https://registry.npmjs.org/has/-/has-1.0.3.tgz",
+      "integrity": "sha512-f2dvO0VU6Oej7RkWJGrehjbzMAjFp5/VKPp5tTpWIV4JHHZK1/BxbFRtf/siA2SWTe09caDmVtYYzWEIbBS4zw==",
+      "dev": true,
+      "requires": {
+        "function-bind": "^1.1.1"
+      }
+    },
+    "iconv-lite": {
+      "version": "0.4.24",
+      "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz",
+      "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==",
+      "dev": true,
+      "optional": true,
+      "requires": {
+        "safer-buffer": ">= 2.1.2 < 3"
+      }
+    },
+    "image-size": {
+      "version": "0.5.5",
+      "resolved": "https://registry.npmjs.org/image-size/-/image-size-0.5.5.tgz",
+      "integrity": "sha1-Cd/Uq50g4p6xw+gLiZA3jfnjy5w=",
+      "dev": true,
+      "optional": true
+    },
+    "is-binary-path": {
+      "version": "2.1.0",
+      "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz",
+      "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==",
+      "dev": true,
+      "requires": {
+        "binary-extensions": "^2.0.0"
+      }
+    },
+    "is-core-module": {
+      "version": "2.8.1",
+      "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.8.1.tgz",
+      "integrity": "sha512-SdNCUs284hr40hFTFP6l0IfZ/RSrMXF3qgoRHd3/79unUTvrFO/JoXwkGm+5J/Oe3E/b5GsnG330uUNgRpu1PA==",
+      "dev": true,
+      "requires": {
+        "has": "^1.0.3"
+      }
+    },
+    "is-extglob": {
+      "version": "2.1.1",
+      "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz",
+      "integrity": "sha1-qIwCU1eR8C7TfHahueqXc8gz+MI=",
+      "dev": true
+    },
+    "is-glob": {
+      "version": "4.0.3",
+      "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz",
+      "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==",
+      "dev": true,
+      "requires": {
+        "is-extglob": "^2.1.1"
+      }
+    },
+    "is-number": {
+      "version": "7.0.0",
+      "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz",
+      "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==",
+      "dev": true
+    },
+    "is-what": {
+      "version": "3.14.1",
+      "resolved": "https://registry.npmjs.org/is-what/-/is-what-3.14.1.tgz",
+      "integrity": "sha512-sNxgpk9793nzSs7bA6JQJGeIuRBQhAaNGG77kzYQgMkrID+lS6SlK07K5LaptscDlSaIgH+GPFzf+d75FVxozA==",
+      "dev": true
+    },
+    "klona": {
+      "version": "2.0.5",
+      "resolved": "https://registry.npmjs.org/klona/-/klona-2.0.5.tgz",
+      "integrity": "sha512-pJiBpiXMbt7dkzXe8Ghj/u4FfXOOa98fPW+bihOJ4SjnoijweJrNThJfd3ifXpXhREjpoF2mZVH1GfS9LV3kHQ==",
+      "dev": true
+    },
+    "less": {
+      "version": "4.1.2",
+      "resolved": "https://registry.npmjs.org/less/-/less-4.1.2.tgz",
+      "integrity": "sha512-EoQp/Et7OSOVu0aJknJOtlXZsnr8XE8KwuzTHOLeVSEx8pVWUICc8Q0VYRHgzyjX78nMEyC/oztWFbgyhtNfDA==",
+      "dev": true,
+      "requires": {
+        "copy-anything": "^2.0.1",
+        "errno": "^0.1.1",
+        "graceful-fs": "^4.1.2",
+        "image-size": "~0.5.0",
+        "make-dir": "^2.1.0",
+        "mime": "^1.4.1",
+        "needle": "^2.5.2",
+        "parse-node-version": "^1.0.1",
+        "source-map": "~0.6.0",
+        "tslib": "^2.3.0"
+      }
+    },
+    "less-loader": {
+      "version": "10.2.0",
+      "resolved": "https://registry.npmjs.org/less-loader/-/less-loader-10.2.0.tgz",
+      "integrity": "sha512-AV5KHWvCezW27GT90WATaDnfXBv99llDbtaj4bshq6DvAihMdNjaPDcUMa6EXKLRF+P2opFenJp89BXg91XLYg==",
+      "dev": true,
+      "requires": {
+        "klona": "^2.0.4"
+      }
+    },
+    "local-pkg": {
+      "version": "0.4.1",
+      "resolved": "https://registry.npmjs.org/local-pkg/-/local-pkg-0.4.1.tgz",
+      "integrity": "sha512-lL87ytIGP2FU5PWwNDo0w3WhIo2gopIAxPg9RxDYF7m4rr5ahuZxP22xnJHIvaLTe4Z9P6uKKY2UHiwyB4pcrw==",
+      "dev": true
+    },
+    "lodash": {
+      "version": "4.17.21",
+      "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz",
+      "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg=="
+    },
+    "lodash-es": {
+      "version": "4.17.21",
+      "resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.17.21.tgz",
+      "integrity": "sha512-mKnC+QJ9pWVzv+C4/U3rRsHapFfHvQFoFB92e52xeyGMcX6/OlIl78je1u8vePzYZSkkogMPJ2yjxxsb89cxyw=="
+    },
+    "lodash-unified": {
+      "version": "1.0.2",
+      "resolved": "https://registry.npmjs.org/lodash-unified/-/lodash-unified-1.0.2.tgz",
+      "integrity": "sha512-OGbEy+1P+UT26CYi4opY4gebD8cWRDxAT6MAObIVQMiqYdxZr1g3QHWCToVsm31x2NkLS4K3+MC2qInaRMa39g=="
+    },
+    "magic-string": {
+      "version": "0.25.9",
+      "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.25.9.tgz",
+      "integrity": "sha512-RmF0AsMzgt25qzqqLc1+MbHmhdx0ojF2Fvs4XnOqz2ZOBXzzkEwc/dJQZCYHAn7v1jbVOjAZfK8msRn4BxO4VQ==",
+      "requires": {
+        "sourcemap-codec": "^1.4.8"
+      }
+    },
+    "make-dir": {
+      "version": "2.1.0",
+      "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-2.1.0.tgz",
+      "integrity": "sha512-LS9X+dc8KLxXCb8dni79fLIIUA5VyZoyjSMCwTluaXA0o27cCK0bhXkpgw+sTXVpPy/lSO57ilRixqk0vDmtRA==",
+      "dev": true,
+      "optional": true,
+      "requires": {
+        "pify": "^4.0.1",
+        "semver": "^5.6.0"
+      }
+    },
+    "memoize-one": {
+      "version": "6.0.0",
+      "resolved": "https://registry.npmjs.org/memoize-one/-/memoize-one-6.0.0.tgz",
+      "integrity": "sha512-rkpe71W0N0c0Xz6QD0eJETuWAJGnJ9afsl1srmwPrI+yBCkge5EycXXbYRyvL29zZVUWQCY7InPRCv3GDXuZNw=="
+    },
+    "merge2": {
+      "version": "1.4.1",
+      "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz",
+      "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==",
+      "dev": true
+    },
+    "micromatch": {
+      "version": "4.0.5",
+      "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.5.tgz",
+      "integrity": "sha512-DMy+ERcEW2q8Z2Po+WNXuw3c5YaUSFjAO5GsJqfEl7UjvtIuFKO6ZrKvcItdy98dwFI2N1tg3zNIdKaQT+aNdA==",
+      "dev": true,
+      "requires": {
+        "braces": "^3.0.2",
+        "picomatch": "^2.3.1"
+      }
+    },
+    "mime": {
+      "version": "1.6.0",
+      "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz",
+      "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==",
+      "dev": true,
+      "optional": true
+    },
+    "minimatch": {
+      "version": "5.0.1",
+      "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.0.1.tgz",
+      "integrity": "sha512-nLDxIFRyhDblz3qMuq+SoRZED4+miJ/G+tdDrjkkkRnjAsBexeGpgjLEQ0blJy7rHhR2b93rhQY4SvyWu9v03g==",
+      "dev": true,
+      "requires": {
+        "brace-expansion": "^2.0.1"
+      }
+    },
+    "ms": {
+      "version": "2.1.3",
+      "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
+      "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
+      "dev": true,
+      "optional": true
+    },
+    "nanoid": {
+      "version": "3.3.2",
+      "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.2.tgz",
+      "integrity": "sha512-CuHBogktKwpm5g2sRgv83jEy2ijFzBwMoYA60orPDR7ynsLijJDqgsi4RDGj3OJpy3Ieb+LYwiRmIOGyytgITA=="
+    },
+    "needle": {
+      "version": "2.9.1",
+      "resolved": "https://registry.npmjs.org/needle/-/needle-2.9.1.tgz",
+      "integrity": "sha512-6R9fqJ5Zcmf+uYaFgdIHmLwNldn5HbK8L5ybn7Uz+ylX/rnOsSp1AHcvQSrCaFN+qNM1wpymHqD7mVasEOlHGQ==",
+      "dev": true,
+      "optional": true,
+      "requires": {
+        "debug": "^3.2.6",
+        "iconv-lite": "^0.4.4",
+        "sax": "^1.2.4"
+      }
+    },
+    "normalize-path": {
+      "version": "3.0.0",
+      "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz",
+      "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==",
+      "dev": true
+    },
+    "normalize-wheel-es": {
+      "version": "1.1.2",
+      "resolved": "https://registry.npmjs.org/normalize-wheel-es/-/normalize-wheel-es-1.1.2.tgz",
+      "integrity": "sha512-scX83plWJXYH1J4+BhAuIHadROzxX0UBF3+HuZNY2Ks8BciE7tSTQ+5JhTsvzjaO0/EJdm4JBGrfObKxFf3Png=="
+    },
+    "parse-node-version": {
+      "version": "1.0.1",
+      "resolved": "https://registry.npmjs.org/parse-node-version/-/parse-node-version-1.0.1.tgz",
+      "integrity": "sha512-3YHlOa/JgH6Mnpr05jP9eDG254US9ek25LyIxZlDItp2iJtwyaXQb57lBYLdT3MowkUFYEV2XXNAYIPlESvJlA==",
+      "dev": true
+    },
+    "path-parse": {
+      "version": "1.0.7",
+      "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz",
+      "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==",
+      "dev": true
+    },
+    "picocolors": {
+      "version": "1.0.0",
+      "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.0.0.tgz",
+      "integrity": "sha512-1fygroTLlHu66zi26VoTDv8yRgm0Fccecssto+MhsZ0D/DGW2sm8E8AjW7NU5VVTRt5GxbeZ5qBuJr+HyLYkjQ=="
+    },
+    "picomatch": {
+      "version": "2.3.1",
+      "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz",
+      "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==",
+      "dev": true
+    },
+    "pify": {
+      "version": "4.0.1",
+      "resolved": "https://registry.npmjs.org/pify/-/pify-4.0.1.tgz",
+      "integrity": "sha512-uB80kBFb/tfd68bVleG9T5GGsGPjJrLAUpR5PZIrhBnIaRTQRjqdJSsIKkOP6OAIFbj7GOrcudc5pNjZ+geV2g==",
+      "dev": true,
+      "optional": true
+    },
+    "postcss": {
+      "version": "8.4.12",
+      "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.12.tgz",
+      "integrity": "sha512-lg6eITwYe9v6Hr5CncVbK70SoioNQIq81nsaG86ev5hAidQvmOeETBqs7jm43K2F5/Ley3ytDtriImV6TpNiSg==",
+      "requires": {
+        "nanoid": "^3.3.1",
+        "picocolors": "^1.0.0",
+        "source-map-js": "^1.0.2"
+      }
+    },
+    "prettier": {
+      "version": "2.7.1",
+      "resolved": "https://registry.npmjs.org/prettier/-/prettier-2.7.1.tgz",
+      "integrity": "sha512-ujppO+MkdPqoVINuDFDRLClm7D78qbDt0/NR+wp5FqEZOoTNAjPHWj17QRhu7geIHJfcNhRk1XVQmF8Bp3ye+g==",
+      "dev": true
+    },
+    "prr": {
+      "version": "1.0.1",
+      "resolved": "https://registry.npmjs.org/prr/-/prr-1.0.1.tgz",
+      "integrity": "sha1-0/wRS6BplaRexok/SEzrHXj19HY=",
+      "dev": true,
+      "optional": true
+    },
+    "queue-microtask": {
+      "version": "1.2.3",
+      "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz",
+      "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==",
+      "dev": true
+    },
+    "readdirp": {
+      "version": "3.6.0",
+      "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz",
+      "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==",
+      "dev": true,
+      "requires": {
+        "picomatch": "^2.2.1"
+      }
+    },
+    "resolve": {
+      "version": "1.22.0",
+      "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.0.tgz",
+      "integrity": "sha512-Hhtrw0nLeSrFQ7phPp4OOcVjLPIeMnRlr5mcnVuMe7M/7eBn98A3hmFRLoFo3DLZkivSYwhRUJTyPyWAk56WLw==",
+      "dev": true,
+      "requires": {
+        "is-core-module": "^2.8.1",
+        "path-parse": "^1.0.7",
+        "supports-preserve-symlinks-flag": "^1.0.0"
+      }
+    },
+    "reusify": {
+      "version": "1.0.4",
+      "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.0.4.tgz",
+      "integrity": "sha512-U9nH88a3fc/ekCF1l0/UP1IosiuIjyTh7hBvXVMHYgVcfGvt897Xguj2UOLDeI5BG2m7/uwyaLVT6fbtCwTyzw==",
+      "dev": true
+    },
+    "rollup": {
+      "version": "2.70.1",
+      "resolved": "https://registry.npmjs.org/rollup/-/rollup-2.70.1.tgz",
+      "integrity": "sha512-CRYsI5EuzLbXdxC6RnYhOuRdtz4bhejPMSWjsFLfVM/7w/85n2szZv6yExqUXsBdz5KT8eoubeyDUDjhLHEslA==",
+      "dev": true,
+      "requires": {
+        "fsevents": "~2.3.2"
+      }
+    },
+    "run-parallel": {
+      "version": "1.2.0",
+      "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz",
+      "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==",
+      "dev": true,
+      "requires": {
+        "queue-microtask": "^1.2.2"
+      }
+    },
+    "safer-buffer": {
+      "version": "2.1.2",
+      "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
+      "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
+      "dev": true,
+      "optional": true
+    },
+    "sax": {
+      "version": "1.2.4",
+      "resolved": "https://registry.npmjs.org/sax/-/sax-1.2.4.tgz",
+      "integrity": "sha512-NqVDv9TpANUjFm0N8uM5GxL36UgKi9/atZw+x7YFnQ8ckwFGKrl4xX4yWtrey3UJm5nP1kUbnYgLopqWNSRhWw==",
+      "dev": true,
+      "optional": true
+    },
+    "semver": {
+      "version": "5.7.1",
+      "resolved": "https://registry.npmjs.org/semver/-/semver-5.7.1.tgz",
+      "integrity": "sha512-sauaDf/PZdVgrLTNYHRtpXa1iRiKcaebiKQ1BJdpQlWH2lCvexQdX55snPFyK7QzpudqbCI0qXFfOasHdyNDGQ==",
+      "dev": true,
+      "optional": true
+    },
+    "source-map": {
+      "version": "0.6.1",
+      "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz",
+      "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="
+    },
+    "source-map-js": {
+      "version": "1.0.2",
+      "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.0.2.tgz",
+      "integrity": "sha512-R0XvVJ9WusLiqTCEiGCmICCMplcCkIwwR11mOSD9CR5u+IXYdiseeEuXCVAjS54zqwkLcPNnmU4OeJ6tUrWhDw=="
+    },
+    "sourcemap-codec": {
+      "version": "1.4.8",
+      "resolved": "https://registry.npmjs.org/sourcemap-codec/-/sourcemap-codec-1.4.8.tgz",
+      "integrity": "sha512-9NykojV5Uih4lgo5So5dtw+f0JgJX30KCNI8gwhz2J9A15wD0Ml6tjHKwf6fTSa6fAdVBdZeNOs9eJ71qCk8vA=="
+    },
+    "supports-preserve-symlinks-flag": {
+      "version": "1.0.0",
+      "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz",
+      "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==",
+      "dev": true
+    },
+    "to-regex-range": {
+      "version": "5.0.1",
+      "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz",
+      "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==",
+      "dev": true,
+      "requires": {
+        "is-number": "^7.0.0"
+      }
+    },
+    "tslib": {
+      "version": "2.3.1",
+      "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.3.1.tgz",
+      "integrity": "sha512-77EbyPPpMz+FRFRuAFlWMtmgUWGe9UOG2Z25NqCwiIjRhOf5iKGuzSe5P2w1laq+FkRy4p+PCuVkJSGkzTEKVw==",
+      "dev": true
+    },
+    "unplugin": {
+      "version": "0.4.0",
+      "resolved": "https://registry.npmjs.org/unplugin/-/unplugin-0.4.0.tgz",
+      "integrity": "sha512-4ScITEmzlz1iZW3tkz+3L1V5k/xMQ6kjgm4lEXKxH0ozd8/OUWfiSA7RMRyrawsvq/t50JIzPpp1UyuSL/AXkA==",
+      "dev": true,
+      "requires": {
+        "chokidar": "^3.5.3",
+        "webpack-virtual-modules": "^0.4.3"
+      }
+    },
+    "unplugin-auto-import": {
+      "version": "0.6.9",
+      "resolved": "https://registry.npmjs.org/unplugin-auto-import/-/unplugin-auto-import-0.6.9.tgz",
+      "integrity": "sha512-IqgT7AoRrNQwNhiF/wDH3sMEDX8SqCYBEgJzwdg5441b5aiC5VwZz0J0wYqkaKu89YkZE9DG6rQ2JpFfZv1iiQ==",
+      "dev": true,
+      "requires": {
+        "@antfu/utils": "^0.5.0",
+        "@rollup/pluginutils": "^4.2.0",
+        "local-pkg": "^0.4.1",
+        "magic-string": "^0.26.1",
+        "resolve": "^1.22.0",
+        "unplugin": "^0.4.0"
+      },
+      "dependencies": {
+        "magic-string": {
+          "version": "0.26.1",
+          "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.26.1.tgz",
+          "integrity": "sha512-ndThHmvgtieXe8J/VGPjG+Apu7v7ItcD5mhEIvOscWjPF/ccOiLxHaSuCAS2G+3x4GKsAbT8u7zdyamupui8Tg==",
+          "dev": true,
+          "requires": {
+            "sourcemap-codec": "^1.4.8"
+          }
+        }
+      }
+    },
+    "unplugin-vue-components": {
+      "version": "0.18.5",
+      "resolved": "https://registry.npmjs.org/unplugin-vue-components/-/unplugin-vue-components-0.18.5.tgz",
+      "integrity": "sha512-VPA6z/4pcKRDYtWu1H+FIpV0MADlFKG3q7YMVFzNFC3EhMVZ4WuBJ76490oKyauguNw1T1obLCuxNU9JzJ0oAQ==",
+      "dev": true,
+      "requires": {
+        "@antfu/utils": "^0.5.0",
+        "@rollup/pluginutils": "^4.2.0",
+        "chokidar": "^3.5.3",
+        "debug": "^4.3.3",
+        "fast-glob": "^3.2.11",
+        "local-pkg": "^0.4.1",
+        "magic-string": "^0.26.1",
+        "minimatch": "^5.0.1",
+        "resolve": "^1.22.0",
+        "unplugin": "^0.4.0"
+      },
+      "dependencies": {
+        "debug": {
+          "version": "4.3.4",
+          "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz",
+          "integrity": "sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==",
+          "dev": true,
+          "requires": {
+            "ms": "2.1.2"
+          }
+        },
+        "magic-string": {
+          "version": "0.26.1",
+          "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.26.1.tgz",
+          "integrity": "sha512-ndThHmvgtieXe8J/VGPjG+Apu7v7ItcD5mhEIvOscWjPF/ccOiLxHaSuCAS2G+3x4GKsAbT8u7zdyamupui8Tg==",
+          "dev": true,
+          "requires": {
+            "sourcemap-codec": "^1.4.8"
+          }
+        },
+        "ms": {
+          "version": "2.1.2",
+          "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz",
+          "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==",
+          "dev": true
+        }
+      }
+    },
+    "vite": {
+      "version": "2.9.1",
+      "resolved": "https://registry.npmjs.org/vite/-/vite-2.9.1.tgz",
+      "integrity": "sha512-vSlsSdOYGcYEJfkQ/NeLXgnRv5zZfpAsdztkIrs7AZHV8RCMZQkwjo4DS5BnrYTqoWqLoUe1Cah4aVO4oNNqCQ==",
+      "dev": true,
+      "requires": {
+        "esbuild": "^0.14.27",
+        "fsevents": "~2.3.2",
+        "postcss": "^8.4.12",
+        "resolve": "^1.22.0",
+        "rollup": "^2.59.0"
+      }
+    },
+    "vue": {
+      "version": "3.2.31",
+      "resolved": "https://registry.npmjs.org/vue/-/vue-3.2.31.tgz",
+      "integrity": "sha512-odT3W2tcffTiQCy57nOT93INw1auq5lYLLYtWpPYQQYQOOdHiqFct9Xhna6GJ+pJQaF67yZABraH47oywkJgFw==",
+      "requires": {
+        "@vue/compiler-dom": "3.2.31",
+        "@vue/compiler-sfc": "3.2.31",
+        "@vue/runtime-dom": "3.2.31",
+        "@vue/server-renderer": "3.2.31",
+        "@vue/shared": "3.2.31"
+      }
+    },
+    "vue-demi": {
+      "version": "0.12.5",
+      "resolved": "https://registry.npmjs.org/vue-demi/-/vue-demi-0.12.5.tgz",
+      "integrity": "sha512-BREuTgTYlUr0zw0EZn3hnhC3I6gPWv+Kwh4MCih6QcAeaTlaIX0DwOVN0wHej7hSvDPecz4jygy/idsgKfW58Q=="
+    },
+    "webpack-virtual-modules": {
+      "version": "0.4.3",
+      "resolved": "https://registry.npmjs.org/webpack-virtual-modules/-/webpack-virtual-modules-0.4.3.tgz",
+      "integrity": "sha512-5NUqC2JquIL2pBAAo/VfBP6KuGkHIZQXW/lNKupLPfhViwh8wNsu0BObtl09yuKZszeEUfbXz8xhrHvSG16Nqw==",
+      "dev": true
+    }
+  }
+}

+ 24 - 0
app/package.json

@@ -0,0 +1,24 @@
+{
+  "name": "tiny_whiteboard_demo",
+  "private": true,
+  "version": "0.0.0",
+  "scripts": {
+    "dev": "vite --host",
+    "build": "vite build",
+    "preview": "vite preview",
+    "format": "prettier --write ."
+  },
+  "dependencies": {
+    "element-plus": "^2.1.6",
+    "vue": "^3.2.25"
+  },
+  "devDependencies": {
+    "@vitejs/plugin-vue": "^2.2.0",
+    "less": "^4.1.2",
+    "less-loader": "^10.2.0",
+    "prettier": "^2.7.1",
+    "unplugin-auto-import": "^0.6.6",
+    "unplugin-vue-components": "^0.18.5",
+    "vite": "^2.8.0"
+  }
+}

BIN
app/public/favicon.ico


+ 1 - 0
app/public/libs/icons.svg

@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="11" height="22"><defs><linearGradient id="a"><stop offset="0"/><stop offset="1" stop-opacity="0"/></linearGradient><radialGradient xlink:href="#a" cx="9.739" cy="9.716" fx="9.739" fy="9.716" r="3.709" gradientUnits="userSpaceOnUse"/></defs><g stroke="#000" fill="none"><g transform="translate(-129.5 -333.862) translate(0 .188)"><rect transform="matrix(.962 0 0 .971 4.943 11.548)" ry="2" rx="2" y="332.362" x="130" height="10.337" width="10.432" opacity=".5"/><g><path d="M132 339.175h6" opacity=".5"/><path d="M135 336.175v6" opacity=".5"/></g></g><g transform="translate(-129.5 -333.862)"><rect width="10.432" height="10.337" x="130" y="332.362" rx="2" ry="2" transform="matrix(.962 0 0 .971 4.943 22.736)" opacity=".5"/><path d="M132 350.362h6" opacity=".5"/></g></g></svg>

+ 107 - 0
app/public/libs/jsonTree.css

@@ -0,0 +1,107 @@
+/*
+ * JSON Tree Viewer
+ * http://github.com/summerstyle/jsonTreeViewer
+ *
+ * Copyright 2017 Vera Lobacheva (http://iamvera.com)
+ * Released under the MIT license (LICENSE.txt)
+ */
+
+/* Background for the tree. May use for <body> element */
+.jsontree_bg {
+    background: #FFF;
+}
+
+/* Styles for the container of the tree (e.g. fonts, margins etc.) */
+.jsontree_tree {
+    margin-left: 30px;
+    font-family: 'PT Mono', monospace;
+    font-size: 14px;
+}
+
+/* Styles for a list of child nodes */
+.jsontree_child-nodes {
+    display: none;
+    margin-left: 35px; 
+    margin-bottom: 5px;
+    line-height: 2;
+}
+.jsontree_node_expanded > .jsontree_value-wrapper > .jsontree_value > .jsontree_child-nodes {
+    display: block;
+}
+
+/* Styles for labels */
+.jsontree_label-wrapper {
+    float: left;
+    margin-right: 8px;
+}
+.jsontree_label {
+    font-weight: normal;
+    vertical-align: top;
+    color: #000;
+    position: relative;
+    padding: 1px;
+    border-radius: 4px;
+    cursor: default;
+}
+.jsontree_node_marked > .jsontree_label-wrapper > .jsontree_label {
+    background: #fff2aa;
+}
+
+/* Styles for values */
+.jsontree_value-wrapper {
+    display: block;
+    overflow: hidden;
+}
+.jsontree_node_complex > .jsontree_value-wrapper {
+    overflow: inherit;
+}
+.jsontree_value { 
+    vertical-align: top;
+    display: inline;
+}
+.jsontree_value_null {
+    color: #777;
+    font-weight: bold;
+}
+.jsontree_value_string {
+    color: #025900;
+    font-weight: bold;
+}
+.jsontree_value_number {
+    color: #000E59;
+    font-weight: bold;
+}
+.jsontree_value_boolean {
+    color: #600100;
+    font-weight: bold;
+}
+
+/* Styles for active elements */
+.jsontree_expand-button {
+    position: absolute;
+    top: 3px;
+    left: -15px;
+    display: block;
+    width: 11px;
+    height: 11px;
+    background-image: url('icons.svg');
+}
+.jsontree_node_expanded > .jsontree_label-wrapper > .jsontree_label > .jsontree_expand-button {
+    background-position: 0 -11px;
+}
+.jsontree_show-more {
+    cursor: pointer;
+}
+.jsontree_node_expanded > .jsontree_value-wrapper > .jsontree_value > .jsontree_show-more {
+    display: none;
+}
+.jsontree_node_empty > .jsontree_label-wrapper > .jsontree_label > .jsontree_expand-button,
+.jsontree_node_empty > .jsontree_value-wrapper > .jsontree_value > .jsontree_show-more {
+    display: none !important;
+}
+.jsontree_node_complex > .jsontree_label-wrapper > .jsontree_label {
+    cursor: pointer;
+}
+.jsontree_node_empty > .jsontree_label-wrapper > .jsontree_label {
+    cursor: default !important;
+}

+ 819 - 0
app/public/libs/jsonTree.js

@@ -0,0 +1,819 @@
+/**
+ * JSON Tree library (a part of jsonTreeViewer)
+ * http://github.com/summerstyle/jsonTreeViewer
+ *
+ * Copyright 2017 Vera Lobacheva (http://iamvera.com)
+ * Released under the MIT license (LICENSE.txt)
+ */
+
+var jsonTree = (function() {
+    
+    /* ---------- Utilities ---------- */
+    var utils = {
+        
+        /*
+         * Returns js-"class" of value
+         * 
+         * @param val {any type} - value
+         * @returns {string} - for example, "[object Function]"
+         */
+        getClass : function(val) {
+            return Object.prototype.toString.call(val);
+        },
+        
+        /**
+         * Checks for a type of value (for valid JSON data types).
+         * In other cases - throws an exception
+         * 
+         * @param val {any type} - the value for new node
+         * @returns {string} ("object" | "array" | "null" | "boolean" | "number" | "string")
+         */
+        getType : function(val) {
+            if (val === null) {
+                return 'null';
+            }
+            
+            switch (typeof val) {
+                case 'number':
+                    return 'number';
+                
+                case 'string':
+                    return 'string';
+                
+                case 'boolean':
+                    return 'boolean';
+            }
+            
+            switch(utils.getClass(val)) {
+                case '[object Array]':
+                    return 'array';
+                
+                case '[object Object]':
+                    return 'object';
+            }
+            
+            throw new Error('Bad type: ' + utils.getClass(val));
+        },
+        
+        /**
+         * Applies for each item of list some function
+         * and checks for last element of the list
+         * 
+         * @param obj {Object | Array} - a list or a dict with child nodes
+         * @param func {Function} - the function for each item
+         */
+        forEachNode : function(obj, func) {
+            var type = utils.getType(obj),
+                isLast;
+        
+            switch (type) {
+                case 'array':
+                    isLast = obj.length - 1;
+                    
+                    obj.forEach(function(item, i) {
+                        func(i, item, i === isLast);
+                    });
+                    
+                    break;
+                
+                case 'object':
+                    var keys = Object.keys(obj).sort();
+                    
+                    isLast = keys.length - 1;
+                    
+                    keys.forEach(function(item, i) {
+                        func(item, obj[item], i === isLast);
+                    });
+                    
+                    break;
+            }
+            
+        },
+        
+        /**
+         * Implements the kind of an inheritance by
+         * using parent prototype and
+         * creating intermediate constructor
+         * 
+         * @param Child {Function} - a child constructor
+         * @param Parent {Function} - a parent constructor
+         */
+        inherits : (function() {
+            var F = function() {};
+            
+            return function(Child, Parent) {
+                F.prototype = Parent.prototype;
+                Child.prototype = new F();
+                Child.prototype.constructor = Child;
+            };
+        })(),
+        
+        /*
+         * Checks for a valid type of root node*
+         *
+         * @param {any type} jsonObj - a value for root node
+         * @returns {boolean} - true for an object or an array, false otherwise
+         */
+        isValidRoot : function(jsonObj) {
+            switch (utils.getType(jsonObj)) {
+                case 'object':
+                case 'array':
+                    return true;
+                default:
+                    return false;
+            }
+        },
+
+        /**
+         * Extends some object
+         */
+        extend : function(targetObj, sourceObj) {
+            for (var prop in sourceObj) {
+                if (sourceObj.hasOwnProperty(prop)) {
+                    targetObj[prop] = sourceObj[prop];
+                }
+            }
+        }
+    };
+    
+    
+    /* ---------- Node constructors ---------- */
+    
+    /**
+     * The factory for creating nodes of defined type.
+     * 
+     * ~~~ Node ~~~ is a structure element of an onject or an array
+     * with own label (a key of an object or an index of an array)
+     * and value of any json data type. The root object or array
+     * is a node without label.
+     * {...
+     * [+] "label": value,
+     * ...}
+     * 
+     * Markup:
+     * <li class="jsontree_node [jsontree_node_expanded]">
+     *     <span class="jsontree_label-wrapper">
+     *         <span class="jsontree_label">
+     *             <span class="jsontree_expand-button" />
+     *             "label"
+     *         </span>
+     *         :
+     *     </span>
+     *     <(div|span) class="jsontree_value jsontree_value_(object|array|boolean|null|number|string)">
+     *         ...
+     *     </(div|span)>
+     * </li>
+     *
+     * @param label {string} - key name
+     * @param val {Object | Array | string | number | boolean | null} - a value of node
+     * @param isLast {boolean} - true if node is last in list of siblings
+     * 
+     * @return {Node}
+     */
+    function Node(label, val, isLast) {
+        var nodeType = utils.getType(val);
+        
+        if (nodeType in Node.CONSTRUCTORS) {
+            return new Node.CONSTRUCTORS[nodeType](label, val, isLast);
+        } else {
+            throw new Error('Bad type: ' + utils.getClass(val));
+        }
+    }
+    
+    Node.CONSTRUCTORS = {
+        'boolean' : NodeBoolean,
+        'number'  : NodeNumber,
+        'string'  : NodeString,
+        'null'    : NodeNull,
+        'object'  : NodeObject,
+        'array'   : NodeArray  
+    };
+    
+    
+    /*
+     * The constructor for simple types (string, number, boolean, null)
+     * {...
+     * [+] "label": value,
+     * ...}
+     * value = string || number || boolean || null
+     *
+     * Markup:
+     * <li class="jsontree_node">
+     *     <span class="jsontree_label-wrapper">
+     *         <span class="jsontree_label">"age"</span>
+     *         :
+     *     </span>
+     *     <span class="jsontree_value jsontree_value_(number|boolean|string|null)">25</span>
+     *     ,
+     * </li>
+     *
+     * @abstract
+     * @param label {string} - key name
+     * @param val {string | number | boolean | null} - a value of simple types
+     * @param isLast {boolean} - true if node is last in list of parent childNodes
+     */
+    function _NodeSimple(label, val, isLast) {
+        if (this.constructor === _NodeSimple) {
+            throw new Error('This is abstract class');
+        }
+        
+        var self = this,
+            el = document.createElement('li'),
+            labelEl,
+            template = function(label, val) {
+                var str = '\
+                    <span class="jsontree_label-wrapper">\
+                        <span class="jsontree_label">"' +
+                            label +
+                        '"</span> : \
+                    </span>\
+                    <span class="jsontree_value-wrapper">\
+                        <span class="jsontree_value jsontree_value_' + self.type + '">' +
+                            val +
+                        '</span>' +
+                        (!isLast ? ',' : '') + 
+                    '</span>';
+    
+                return str;
+            };
+            
+        self.label = label;
+        self.isComplex = false;
+    
+        el.classList.add('jsontree_node');
+        el.innerHTML = template(label, val);
+    
+        self.el = el;
+
+        labelEl = el.querySelector('.jsontree_label');
+    
+        labelEl.addEventListener('click', function(e) {
+            if (e.altKey) {
+                self.toggleMarked();
+                return;
+            }
+
+            if (e.shiftKey) {
+                document.getSelection().removeAllRanges();
+                alert(self.getJSONPath());
+                return;
+            }
+        }, false);
+    }
+
+    _NodeSimple.prototype = {
+        constructor : _NodeSimple,
+
+        /**
+         * Mark node
+         */
+        mark : function() {
+            this.el.classList.add('jsontree_node_marked');    
+        },
+
+        /**
+         * Unmark node
+         */
+        unmark : function() {
+            this.el.classList.remove('jsontree_node_marked');    
+        },
+
+        /**
+         * Mark or unmark node
+         */
+        toggleMarked : function() {
+            this.el.classList.toggle('jsontree_node_marked');    
+        },
+
+        /**
+         * Expands parent node of this node
+         *
+         * @param isRecursive {boolean} - if true, expands all parent nodes
+         *                                (from node to root)
+         */
+        expandParent : function(isRecursive) {
+            if (!this.parent) {
+                return;
+            }
+               
+            this.parent.expand(); 
+            this.parent.expandParent(isRecursive);
+        },
+
+        /**
+         * Returns JSON-path of this 
+         * 
+         * @param isInDotNotation {boolean} - kind of notation for returned json-path
+         *                                    (by default, in bracket notation)
+         * @returns {string}
+         */
+        getJSONPath : function(isInDotNotation) {
+            if (this.isRoot) {
+                return "$";
+            }
+
+            var currentPath;
+
+            if (this.parent.type === 'array') {
+                currentPath = "[" + this.label + "]";
+            } else {
+                currentPath = isInDotNotation ? "." + this.label : "['" + this.label + "']";
+            }
+
+            return this.parent.getJSONPath(isInDotNotation) + currentPath; 
+        }
+    };
+    
+    
+    /*
+     * The constructor for boolean values
+     * {...
+     * [+] "label": boolean,
+     * ...}
+     * boolean = true || false
+     *
+     * @constructor
+     * @param label {string} - key name
+     * @param val {boolean} - value of boolean type, true or false
+     * @param isLast {boolean} - true if node is last in list of parent childNodes
+     */
+    function NodeBoolean(label, val, isLast) {
+        this.type = "boolean";
+    
+        _NodeSimple.call(this, label, val, isLast);
+    }
+    utils.inherits(NodeBoolean,_NodeSimple);
+    
+    
+    /*
+     * The constructor for number values
+     * {...
+     * [+] "label": number,
+     * ...}
+     * number = 123
+     *
+     * @constructor
+     * @param label {string} - key name
+     * @param val {number} - value of number type, for example 123
+     * @param isLast {boolean} - true if node is last in list of parent childNodes
+     */
+    function NodeNumber(label, val, isLast) {
+        this.type = "number";
+    
+        _NodeSimple.call(this, label, val, isLast);
+    }
+    utils.inherits(NodeNumber,_NodeSimple);
+    
+    
+    /*
+     * The constructor for string values
+     * {...
+     * [+] "label": string,
+     * ...}
+     * string = "abc"
+     *
+     * @constructor
+     * @param label {string} - key name
+     * @param val {string} - value of string type, for example "abc"
+     * @param isLast {boolean} - true if node is last in list of parent childNodes
+     */
+    function NodeString(label, val, isLast) {
+        this.type = "string";
+    
+        _NodeSimple.call(this, label, '"' + val + '"', isLast);
+    }
+    utils.inherits(NodeString,_NodeSimple);
+    
+    
+    /*
+     * The constructor for null values
+     * {...
+     * [+] "label": null,
+     * ...}
+     *
+     * @constructor
+     * @param label {string} - key name
+     * @param val {null} - value (only null)
+     * @param isLast {boolean} - true if node is last in list of parent childNodes
+     */
+    function NodeNull(label, val, isLast) {
+        this.type = "null";
+    
+        _NodeSimple.call(this, label, val, isLast);
+    }
+    utils.inherits(NodeNull,_NodeSimple);
+    
+    
+    /*
+     * The constructor for complex types (object, array)
+     * {...
+     * [+] "label": value,
+     * ...}
+     * value = object || array
+     *
+     * Markup:
+     * <li class="jsontree_node jsontree_node_(object|array) [expanded]">
+     *     <span class="jsontree_label-wrapper">
+     *         <span class="jsontree_label">
+     *             <span class="jsontree_expand-button" />
+     *             "label"
+     *         </span>
+     *         :
+     *     </span>
+     *     <div class="jsontree_value">
+     *         <b>{</b>
+     *         <ul class="jsontree_child-nodes" />
+     *         <b>}</b>
+     *         ,
+     *     </div>
+     * </li>
+     *
+     * @abstract
+     * @param label {string} - key name
+     * @param val {Object | Array} - a value of complex types, object or array
+     * @param isLast {boolean} - true if node is last in list of parent childNodes
+     */
+    function _NodeComplex(label, val, isLast) {
+        if (this.constructor === _NodeComplex) {
+            throw new Error('This is abstract class');
+        }
+        
+        var self = this,
+            el = document.createElement('li'),
+            template = function(label, sym) {
+                var comma = (!isLast) ? ',' : '',
+                    str = '\
+                        <div class="jsontree_value-wrapper">\
+                            <div class="jsontree_value jsontree_value_' + self.type + '">\
+                                <b>' + sym[0] + '</b>\
+                                <span class="jsontree_show-more">&hellip;</span>\
+                                <ul class="jsontree_child-nodes"></ul>\
+                                <b>' + sym[1] + '</b>' +
+                            '</div>' + comma +
+                        '</div>';
+    
+                if (label !== null) {
+                    str = '\
+                        <span class="jsontree_label-wrapper">\
+                            <span class="jsontree_label">' +
+                                '<span class="jsontree_expand-button"></span>' +
+                                '"' + label +
+                            '"</span> : \
+                        </span>' + str;
+                }
+    
+                return str;
+            },
+            childNodesUl,
+            labelEl,
+            moreContentEl,
+            childNodes = [];
+    
+        self.label = label;
+        self.isComplex = true;
+    
+        el.classList.add('jsontree_node');
+        el.classList.add('jsontree_node_complex');
+        el.innerHTML = template(label, self.sym);
+    
+        childNodesUl = el.querySelector('.jsontree_child-nodes');
+    
+        if (label !== null) {
+            labelEl = el.querySelector('.jsontree_label');
+            moreContentEl = el.querySelector('.jsontree_show-more');
+    
+            labelEl.addEventListener('click', function(e) {
+                if (e.altKey) {
+                    self.toggleMarked();
+                    return;
+                }
+
+                if (e.shiftKey) {
+                    document.getSelection().removeAllRanges();
+                    alert(self.getJSONPath());
+                    return;
+                }
+
+                self.toggle(e.ctrlKey || e.metaKey);
+            }, false);
+            
+            moreContentEl.addEventListener('click', function(e) {
+                self.toggle(e.ctrlKey || e.metaKey);
+            }, false);
+    
+            self.isRoot = false;
+        } else {
+            self.isRoot = true;
+            self.parent = null;
+    
+            el.classList.add('jsontree_node_expanded');
+        }
+    
+        self.el = el;
+        self.childNodes = childNodes;
+        self.childNodesUl = childNodesUl;
+    
+        utils.forEachNode(val, function(label, node, isLast) {
+            self.addChild(new Node(label, node, isLast));
+        });
+    
+        self.isEmpty = !Boolean(childNodes.length);
+        if (self.isEmpty) {
+            el.classList.add('jsontree_node_empty');
+        }
+    }
+
+    utils.inherits(_NodeComplex, _NodeSimple);
+    
+    utils.extend(_NodeComplex.prototype, {
+        constructor : _NodeComplex,
+        
+        /*
+         * Add child node to list of child nodes
+         *
+         * @param child {Node} - child node
+         */
+        addChild : function(child) {
+            this.childNodes.push(child);
+            this.childNodesUl.appendChild(child.el);
+            child.parent = this;
+        },
+    
+        /*
+         * Expands this list of node child nodes
+         *
+         * @param isRecursive {boolean} - if true, expands all child nodes
+         */
+        expand : function(isRecursive){
+            if (this.isEmpty) {
+                return;
+            }
+            
+            if (!this.isRoot) {
+                this.el.classList.add('jsontree_node_expanded');
+            }
+    
+            if (isRecursive) {
+                this.childNodes.forEach(function(item, i) {
+                    if (item.isComplex) {
+                        item.expand(isRecursive);
+                    }
+                });
+            }
+        },
+    
+        /*
+         * Collapses this list of node child nodes
+         *
+         * @param isRecursive {boolean} - if true, collapses all child nodes
+         */
+        collapse : function(isRecursive) {
+            if (this.isEmpty) {
+                return;
+            }
+            
+            if (!this.isRoot) {
+                this.el.classList.remove('jsontree_node_expanded');
+            }
+    
+            if (isRecursive) {
+                this.childNodes.forEach(function(item, i) {
+                    if (item.isComplex) {
+                        item.collapse(isRecursive);
+                    }
+                });
+            }
+        },
+    
+        /*
+         * Expands collapsed or collapses expanded node
+         *
+         * @param {boolean} isRecursive - Expand all child nodes if this node is expanded
+         *                                and collapse it otherwise
+         */
+        toggle : function(isRecursive) {
+            if (this.isEmpty) {
+                return;
+            }
+            
+            this.el.classList.toggle('jsontree_node_expanded');
+            
+            if (isRecursive) {
+                var isExpanded = this.el.classList.contains('jsontree_node_expanded');
+                
+                this.childNodes.forEach(function(item, i) {
+                    if (item.isComplex) {
+                        item[isExpanded ? 'expand' : 'collapse'](isRecursive);
+                    }
+                });
+            }
+        },
+
+        /**
+         * Find child nodes that match some conditions and handle it
+         * 
+         * @param {Function} matcher
+         * @param {Function} handler
+         * @param {boolean} isRecursive
+         */
+        findChildren : function(matcher, handler, isRecursive) {
+            if (this.isEmpty) {
+                return;
+            }
+            
+            this.childNodes.forEach(function(item, i) {
+                if (matcher(item)) {
+                    handler(item);
+                }
+
+                if (item.isComplex && isRecursive) {
+                    item.findChildren(matcher, handler, isRecursive);
+                }
+            });
+        }
+    });
+    
+    
+    /*
+     * The constructor for object values
+     * {...
+     * [+] "label": object,
+     * ...}
+     * object = {"abc": "def"}
+     *
+     * @constructor
+     * @param label {string} - key name
+     * @param val {Object} - value of object type, {"abc": "def"}
+     * @param isLast {boolean} - true if node is last in list of siblings
+     */
+    function NodeObject(label, val, isLast) {
+        this.sym = ['{', '}'];
+        this.type = "object";
+    
+        _NodeComplex.call(this, label, val, isLast);
+    }
+    utils.inherits(NodeObject,_NodeComplex);
+    
+    
+    /*
+     * The constructor for array values
+     * {...
+     * [+] "label": array,
+     * ...}
+     * array = [1,2,3]
+     *
+     * @constructor
+     * @param label {string} - key name
+     * @param val {Array} - value of array type, [1,2,3]
+     * @param isLast {boolean} - true if node is last in list of siblings
+     */
+    function NodeArray(label, val, isLast) {
+        this.sym = ['[', ']'];
+        this.type = "array";
+    
+        _NodeComplex.call(this, label, val, isLast);
+    }
+    utils.inherits(NodeArray, _NodeComplex);
+    
+    
+    /* ---------- The tree constructor ---------- */
+    
+    /*
+     * The constructor for json tree.
+     * It contains only one Node (Array or Object), without property name.
+     * CSS-styles of .tree define main tree styles like font-family,
+     * font-size and own margins.
+     *
+     * Markup:
+     * <ul class="jsontree_tree clearfix">
+     *     {Node}
+     * </ul>
+     *
+     * @constructor
+     * @param jsonObj {Object | Array} - data for tree
+     * @param domEl {DOMElement} - DOM-element, wrapper for tree
+     */
+    function Tree(jsonObj, domEl) {
+        this.wrapper = document.createElement('ul');
+        this.wrapper.className = 'jsontree_tree clearfix';
+        
+        this.rootNode = null;
+        
+        this.sourceJSONObj = jsonObj;
+
+        this.loadData(jsonObj);
+        this.appendTo(domEl);
+    }
+    
+    Tree.prototype = {
+        constructor : Tree,
+        
+        /**
+         * Fill new data in current json tree
+         *
+         * @param {Object | Array} jsonObj - json-data
+         */
+        loadData : function(jsonObj) {
+            if (!utils.isValidRoot(jsonObj)) {
+                alert('The root should be an object or an array');
+                return;
+            }
+
+            this.sourceJSONObj = jsonObj;
+            
+            this.rootNode = new Node(null, jsonObj, 'last');
+            this.wrapper.innerHTML = '';
+            this.wrapper.appendChild(this.rootNode.el);
+        },
+        
+        /**
+         * Appends tree to DOM-element (or move it to new place)
+         *
+         * @param {DOMElement} domEl 
+         */
+        appendTo : function(domEl) {
+            domEl.appendChild(this.wrapper);
+        },
+        
+        /**
+         * Expands all tree nodes (objects or arrays) recursively
+         *
+         * @param {Function} filterFunc - 'true' if this node should be expanded
+         */
+        expand : function(filterFunc) {
+            if (this.rootNode.isComplex) {
+                if (typeof filterFunc == 'function') {
+                    this.rootNode.childNodes.forEach(function(item, i) {
+                        if (item.isComplex && filterFunc(item)) {
+                            item.expand();
+                        }
+                    });
+                } else {
+                    this.rootNode.expand('recursive');
+                }
+            }
+        },
+       
+        /**
+         * Collapses all tree nodes (objects or arrays) recursively
+         */
+        collapse : function() {
+            if (typeof this.rootNode.collapse === 'function') {
+                this.rootNode.collapse('recursive');
+            }
+        },
+
+        /**
+         * Returns the source json-string (pretty-printed)
+         * 
+         * @param {boolean} isPrettyPrinted - 'true' for pretty-printed string
+         * @returns {string} - for exemple, '{"a":2,"b":3}'
+         */
+        toSourceJSON : function(isPrettyPrinted) {
+            if (!isPrettyPrinted) {
+                return JSON.stringify(this.sourceJSONObj);
+            }
+
+            var DELIMETER = "[%^$#$%^%]",
+                jsonStr = JSON.stringify(this.sourceJSONObj, null, DELIMETER);
+
+            jsonStr = jsonStr.split("\n").join("<br />");
+            jsonStr = jsonStr.split(DELIMETER).join("&nbsp;&nbsp;&nbsp;&nbsp;");
+
+            return jsonStr;
+        },
+
+        /**
+         * Find all nodes that match some conditions and handle it
+         */
+        findAndHandle : function(matcher, handler) {
+            this.rootNode.findChildren(matcher, handler, 'isRecursive');
+        },
+
+        /**
+         * Unmark all nodes
+         */
+        unmarkAll : function() {
+            this.rootNode.findChildren(function(node) {
+                return true;
+            }, function(node) {
+                node.unmark();
+            }, 'isRecursive');
+        }
+    };
+
+    
+    /* ---------- Public methods ---------- */
+    return {
+        /**
+         * Creates new tree by data and appends it to the DOM-element
+         * 
+         * @param jsonObj {Object | Array} - json-data
+         * @param domEl {DOMElement} - the wrapper element
+         * @returns {Tree}
+         */
+        create : function(jsonObj, domEl) {
+            return new Tree(jsonObj, domEl);
+        }
+    };
+})();

+ 1022 - 0
app/src/App.vue

@@ -0,0 +1,1022 @@
+<template>
+  <div class="container">
+    <div class="canvasBox" ref="box"></div>
+    <div class="toolbar" v-if="!readonly">
+      <el-radio-group v-model="currentType" @change="onCurrentTypeChange">
+        <el-radio-button label="selection">选择</el-radio-button>
+        <el-radio-button label="rectangle">矩形</el-radio-button>
+        <el-radio-button label="diamond">菱形</el-radio-button>
+        <el-radio-button label="triangle">三角形</el-radio-button>
+        <el-radio-button label="circle">圆形</el-radio-button>
+        <el-radio-button label="line">线段</el-radio-button>
+        <el-radio-button label="arrow">箭头</el-radio-button>
+        <el-radio-button label="freedraw">自由画笔</el-radio-button>
+        <el-radio-button label="text">文字</el-radio-button>
+        <el-radio-button label="image">图片</el-radio-button>
+      </el-radio-group>
+    </div>
+    <Transition>
+      <div class="sidebar" v-show="activeElement || hasSelectedElements">
+        <div class="elementStyle">
+          <!-- 描边 -->
+          <div class="styleBlock" v-if="
+            !['text', 'image'].includes(activeElement?.type) ||
+            hasSelectedElements
+          ">
+            <div class="styleBlockTitle">描边</div>
+            <div class="styleBlockContent">
+              <ColorPicker type="stroke" :value="activeElement?.style.strokeStyle"
+                @change="updateStyle('strokeStyle', $event)"></ColorPicker>
+            </div>
+          </div>
+          <!-- 填充 -->
+          <div class="styleBlock" v-if="
+            !['image', 'line', 'arrow', 'freedraw'].includes(
+              activeElement?.type
+            ) || hasSelectedElements
+          ">
+            <div class="styleBlockTitle">填充</div>
+            <div class="styleBlockContent">
+              <ColorPicker type="fill" :value="activeElement?.style.fillStyle"
+                @change="updateStyle('fillStyle', $event)"></ColorPicker>
+            </div>
+          </div>
+          <!-- 字体 -->
+          <div class="styleBlock" v-if="['text'].includes(activeElement?.type) || hasSelectedElements">
+            <div class="styleBlockTitle">字体</div>
+            <div class="styleBlockContent">
+              <el-select size="mini" v-model="fontFamily" placeholder="字体" @change="updateStyle('fontFamily', $event)">
+                <el-option v-for="item in fontFamilyList" :key="item.value" :label="item.name" :value="item.value"
+                  :style="{ fontFamily: item.value }">
+                </el-option>
+              </el-select>
+            </div>
+          </div>
+          <!-- 字号 -->
+          <div class="styleBlock" v-if="['text'].includes(activeElement?.type) || hasSelectedElements">
+            <div class="styleBlockTitle">字号</div>
+            <div class="styleBlockContent">
+              <el-select size="mini" v-model="fontSize" placeholder="字号" @change="updateStyle('fontSize', $event)">
+                <el-option v-for="item in fontSizeList" :key="item.value" :label="item.name" :value="item.value"
+                  :style="{ fontSize: item.value }">
+                </el-option>
+              </el-select>
+            </div>
+          </div>
+          <!-- 描边宽度 -->
+          <div class="styleBlock" v-if="
+            !['image', 'text'].includes(activeElement?.type) ||
+            hasSelectedElements
+          ">
+            <div class="styleBlockTitle">描边宽度</div>
+            <div class="styleBlockContent">
+              <el-radio-group v-model="lineWidth" @change="updateStyle('lineWidth', $event)">
+                <el-radio-button label="small">
+                  <div class="lineWidthItem small">
+                    <div class="bar"></div>
+                  </div>
+                </el-radio-button>
+                <el-radio-button label="middle">
+                  <div class="lineWidthItem middle">
+                    <div class="bar"></div>
+                  </div>
+                </el-radio-button>
+                <el-radio-button label="large">
+                  <div class="lineWidthItem large">
+                    <div class="bar"></div>
+                  </div>
+                </el-radio-button>
+              </el-radio-group>
+            </div>
+          </div>
+          <!-- 边框样式 -->
+          <div class="styleBlock" v-if="
+            !['freedraw', 'image', 'text'].includes(activeElement?.type) ||
+            hasSelectedElements
+          ">
+            <div class="styleBlockTitle">边框样式</div>
+            <div class="styleBlockContent">
+              <el-radio-group v-model="lineDash" @change="updateStyle('lineDash', $event)">
+                <el-radio-button :label="0">
+                  <div>实线</div>
+                </el-radio-button>
+                <el-radio-button :label="5">
+                  <div>大虚线</div>
+                </el-radio-button>
+                <el-radio-button :label="2">
+                  <div>小虚线</div>
+                </el-radio-button>
+              </el-radio-group>
+            </div>
+          </div>
+          <!-- 透明度 -->
+          <div class="styleBlock">
+            <div class="styleBlockTitle">透明度</div>
+            <div class="styleBlockContent">
+              <el-slider v-model="globalAlpha" :min="0" :max="1" :step="0.1"
+                @change="updateStyle('globalAlpha', $event)" />
+            </div>
+          </div>
+          <!-- 角度 -->
+          <div class="styleBlock" v-if="!hasSelectedElements">
+            <div class="styleBlockTitle">角度</div>
+            <div class="styleBlockContent">
+              <el-slider v-model="rotate" :min="0" :max="360" :step="1" @input="onRotateChange" />
+              <el-input-number style="width: 80px; margin-left: 20px" :controls="false" v-model="rotate" :min="0"
+                :max="360" @focus="onInputNumberFocus" @blur="onInputNumberBlur" @change="onRotateChange" />
+            </div>
+          </div>
+          <!-- 操作 -->
+          <div class="styleBlock">
+            <div class="styleBlockTitle">操作</div>
+            <div class="styleBlockContent">
+              <el-button type="danger" :icon="Delete" circle @click="deleteElement" />
+              <el-button type="primary" :icon="CopyDocument" circle @click="copyElement" />
+            </div>
+          </div>
+        </div>
+      </div>
+    </Transition>
+    <div class="footerLeft" @click.stop>
+      <!-- 缩放 -->
+      <div class="blockBox">
+        <el-tooltip effect="light" content="缩小" placement="top">
+          <el-button :icon="ZoomOut" circle @click="zoomOut" />
+        </el-tooltip>
+        <el-tooltip effect="light" content="重置缩放" placement="top">
+          <span class="zoom" @click="resetZoom">{{ currentZoom }}%</span>
+        </el-tooltip>
+        <el-tooltip effect="light" content="放大" placement="top">
+          <el-button :icon="ZoomIn" circle @click="zoomIn" />
+        </el-tooltip>
+      </div>
+      <!-- 前进回退 -->
+      <div class="blockBox" v-if="!readonly">
+        <el-tooltip effect="light" content="回退" placement="top">
+          <el-button :icon="RefreshLeft" circle :disabled="!canUndo" @click="undo" />
+        </el-tooltip>
+        <el-tooltip effect="light" content="前进" placement="top">
+          <el-button :icon="RefreshRight" circle :disabled="!canRedo" @click="redo" />
+        </el-tooltip>
+      </div>
+      <!-- 滚动 -->
+      <div class="blockBox">
+        <el-tooltip effect="light" content="滚动至中心" placement="top">
+          <el-button @click="scrollToCenter">X:{{ scroll.x }} Y:{{ scroll.y }}
+          </el-button>
+        </el-tooltip>
+      </div>
+      <!-- 橡皮擦、显示网格、清空 -->
+      <div class="blockBox">
+        <!-- 橡皮擦 -->
+        <el-tooltip effect="light" :content="currentType === 'eraser' ? '关闭橡皮擦' : '橡皮擦'" placement="top">
+          <el-button v-if="!readonly" :icon="Remove" circle :type="currentType === 'eraser' ? 'primary' : null"
+            @click="toggleEraser" />
+        </el-tooltip>
+        <!-- 网格 -->
+        <el-tooltip effect="light" :content="showGrid ? '隐藏网格' : '显示网格'" placement="top">
+          <el-button :icon="Grid" circle :type="showGrid ? 'primary' : null" @click="toggleGrid" />
+        </el-tooltip>
+        <!-- 只读、编辑模式切换 -->
+        <el-tooltip effect="light" :content="readonly ? '切换到编辑模式' : '切换到只读模式'" placement="top">
+          <el-button :icon="readonly ? View : Edit" circle @click="toggleMode" />
+        </el-tooltip>
+        <!-- 清空 -->
+        <el-tooltip effect="light" content="清空" placement="top">
+          <el-button v-if="!readonly" :icon="Delete" circle @click="empty" />
+        </el-tooltip>
+      </div>
+      <!-- 导入导出 -->
+      <div class="blockBox">
+        <el-tooltip effect="light" content="从json文件导入" placement="top">
+          <el-button v-if="!readonly" :icon="Upload" circle style="margin-right: 10px" @click="importFromJson" />
+        </el-tooltip>
+        <el-tooltip effect="light" content="保存" placement="top">
+          <el-button v-if="!readonly" :icon="Check" circle style="margin-right: 10px" @click="saveJson = !saveJson">
+          </el-button>
+        </el-tooltip>
+        <el-tooltip effect="light" content="文件列表" placement="top">
+          <el-button v-if="!readonly" :icon="List" circle style="margin-right: 10px" @click="filelist = !filelist">
+          </el-button>
+        </el-tooltip>
+        <el-tooltip effect="light" content="模板库" placement="top">
+          <el-button :icon="FolderOpened" circle style="margin-right: 10px"
+            @click="InportJsonDialogVisible = !InportJsonDialogVisible" />
+        </el-tooltip>
+        <el-dropdown @command="handleExportCommand">
+          <span class="el-dropdown-link">
+            <el-button :icon="Download" circle />
+          </span>
+          <template #dropdown>
+            <el-dropdown-menu>
+              <el-dropdown-item command="png">导出为图片</el-dropdown-item>
+              <el-dropdown-item command="json">导出为json</el-dropdown-item>
+            </el-dropdown-menu>
+          </template>
+        </el-dropdown>
+      </div>
+      <!-- 背景 -->
+      <div class="blockBox">
+        <ColorPicker style="width: 280px" type="background" :value="backgroundColor" :showEmptySelect="true"
+          placement="top" name="背景颜色" @change="setBackgroundColor"></ColorPicker>
+      </div>
+      <!-- 帮助 -->
+      <div class="blockBox">
+        <el-tooltip effect="light" content="帮助" placement="top">
+          <el-button :icon="QuestionFilled" circle style="margin-right: 10px"
+            @click="helpDialogVisible = !helpDialogVisible" />
+        </el-tooltip>
+      </div>
+      <!-- 模板库 -->
+      <!-- <div class="">
+        <el-tooltip effect="light" content="模板库" placement="top">
+          <el-button :icon="QuestionFilled" circle style="margin-right: 10px"
+            @click="InportJsonDialogVisible = !InportJsonDialogVisible" />
+        </el-tooltip>
+      </div> -->
+    </div>
+    <!-- 导出图片弹窗 -->
+    <el-dialog v-model="exportImageDialogVisible" title="导出为图片" :width="800">
+      <div class="exportImageContainer">
+        <div class="imagePreviewBox">
+          <img :src="exportImageUrl" alt="" />
+        </div>
+        <div class="handleBox">
+          <el-checkbox v-model="exportOnlySelected" label="仅导出被选中" size="large" @change="reRenderExportImage"
+            style="margin-right: 10px" />
+          <el-checkbox v-model="exportRenderBackground" label="背景" size="large" @change="reRenderExportImage"
+            style="margin-right: 10px" />
+          <el-input v-model="exportFileName" style="width: 150px; margin-right: 10px"></el-input>
+          <el-input-number v-model="exportImagePaddingX" :min="10" :max="100" :step="5" controls-position="right"
+            @change="reRenderExportImage" style="margin-right: 10px" />
+          <el-input-number v-model="exportImagePaddingY" :min="10" :max="100" :step="5" controls-position="right"
+            @change="reRenderExportImage" style="margin-right: 10px" />
+          <el-button type="primary" @click="downloadExportImage">下载</el-button>
+        </div>
+      </div>
+    </el-dialog>
+    <!-- 导出json弹窗 -->
+    <el-dialog v-model="exportJsonDialogVisible" title="导出为json" :width="800">
+      <div class="exportJsonContainer">
+        <div class="jsonPreviewBox" ref="jsonPreviewBox"></div>
+        <div class="handleBox">
+          <el-input v-model="exportFileName" style="width: 150px; margin-right: 10px"></el-input>
+          <el-button type="primary" @click="downloadExportJson">下载</el-button>
+        </div>
+      </div>
+    </el-dialog>
+    <!-- 模板库弹窗 -->
+    <el-dialog v-model="InportJsonDialogVisible" title="模板库" :width="800">
+      <el-image style="width: 100px; height: 100px" :src="url" @click="importFromJsonServer" />
+    </el-dialog>
+    <!-- 保存 -->
+    <el-dialog v-model="saveJson" title="保存文件" :width="800">
+      <el-switch v-model="filetype" size="small" active-text="文件" inactive-text="模板" />
+      <el-input v-model="saveFileName" style="width: 150px; margin-right: 10px" placeholder="请输入文件名"
+        clearable></el-input>
+      <el-button type="primary" @click="save">保存文件</el-button>
+    </el-dialog>
+    <!-- 文件列表 -->
+    <el-dialog v-model="filelist" title="文件列表" :width="800">
+      <el-switch v-model="filetype" size="small" active-text="文件" inactive-text="模板" @click="templateItmes" />
+      <div class="demo-image">
+        <div v-for="fit in fits" :key="fit" class="block">
+          <span class="demonstration">{{ fit }}</span>
+          <el-image style="width: 100px; height: 100px" :src="url" @click="templateItmes" />
+        </div>
+      </div>
+    </el-dialog>
+    <!-- 帮助弹窗 -->
+    <el-dialog v-model="helpDialogVisible" title="帮助" :width="500">
+      <div class="helpDialogContent">
+        <h2>tips</h2>
+        <p>移动画布:按住空格键进行拖动</p>
+        <h2>快捷键</h2>
+        <el-table :data="shortcutKeyList">
+          <el-table-column property="name" label="操作" />
+          <el-table-column property="value" label="快捷键" />
+        </el-table>
+      </div>
+    </el-dialog>
+    <!-- 右键菜单 -->
+    <Contextmenu v-if="appInstance" :app="appInstance"></Contextmenu>
+  </div>
+</template>
+
+<script setup>
+import { onMounted, ref, watch, toRaw, nextTick, computed, reactive } from 'vue'
+import TinyWhiteboard from 'tiny-whiteboard'
+import ColorPicker from './components/ColorPicker.vue'
+import {
+  Delete,
+  CopyDocument,
+  ZoomIn,
+  ZoomOut,
+  Remove,
+  RefreshLeft,
+  RefreshRight,
+  Download,
+  FolderOpened,
+  Upload,
+  CaretTop,
+  CaretBottom,
+  Minus,
+  Grid,
+  View,
+  Check,
+  Edit,
+  List,
+  QuestionFilled
+} from '@element-plus/icons-vue'
+import Contextmenu from './components/Contextmenu.vue'
+import { fontFamilyList, fontSizeList } from './constants'
+import { ElMessage } from 'element-plus'
+// 当前操作类型
+const currentType = ref('selection')
+const saveFileName = ref('')
+
+// dom节点
+const box = ref(null)
+
+// 应用实例
+let app = null
+const appInstance = ref(null)
+// 当前激活的元素
+const activeElement = ref(null)
+// 当前多选的元素
+const selectedElements = ref([])
+const hasSelectedElements = computed(() => {
+  return selectedElements.value.length > 0
+})
+// 描边宽度
+const lineWidth = ref('small')
+// 字体
+const fontFamily = ref('微软雅黑, Microsoft YaHei')
+// 字号
+const fontSize = ref(18)
+// 边框样式
+const lineDash = ref(0)
+// 透明度
+const globalAlpha = ref(1)
+// 角度
+const rotate = ref(0)
+//文件类型
+const filetype = ref(true)
+// 当前缩放
+const currentZoom = ref(100)
+// 缩放允许前进后退
+const canUndo = ref(false)
+const canRedo = ref(false)
+// 图片导出弹窗
+const exportImageDialogVisible = ref(false)
+const exportImageUrl = ref('')
+const exportOnlySelected = ref(false)
+const exportRenderBackground = ref(true)
+const exportFileName = ref('未命名')
+const exportImagePaddingX = ref(10)
+const exportImagePaddingY = ref(10)
+// json导出弹窗
+const exportJsonDialogVisible = ref(false)
+const InportJsonDialogVisible = ref(false)
+const saveJson = ref(false)
+const filelist = ref(false)
+const exportJsonData = ref('')
+const tree = ref(null)
+const jsonPreviewBox = ref(null)
+// 背景颜色
+const backgroundColor = ref('')
+// 当前滚动距离
+const scroll = reactive({
+  x: 0,
+  y: 0
+})
+// 切换显示网格
+const showGrid = ref(false)
+// 模式切换
+const readonly = ref(false)
+// 帮助弹窗
+const helpDialogVisible = ref(false)
+const shortcutKeyList = reactive([
+  {
+    name: '全部选中',
+    value: 'Control + a'
+  },
+  {
+    name: '删除',
+    value: 'Del 或 Backspace'
+  },
+  {
+    name: '复制',
+    value: 'Control + c'
+  },
+  {
+    name: '粘贴',
+    value: 'Control + v'
+  },
+  {
+    name: '放大',
+    value: 'Control + +'
+  },
+  {
+    name: '缩小',
+    value: 'Control + -'
+  },
+  {
+    name: '重置缩放',
+    value: 'Control + 0'
+  },
+  {
+    name: '缩放以适应所有元素',
+    value: 'Shift + 1'
+  },
+  {
+    name: '撤销',
+    value: 'Control + z'
+  },
+  {
+    name: '重做',
+    value: 'Control + y'
+  },
+  {
+    name: '显示隐藏网格',
+    value: "Control + '"
+  }
+])
+
+// 通知app更当前类型
+watch(currentType, () => {
+  app.updateCurrentType(currentType.value)
+})
+
+// 元素角度变化
+const onElementRotateChange = elementRotate => {
+  rotate.value = elementRotate
+}
+
+// 修改元素角度
+const onRotateChange = rotate => {
+  app.updateActiveElementRotate(rotate)
+}
+
+// 数字输入框聚焦事件
+const onInputNumberFocus = () => {
+  // 解绑快捷键按键事件,防止冲突
+  app.keyCommand.unBindEvent()
+}
+
+// 数字输入框失焦事件
+const onInputNumberBlur = () => {
+  // 重新绑定快捷键按键事件
+  app.keyCommand.bindEvent()
+}
+
+// 更新样式
+const updateStyle = (key, value) => {
+  app.setCurrentElementsStyle({
+    [key]: value
+  })
+}
+
+// 类型变化
+const onCurrentTypeChange = () => {
+  // 清除激活项
+  app.cancelActiveElement()
+}
+
+// 删除元素
+const deleteElement = () => {
+  app.deleteCurrentElements()
+}
+
+// 复制元素
+const copyElement = () => {
+  app.copyPasteCurrentElements()
+}
+
+// 放大
+const zoomIn = () => {
+  app.zoomIn()
+}
+
+// 缩小
+const zoomOut = () => {
+  app.zoomOut()
+}
+
+// 恢复初始缩放
+const resetZoom = () => {
+  app.setZoom(1)
+}
+
+// 橡皮擦
+const toggleEraser = () => {
+  currentType.value = currentType.value === 'eraser' ? 'selection' : 'eraser'
+}
+
+// 回退
+const undo = () => {
+  app.undo()
+}
+
+// 前进
+const redo = () => {
+  app.redo()
+}
+
+// 清空
+const empty = () => {
+  app.empty()
+}
+
+// 从本地导入模板
+const importFromJson = () => {
+  let el = document.createElement('input')
+  el.type = 'file'
+  el.accept = 'application/json'
+  el.addEventListener('input', () => {
+    let reader = new FileReader()
+    reader.onload = () => {
+      el.value = null
+      if (reader.result) {
+        app.setData(JSON.parse(reader.result))
+      }
+    }
+    reader.readAsText(el.files[0])
+  })
+  el.click()
+}
+//从服务器获取模板
+const importFromJsonServer = async () => {
+  try {
+    const response = await this.$axios.get('http://localhost:8080/api/template', {
+      headers: {
+        'Content-Type': 'application/json'
+      },
+      params: {
+        name: "文件测试"
+      }
+    })
+    if (!response.ok) throw new Error('Failed to fetch JSON data')
+    const jsonData = await response.json()
+    app.setData(jsonData.data)
+  } catch (error) {
+    console.log(error)
+  }
+}
+
+// 导出
+const handleExportCommand = type => {
+  if (type === 'png') {
+    exportImageUrl.value = app.exportImage({
+      renderBg: exportRenderBackground.value,
+      paddingX: exportImagePaddingX.value,
+      paddingY: exportImagePaddingY.value,
+      onlySelected: exportOnlySelected.value
+    })
+    exportImageDialogVisible.value = true
+  } else if (type === 'json') {
+    exportJsonData.value = app.exportJson()
+    exportJsonDialogVisible.value = true
+    nextTick(() => {
+      if (!tree.value) {
+        tree.value = jsonTree.create(exportJsonData.value, jsonPreviewBox.value)
+        console.log(tree.value)
+      } else {
+        tree.value.loadData(exportJsonData.value)
+        console.log(tree.value)
+      }
+    })
+  }
+}
+//保存为json文件
+const save = async () => {
+  try {
+    const response = await this.$axios.post('http://localhost:8080/api/savefile', {
+      type: filetype.value, // true:代表普通保存,false:代表模板保存
+      name: saveFileName.value,
+      data: app.exportJson()
+    }, {
+      headers: {
+        'Content-Type': 'application/json'
+        // 'Content-Type': 'application/x-www-form-urlencoded',
+      },
+    })
+    if (!response.ok) throw new Error('Failed to fetch JSON data')
+    ElMessage.success('保存成功')
+  } catch (error) {
+    console.log(error)
+  }
+}
+//导入
+const fits = ['fill', 'contain', 'cover', 'none', 'scale-down']
+const url =
+  'https://fuss10.elemecdn.com/a/3f/3302e58f9a181d2509f3dc0fa68b0jpeg.jpeg'
+
+let items = ['']
+const templateItmes = async () => {
+  const requestBody = {
+    type: filetype.value,
+  }
+  try {
+    const response = await this.$axios.post('http://localhost:8080/api/template',{
+      type: requestBody,
+    }, {
+      headers: {
+        'Content-Type': 'application/json'
+        // 'Content-Type': 'application/x-www-form-urlencoded',
+      },
+    })
+    if (response.ok) {
+      const jsonData = await response.json()
+      items = Object.keys(jsonData.data)
+      Object.values(jsonData.data)
+      const keyValuePairs = Object.entries(jsonData.data).map(([key, value]) => ({ key, value }));
+      // console.log(keyValuePairs)
+      for (let i = 0; i < keyValuePairs.length; i++) {
+        const key = keyValuePairs[i].key;
+        const value = keyValuePairs[i].value;
+        // console.log(key, value);
+        // Object.prototype.hasOwnProperty.call()
+        const json = JSON.parse(value).data.elements
+        if (json.length == 2) {
+          console.log(json[0].url)
+        } else {
+          console.log(json)
+        }
+
+      }
+      filelist.value = true
+    }
+    ElMessage.success('获取成功')
+  } catch (error) {
+    ElMessage.error('获取模板失败')
+    console.log(error)
+  }
+}
+
+// 重新生成导出图片
+const reRenderExportImage = () => {
+  exportImageUrl.value = app.exportImage({
+    renderBg: exportRenderBackground.value,
+    paddingX: exportImagePaddingX.value,
+    paddingY: exportImagePaddingY.value,
+    onlySelected: exportOnlySelected.value
+  })
+}
+
+// 下载导出的图片
+const downloadExportImage = () => {
+  TinyWhiteboard.utils.downloadFile(
+    exportImageUrl.value,
+    exportFileName.value + '.png'
+  )
+}
+
+// 下载导出的json
+const downloadExportJson = () => {
+  let str = JSON.stringify(exportJsonData.value, null, 4)
+  let blob = new Blob([str])
+  TinyWhiteboard.utils.downloadFile(
+    URL.createObjectURL(blob),
+    exportFileName.value + '.json'
+  )
+}
+
+// 更新背景颜色
+const setBackgroundColor = value => {
+  app.setBackgroundColor(value)
+}
+
+// 滚动至中心
+const scrollToCenter = () => {
+  app.scrollToCenter()
+}
+
+// 切换显示网格
+const toggleGrid = () => {
+  if (showGrid.value) {
+    showGrid.value = false
+    app.hideGrid()
+  } else {
+    showGrid.value = true
+    app.showGrid()
+  }
+}
+
+// 模式切换
+const toggleMode = () => {
+  if (readonly.value) {
+    readonly.value = false
+    app.setEditMode()
+  } else {
+    readonly.value = true
+    app.setReadonlyMode()
+  }
+}
+
+// dom元素挂载完成
+onMounted(() => {
+  // 创建实例
+  app = new TinyWhiteboard({
+    container: box.value,
+    drawType: currentType.value,
+    state: {
+      // backgroundColor: '#121212',
+      // strokeStyle: '#fff',
+      // fontFamily: '楷体, 楷体_GB2312, SimKai, STKaiti',
+      // dragStrokeStyle: '#999'
+    }
+  })
+  let storeData = localStorage.getItem('TINY_WHITEBOARD_DATA')
+  if (storeData) {
+    storeData = JSON.parse(storeData)
+      ;[
+        ['backgroundColor', ''],
+        ['strokeStyle', '#000000'],
+        ['fontFamily', '微软雅黑, Microsoft YaHei'],
+        ['dragStrokeStyle', '#666'],
+        ['fillStyle', 'transparent'],
+        ['fontSize', 18]
+      ].forEach(item => {
+        if (storeData.state[item[0]] === undefined) {
+          storeData.state[item[0]] = item[1]
+        }
+      })
+    currentZoom.value = parseInt(storeData.state.scale * 100)
+    scroll.x = parseInt(storeData.state.scrollX)
+    scroll.y = parseInt(storeData.state.scrollY)
+    showGrid.value = storeData.state.showGrid
+    readonly.value = storeData.state.readonly
+    app.setData(storeData)
+  }
+  // 监听app内部修改类型事件
+  app.on('currentTypeChange', type => {
+    currentType.value = type
+  })
+  // 监听元素激活事件
+  app.on('activeElementChange', element => {
+    if (activeElement.value) {
+      activeElement.value.off('elementRotateChange', onElementRotateChange)
+    }
+    activeElement.value = element
+    if (element) {
+      let { style, rotate: elementRotate } = element
+      lineWidth.value = style.lineWidth
+      fontFamily.value = style.fontFamily
+      fontSize.value = style.fontSize
+      lineDash.value = style.lineDash
+      globalAlpha.value = style.globalAlpha
+      rotate.value = elementRotate
+      element.on('elementRotateChange', onElementRotateChange)
+    }
+  })
+  // 元素多选变化
+  app.on('multiSelectChange', elements => {
+    selectedElements.value = elements
+  })
+  // 缩放变化
+  app.on('zoomChange', scale => {
+    currentZoom.value = parseInt(scale * 100)
+  })
+  // 监听前进后退事件
+  app.on('shuttle', (index, length) => {
+    canUndo.value = index > 0
+    canRedo.value = index < length - 1
+  })
+  // 监听数据变化
+  app.on('change', data => {
+    showGrid.value = data.state.showGrid
+    localStorage.setItem('TINY_WHITEBOARD_DATA', JSON.stringify(data))
+  })
+  // 监听滚动变化
+  app.on('scrollChange', (x, y) => {
+    scroll.y = parseInt(y)
+    scroll.x = parseInt(x)
+  })
+  appInstance.value = app
+  // 窗口尺寸变化
+  let resizeTimer = null
+  window.addEventListener('resize', () => {
+    clearTimeout(resizeTimer)
+    resizeTimer = setTimeout(() => {
+      app.resize()
+    }, 300)
+  })
+})
+</script>
+
+<style lang="less">
+ul,
+ol {
+  list-style: none;
+}
+
+.v-enter-active,
+.v-leave-active {
+  transition: all 0.5s ease;
+}
+
+.v-enter-from,
+.v-leave-to {
+  opacity: 0;
+  transform: translateX(-300px);
+}
+</style>
+<style lang="less" scoped>
+.container {
+  position: fixed;
+  left: 0;
+  top: 0;
+  width: 100%;
+  height: 100%;
+
+  .toolbar {
+    position: absolute;
+    left: 50%;
+    top: 10px;
+    transform: translateX(-50%);
+    z-index: 2;
+    display: flex;
+    justify-content: center;
+  }
+
+  .canvasBox {
+    position: absolute;
+    left: 50%;
+    top: 50%;
+    width: 100%;
+    height: 100%;
+    transform: translate(-50%, -50%);
+    background-color: #fff;
+  }
+
+  .sidebar {
+    position: absolute;
+    left: 10px;
+    top: 10px;
+    width: 250px;
+    background-color: #fff;
+
+    .elementStyle {
+      padding: 10px;
+      box-shadow: 0 1px 4px rgba(0, 0, 0, 0.25);
+      border-radius: 4px;
+
+      .styleBlock {
+        margin-bottom: 10px;
+
+        .styleBlockTitle {
+          color: #343a40;
+          font-size: 14px;
+          margin-bottom: 10px;
+        }
+
+        .styleBlockContent {
+          display: flex;
+
+          .lineWidthItem {
+            display: flex;
+            width: 30px;
+            height: 10px;
+            align-items: center;
+
+            .bar {
+              width: 100%;
+              background-color: #212529;
+            }
+
+            &.small {
+              .bar {
+                height: 2px;
+              }
+            }
+
+            &.middle {
+              .bar {
+                height: 4px;
+              }
+            }
+
+            &.large {
+              .bar {
+                height: 6px;
+              }
+            }
+          }
+
+          /deep/ .el-radio-group {
+            .el-radio-button {
+              &.is-active {
+                .lineWidthItem {
+                  .bar {
+                    background-color: #fff;
+                  }
+                }
+              }
+            }
+          }
+        }
+      }
+    }
+  }
+
+  .footerLeft {
+    position: absolute;
+    left: 10px;
+    bottom: 10px;
+    height: 40px;
+    display: flex;
+    align-items: center;
+
+    .blockBox {
+      height: 100%;
+      display: flex;
+      align-items: center;
+      padding: 0 10px;
+
+      .zoom {
+        width: 40px;
+        margin: 0 10px;
+        user-select: none;
+        color: #606266;
+        cursor: pointer;
+        height: 32px;
+        display: flex;
+        align-items: center;
+        background-color: #fff;
+        border-radius: 5px;
+        padding: 0 5px;
+        justify-content: center;
+      }
+    }
+  }
+}
+
+.exportImageContainer {
+  .imagePreviewBox {
+    height: 400px;
+    background: url('data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAAAMUlEQVQ4T2NkYGAQYcAP3uCTZhw1gGGYhAGBZIA/nYDCgBDAm9BGDWAAJyRCgLaBCAAgXwixzAS0pgAAAABJRU5ErkJggg==') 0;
+    padding: 10px;
+
+    img {
+      width: 100%;
+      height: 100%;
+      object-fit: scale-down;
+    }
+  }
+
+  .handleBox {
+    display: flex;
+    align-items: center;
+    height: 50px;
+    justify-content: center;
+  }
+}
+
+.exportJsonContainer {
+  .jsonPreviewBox {
+    height: 400px;
+    overflow: auto;
+    background-color: #f5f5f5;
+    font-size: 14px;
+    color: #000;
+
+    /deep/ .jsontree_tree {
+      font-family: 'Trebuchet MS', Arial, sans-serif !important;
+    }
+  }
+
+  .handleBox {
+    display: flex;
+    align-items: center;
+    height: 50px;
+    justify-content: center;
+  }
+}
+
+.helpDialogContent {
+  height: 500px;
+  overflow: auto;
+}
+
+.demo-image .block {
+  padding: 30px 0;
+  text-align: center;
+  border-right: solid 1px var(--el-border-color);
+  display: inline-block;
+  width: 20%;
+  box-sizing: border-box;
+  vertical-align: top;
+}
+
+.demo-image .block:last-child {
+  border-right: none;
+}
+
+.demo-image .demonstration {
+  display: block;
+  color: var(--el-text-color-secondary);
+  font-size: 14px;
+  margin-bottom: 20px;
+}
+</style>

+ 132 - 0
app/src/components/ColorPicker.vue

@@ -0,0 +1,132 @@
+<template>
+  <div class="colorPickerContainer">
+    <div class="content">
+      <el-popover
+        :placement="placement"
+        :width="200"
+        trigger="click"
+        :disabled="colorList.length <= 0"
+      >
+        <template #reference>
+          <div class="colorPreview" :style="{ backgroundColor: color }"></div>
+        </template>
+        <div class="colorList">
+          <div
+            class="colorItem"
+            v-for="item in colorList"
+            :key="item"
+            :style="{ backgroundColor: item }"
+            @click="color = item"
+          >
+            <span v-if="!item">无</span>
+            <span v-if="item === 'transparent'">透明</span>
+          </div>
+        </div>
+      </el-popover>
+      <el-input v-model="color">
+        <template #prepend>{{ name }}</template>
+      </el-input>
+    </div>
+  </div>
+</template>
+
+<script setup>
+import { ref, computed, watch } from 'vue'
+import {
+  strokeColorList,
+  fillColorList,
+  backgroundColorList
+} from '../constants'
+
+const props = defineProps({
+  value: {
+    type: String,
+    default: ''
+  },
+  type: {
+    type: String,
+    default: ''
+  },
+  name: {
+    type: String,
+    default: '颜色'
+  },
+  placement: {
+    type: String,
+    default: 'bottom'
+  },
+  showEmptySelect: {
+    type: Boolean,
+    default: false
+  }
+})
+
+const emits = defineEmits(['change'])
+
+const color = ref(props.value)
+watch(
+  () => {
+    return props.value
+  },
+  val => {
+    color.value = val
+  }
+)
+const colorList = computed(() => {
+  let list = props.showEmptySelect ? [''] : []
+  switch (props.type) {
+    case 'stroke':
+      list.push(...strokeColorList)
+      break
+    case 'fill':
+      list.push(...fillColorList)
+      break
+    case 'background':
+      list.push(...backgroundColorList)
+      break
+    default:
+  }
+  return list
+})
+watch(color, () => {
+  emits('change', color.value)
+})
+</script>
+
+<style lang="less" scoped>
+.colorPickerContainer {
+  .content {
+    display: flex;
+    align-items: center;
+
+    .colorPreview {
+      width: 30px;
+      height: 30px;
+      border: 1px solid #dee2e6;
+      border-radius: 5px;
+      flex-shrink: 0;
+      margin-right: 10px;
+      overflow: hidden;
+      cursor: pointer;
+    }
+  }
+}
+
+.colorList {
+  display: grid;
+  grid-template-columns: repeat(5, auto);
+  grid-gap: 5px;
+  .colorItem {
+    width: 30px;
+    height: 30px;
+    cursor: pointer;
+    border-radius: 4px;
+    border: 1px solid #ddd;
+    display: flex;
+    justify-content: center;
+    align-items: center;
+    font-size: 12px;
+    color: #909399;
+  }
+}
+</style>

+ 218 - 0
app/src/components/Contextmenu.vue

@@ -0,0 +1,218 @@
+<template>
+  <div
+    class="contextmenuContainer"
+    v-if="isShow"
+    :style="{ left: left + 'px', top: top + 'px' }"
+  >
+    <template v-if="isHasActiveElements">
+      <div
+        class="item"
+        :class="{ disabled: !canMoveLevel }"
+        @click="exec('moveUp')"
+      >
+        上移一层
+      </div>
+      <div
+        class="item"
+        :class="{ disabled: !canMoveLevel }"
+        @click="exec('moveDown')"
+      >
+        下移一层
+      </div>
+      <div
+        class="item"
+        :class="{ disabled: !canMoveLevel }"
+        @click="exec('moveTop')"
+      >
+        置于顶层
+      </div>
+      <div
+        class="item"
+        :class="{ disabled: !canMoveLevel }"
+        @click="exec('moveBottom')"
+      >
+        置于底层
+      </div>
+      <div class="splitLine"></div>
+      <div class="item danger" @click="exec('del')">删除</div>
+      <div class="item" @click="exec('copy')">复制</div>
+      <div
+        class="item"
+        :class="{ disabled: groupStatus === 'disabled' }"
+        @click="exec(groupStatus)"
+      >
+        {{ groupBtnText }}
+      </div>
+    </template>
+    <template v-else>
+      <div class="item" @click="exec('selectAll')">全部选中</div>
+      <div class="item" @click="exec('backToCenter')">回到中心</div>
+      <div class="item" @click="exec('fit')">显示全部</div>
+      <div class="item" @click="exec('resetZoom')">重置缩放</div>
+    </template>
+  </div>
+</template>
+
+<script setup>
+import { computed, ref } from 'vue'
+
+const props = defineProps({
+  app: {
+    type: Object
+  }
+})
+
+const isShow = ref(false)
+const left = ref(0)
+const top = ref(0)
+const isHasActiveElements = ref(false)
+const canMoveLevel = ref(false)
+const groupStatus = ref('disabled')
+const groupBtnText = computed(() => {
+  return {
+    disabled: '编组',
+    dogroup: '编组',
+    ungroup: '取消编组'
+  }[groupStatus.value]
+})
+
+const show = (e, activeElements) => {
+  isHasActiveElements.value = activeElements.length > 0
+  canMoveLevel.value = activeElements.length === 1
+  left.value = e.clientX + 10
+  top.value = e.clientY + 10
+  isShow.value = true
+  handleGroup(activeElements)
+}
+
+const handleGroup = activeElements => {
+  let isGroup = true
+  activeElements.forEach(item => {
+    if (!item.hasGroup()) {
+      isGroup = false
+    }
+  })
+  if (isGroup) {
+    groupStatus.value = 'ungroup'
+  } else if (activeElements.length > 1) {
+    groupStatus.value = 'dogroup'
+  }
+}
+
+const hide = () => {
+  isShow.value = false
+  left.value = 0
+  top.value = 0
+}
+
+props.app.on('contextmenu', show)
+
+document.body.addEventListener('click', hide)
+
+const exec = command => {
+  switch (command) {
+    case 'moveUp':
+      props.app.moveUpCurrentElement()
+      break
+    case 'moveDown':
+      props.app.moveDownCurrentElement()
+      break
+    case 'moveTop':
+      props.app.moveTopCurrentElement()
+      break
+    case 'moveBottom':
+      props.app.moveBottomCurrentElement()
+      break
+    case 'del':
+      props.app.deleteCurrentElements()
+      break
+    case 'copy':
+      props.app.copyPasteCurrentElements()
+      break
+    case 'selectAll':
+      props.app.selectAll()
+      break
+    case 'backToCenter':
+      props.app.scrollToCenter()
+      break
+    case 'fit':
+      props.app.fit()
+      break
+    case 'resetZoom':
+      props.app.setZoom(1)
+    case 'dogroup':
+      props.app.dogroup()
+      break
+    case 'ungroup':
+      props.app.ungroup()
+      break
+    default:
+      break
+  }
+}
+
+// onMousedown(e) {
+//       if (e.which !== 3) {
+//         return;
+//       }
+//       this.mosuedownX = e.clientX;
+//       this.mosuedownY = e.clientY;
+//       this.isMousedown = true;
+//     }
+//     onMouseup(e) {
+//       if (!this.isMousedown) {
+//         return;
+//       }
+//       this.isMousedown = false;
+//       if (
+//         Math.abs(this.mosuedownX - e.clientX) > 3 ||
+//         Math.abs(this.mosuedownY - e.clientY) > 3
+//       ) {
+//         this.hide();
+//         return;
+//       }
+//       this.show2(e);
+//     },
+</script>
+
+<style lang="less" scoped>
+.contextmenuContainer {
+  position: fixed;
+  width: 161px;
+  background: #fff;
+  box-shadow: 0 4px 12px 0 hsla(0, 0%, 69%, 0.5);
+  border-radius: 4px;
+  padding-top: 16px;
+  padding-bottom: 16px;
+  font-size: 14px;
+  font-family: PingFangSC-Regular, PingFang SC;
+  font-weight: 400;
+  color: #1a1a1a;
+
+  .splitLine {
+    height: 1px;
+    background-color: #f5f5f5;
+    margin: 5px 0;
+  }
+
+  .item {
+    height: 28px;
+    line-height: 28px;
+    padding-left: 16px;
+    cursor: pointer;
+    &.danger {
+      color: #f56c6c;
+    }
+    &:hover {
+      background: #f5f5f5;
+    }
+    &.disabled {
+      color: grey;
+      cursor: not-allowed;
+      &:hover {
+        background: #fff;
+      }
+    }
+  }
+}
+</style>

+ 120 - 0
app/src/constants.js

@@ -0,0 +1,120 @@
+// 描边颜色
+export const strokeColorList = [
+  '#000000',
+  '#343a40',
+  '#495057',
+  '#c92a2a',
+  '#a61e4d',
+  '#862e9c',
+  '#5f3dc4',
+  '#364fc7',
+  '#1864ab',
+  '#0b7285',
+  '#087f5b',
+  '#2b8a3e',
+  '#5c940d',
+  '#e67700',
+  '#d9480f'
+]
+
+// 填充颜色
+export const fillColorList = [
+  'transparent',
+  '#ced4da',
+  '#868e96',
+  '#fa5252',
+  '#e64980',
+  '#be4bdb',
+  '#7950f2',
+  '#4c6ef5',
+  '#228be6',
+  '#15aabf',
+  '#12b886',
+  '#40c057',
+  '#82c91e',
+  '#fab005',
+  '#fd7e14'
+]
+
+// 背景颜色
+export const backgroundColorList = [
+  'rgb(255, 255, 255)',
+  'rgb(248, 249, 250)',
+  'rgb(241, 243, 245)',
+  'rgb(255, 245, 245)',
+  'rgb(255, 240, 246)',
+  'rgb(248, 240, 252)',
+  'rgb(243, 240, 255)',
+  'rgb(237, 242, 255)',
+  'rgb(231, 245, 255)',
+  'rgb(227, 250, 252)',
+  'rgb(230, 252, 245)',
+  'rgb(235, 251, 238)',
+  'rgb(244, 252, 227)',
+  'rgb(255, 249, 219)',
+  'rgb(255, 244, 230)'
+]
+
+// 字体列表
+export const fontFamilyList = [
+  {
+    name: '微软雅黑',
+    value: '微软雅黑, Microsoft YaHei'
+  },
+  {
+    name: '宋体',
+    value: '宋体, SimSun, Songti SC'
+  },
+  {
+    name: '楷体',
+    value: '楷体, 楷体_GB2312, SimKai, STKaiti'
+  },
+  {
+    name: '黑体',
+    value: '黑体, SimHei, Heiti SC'
+  },
+  {
+    name: '隶书',
+    value: '隶书, SimLi'
+  },
+  {
+    name: 'Andale Mono',
+    value: 'andale mono'
+  },
+  {
+    name: 'Arial',
+    value: 'arial, helvetica, sans-serif'
+  },
+  {
+    name: 'arialBlack',
+    value: 'arial black, avant garde'
+  },
+  {
+    name: 'Comic Sans Ms',
+    value: 'comic sans ms'
+  },
+  {
+    name: 'Impact',
+    value: 'impact, chicago'
+  },
+  {
+    name: 'Times New Roman',
+    value: 'times new roman'
+  },
+  {
+    name: 'Sans-Serif',
+    value: 'sans-serif'
+  },
+  {
+    name: 'serif',
+    value: 'serif'
+  }
+]
+
+// 字号
+export const fontSizeList = [10, 12, 16, 18, 24, 32, 48].map(item => {
+  return {
+    name: item,
+    value: item
+  }
+})

+ 4 - 0
app/src/main.js

@@ -0,0 +1,4 @@
+import { createApp } from 'vue'
+import App from './App.vue'
+
+createApp(App).mount('#app')

+ 25 - 0
app/vite.config.js

@@ -0,0 +1,25 @@
+import { defineConfig } from 'vite'
+import vue from '@vitejs/plugin-vue'
+import AutoImport from 'unplugin-auto-import/vite'
+import Components from 'unplugin-vue-components/vite'
+import { ElementPlusResolver } from 'unplugin-vue-components/resolvers'
+
+// https://vitejs.dev/config/
+export default defineConfig({
+  base: '/tiny_whiteboard_demo/',
+  plugins: [
+    vue(),
+    AutoImport({
+      resolvers: [ElementPlusResolver()]
+    }),
+    Components({
+      resolvers: [ElementPlusResolver()]
+    })
+  ],
+  server: {
+    fs: {
+      // 可以为项目根目录的上一级提供服务
+      allow: ['..']
+    }
+  }
+})

BIN
assets/1.jpg


+ 6 - 0
package-lock.json

@@ -0,0 +1,6 @@
+{
+  "name": "tiny_whiteboard-main",
+  "lockfileVersion": 3,
+  "requires": true,
+  "packages": {}
+}

+ 4 - 0
tiny-whiteboard/.prettierignore

@@ -0,0 +1,4 @@
+dist
+node_modules
+package-lock.json
+package.json

+ 5 - 0
tiny-whiteboard/.prettierrc

@@ -0,0 +1,5 @@
+semi: false
+singleQuote: true
+printWidth: 80
+trailingComma: 'none'
+arrowParens: 'avoid'

+ 4515 - 0
tiny-whiteboard/dist/tiny-whiteboard.es.js

@@ -0,0 +1,4515 @@
+var __defProp = Object.defineProperty;
+var __defProps = Object.defineProperties;
+var __getOwnPropDescs = Object.getOwnPropertyDescriptors;
+var __getOwnPropSymbols = Object.getOwnPropertySymbols;
+var __hasOwnProp = Object.prototype.hasOwnProperty;
+var __propIsEnum = Object.prototype.propertyIsEnumerable;
+var __defNormalProp = (obj, key, value) => key in obj ? __defProp(obj, key, { enumerable: true, configurable: true, writable: true, value }) : obj[key] = value;
+var __spreadValues = (a, b) => {
+  for (var prop in b || (b = {}))
+    if (__hasOwnProp.call(b, prop))
+      __defNormalProp(a, prop, b[prop]);
+  if (__getOwnPropSymbols)
+    for (var prop of __getOwnPropSymbols(b)) {
+      if (__propIsEnum.call(b, prop))
+        __defNormalProp(a, prop, b[prop]);
+    }
+  return a;
+};
+var __spreadProps = (a, b) => __defProps(a, __getOwnPropDescs(b));
+var __async = (__this, __arguments, generator) => {
+  return new Promise((resolve, reject) => {
+    var fulfilled = (value) => {
+      try {
+        step(generator.next(value));
+      } catch (e) {
+        reject(e);
+      }
+    };
+    var rejected = (value) => {
+      try {
+        step(generator.throw(value));
+      } catch (e) {
+        reject(e);
+      }
+    };
+    var step = (x) => x.done ? resolve(x.value) : Promise.resolve(x.value).then(fulfilled, rejected);
+    step((generator = generator.apply(__this, __arguments)).next());
+  });
+};
+var eventemitter3 = { exports: {} };
+(function(module) {
+  var has = Object.prototype.hasOwnProperty, prefix = "~";
+  function Events() {
+  }
+  if (Object.create) {
+    Events.prototype = /* @__PURE__ */ Object.create(null);
+    if (!new Events().__proto__)
+      prefix = false;
+  }
+  function EE(fn, context, once) {
+    this.fn = fn;
+    this.context = context;
+    this.once = once || false;
+  }
+  function addListener(emitter, event, fn, context, once) {
+    if (typeof fn !== "function") {
+      throw new TypeError("The listener must be a function");
+    }
+    var listener = new EE(fn, context || emitter, once), evt = prefix ? prefix + event : event;
+    if (!emitter._events[evt])
+      emitter._events[evt] = listener, emitter._eventsCount++;
+    else if (!emitter._events[evt].fn)
+      emitter._events[evt].push(listener);
+    else
+      emitter._events[evt] = [emitter._events[evt], listener];
+    return emitter;
+  }
+  function clearEvent(emitter, evt) {
+    if (--emitter._eventsCount === 0)
+      emitter._events = new Events();
+    else
+      delete emitter._events[evt];
+  }
+  function EventEmitter2() {
+    this._events = new Events();
+    this._eventsCount = 0;
+  }
+  EventEmitter2.prototype.eventNames = function eventNames() {
+    var names = [], events, name;
+    if (this._eventsCount === 0)
+      return names;
+    for (name in events = this._events) {
+      if (has.call(events, name))
+        names.push(prefix ? name.slice(1) : name);
+    }
+    if (Object.getOwnPropertySymbols) {
+      return names.concat(Object.getOwnPropertySymbols(events));
+    }
+    return names;
+  };
+  EventEmitter2.prototype.listeners = function listeners(event) {
+    var evt = prefix ? prefix + event : event, handlers = this._events[evt];
+    if (!handlers)
+      return [];
+    if (handlers.fn)
+      return [handlers.fn];
+    for (var i = 0, l = handlers.length, ee = new Array(l); i < l; i++) {
+      ee[i] = handlers[i].fn;
+    }
+    return ee;
+  };
+  EventEmitter2.prototype.listenerCount = function listenerCount(event) {
+    var evt = prefix ? prefix + event : event, listeners = this._events[evt];
+    if (!listeners)
+      return 0;
+    if (listeners.fn)
+      return 1;
+    return listeners.length;
+  };
+  EventEmitter2.prototype.emit = function emit(event, a1, a2, a3, a4, a5) {
+    var evt = prefix ? prefix + event : event;
+    if (!this._events[evt])
+      return false;
+    var listeners = this._events[evt], len = arguments.length, args, i;
+    if (listeners.fn) {
+      if (listeners.once)
+        this.removeListener(event, listeners.fn, void 0, true);
+      switch (len) {
+        case 1:
+          return listeners.fn.call(listeners.context), true;
+        case 2:
+          return listeners.fn.call(listeners.context, a1), true;
+        case 3:
+          return listeners.fn.call(listeners.context, a1, a2), true;
+        case 4:
+          return listeners.fn.call(listeners.context, a1, a2, a3), true;
+        case 5:
+          return listeners.fn.call(listeners.context, a1, a2, a3, a4), true;
+        case 6:
+          return listeners.fn.call(listeners.context, a1, a2, a3, a4, a5), true;
+      }
+      for (i = 1, args = new Array(len - 1); i < len; i++) {
+        args[i - 1] = arguments[i];
+      }
+      listeners.fn.apply(listeners.context, args);
+    } else {
+      var length = listeners.length, j;
+      for (i = 0; i < length; i++) {
+        if (listeners[i].once)
+          this.removeListener(event, listeners[i].fn, void 0, true);
+        switch (len) {
+          case 1:
+            listeners[i].fn.call(listeners[i].context);
+            break;
+          case 2:
+            listeners[i].fn.call(listeners[i].context, a1);
+            break;
+          case 3:
+            listeners[i].fn.call(listeners[i].context, a1, a2);
+            break;
+          case 4:
+            listeners[i].fn.call(listeners[i].context, a1, a2, a3);
+            break;
+          default:
+            if (!args)
+              for (j = 1, args = new Array(len - 1); j < len; j++) {
+                args[j - 1] = arguments[j];
+              }
+            listeners[i].fn.apply(listeners[i].context, args);
+        }
+      }
+    }
+    return true;
+  };
+  EventEmitter2.prototype.on = function on(event, fn, context) {
+    return addListener(this, event, fn, context, false);
+  };
+  EventEmitter2.prototype.once = function once(event, fn, context) {
+    return addListener(this, event, fn, context, true);
+  };
+  EventEmitter2.prototype.removeListener = function removeListener(event, fn, context, once) {
+    var evt = prefix ? prefix + event : event;
+    if (!this._events[evt])
+      return this;
+    if (!fn) {
+      clearEvent(this, evt);
+      return this;
+    }
+    var listeners = this._events[evt];
+    if (listeners.fn) {
+      if (listeners.fn === fn && (!once || listeners.once) && (!context || listeners.context === context)) {
+        clearEvent(this, evt);
+      }
+    } else {
+      for (var i = 0, events = [], length = listeners.length; i < length; i++) {
+        if (listeners[i].fn !== fn || once && !listeners[i].once || context && listeners[i].context !== context) {
+          events.push(listeners[i]);
+        }
+      }
+      if (events.length)
+        this._events[evt] = events.length === 1 ? events[0] : events;
+      else
+        clearEvent(this, evt);
+    }
+    return this;
+  };
+  EventEmitter2.prototype.removeAllListeners = function removeAllListeners(event) {
+    var evt;
+    if (event) {
+      evt = prefix ? prefix + event : event;
+      if (this._events[evt])
+        clearEvent(this, evt);
+    } else {
+      this._events = new Events();
+      this._eventsCount = 0;
+    }
+    return this;
+  };
+  EventEmitter2.prototype.off = EventEmitter2.prototype.removeListener;
+  EventEmitter2.prototype.addListener = EventEmitter2.prototype.on;
+  EventEmitter2.prefixed = prefix;
+  EventEmitter2.EventEmitter = EventEmitter2;
+  {
+    module.exports = EventEmitter2;
+  }
+})(eventemitter3);
+var EventEmitter = eventemitter3.exports;
+const createCanvas = (width, height, opt = { noStyle: false, noTranslate: false, className: "" }) => {
+  let canvas = document.createElement("canvas");
+  if (!opt.noStyle) {
+    canvas.style.cssText = `
+      position: absolute;
+      left: 0;
+      top: 0;
+    `;
+  }
+  if (opt.className) {
+    canvas.className = opt.className;
+  }
+  let ctx = canvas.getContext("2d");
+  canvas.width = width;
+  canvas.height = height;
+  if (!opt.noTranslate) {
+    ctx.translate(canvas.width / 2, canvas.height / 2);
+  }
+  return {
+    canvas,
+    ctx
+  };
+};
+const getTowPointDistance = (x1, y1, x2, y2) => {
+  return Math.sqrt(Math.pow(x1 - x2, 2) + Math.pow(y1 - y2, 2));
+};
+const getPointToLineDistance = (x, y, x1, y1, x2, y2) => {
+  if (x1 === x2) {
+    return Math.abs(x - x1);
+  } else {
+    let B = 1;
+    let A, C;
+    A = (y1 - y2) / (x2 - x1);
+    C = 0 - B * y1 - A * x1;
+    return Math.abs((A * x + B * y + C) / Math.sqrt(A * A + B * B));
+  }
+};
+const checkIsAtSegment = (x, y, x1, y1, x2, y2, dis = 10) => {
+  if (getPointToLineDistance(x, y, x1, y1, x2, y2) > dis) {
+    return false;
+  }
+  let dis1 = getTowPointDistance(x, y, x1, y1);
+  let dis2 = getTowPointDistance(x, y, x2, y2);
+  let dis3 = getTowPointDistance(x1, y1, x2, y2);
+  let max = Math.sqrt(dis * dis + dis3 * dis3);
+  if (dis1 <= max && dis2 <= max) {
+    return true;
+  }
+  return false;
+};
+const radToDeg = (rad) => {
+  return rad * (180 / Math.PI);
+};
+const degToRad = (deg) => {
+  return deg * (Math.PI / 180);
+};
+const getTowPointRotate = (cx, cy, tx, ty, fx, fy) => {
+  return radToDeg(Math.atan2(ty - cy, tx - cx) - Math.atan2(fy - cy, fx - cx));
+};
+const getRotatedPoint = (x, y, cx, cy, rotate) => {
+  let deg = radToDeg(Math.atan2(y - cy, x - cx));
+  let del = deg + rotate;
+  let dis = getTowPointDistance(x, y, cx, cy);
+  return {
+    x: Math.cos(degToRad(del)) * dis + cx,
+    y: Math.sin(degToRad(del)) * dis + cy
+  };
+};
+const getElementCenterPoint = (element) => {
+  let { x, y, width, height } = element;
+  return {
+    x: x + width / 2,
+    y: y + height / 2
+  };
+};
+const transformPointReverseRotate = (x, y, cx, cy, rotate) => {
+  if (rotate !== 0) {
+    let rp = getRotatedPoint(x, y, cx, cy, -rotate);
+    x = rp.x;
+    y = rp.y;
+  }
+  return {
+    x,
+    y
+  };
+};
+const transformPointOnElement = (x, y, element) => {
+  let center = getElementCenterPoint(element);
+  return transformPointReverseRotate(x, y, center.x, center.y, element.rotate);
+};
+const getElementCornerPoint = (element, dir) => {
+  let { x, y, width, height } = element;
+  switch (dir) {
+    case "topLeft":
+      return {
+        x,
+        y
+      };
+    case "topRight":
+      return {
+        x: x + width,
+        y
+      };
+    case "bottomRight":
+      return {
+        x: x + width,
+        y: y + height
+      };
+    case "bottomLeft":
+      return {
+        x,
+        y: y + height
+      };
+  }
+};
+const getElementRotatedCornerPoint = (element, dir) => {
+  let center = getElementCenterPoint(element);
+  let dirPos = getElementCornerPoint(element, dir);
+  return getRotatedPoint(dirPos.x, dirPos.y, center.x, center.y, element.rotate);
+};
+const checkPointIsInRectangle = (x, y, rx, ry, rw, rh) => {
+  if (typeof rx === "object") {
+    let element = rx;
+    rx = element.x;
+    ry = element.y;
+    rw = element.width;
+    rh = element.height;
+  }
+  return x >= rx && x <= rx + rw && y >= ry && y <= ry + rh;
+};
+const getBoundingRect = (pointArr = [], returnCorners = false) => {
+  let minX = Infinity;
+  let maxX = -Infinity;
+  let minY = Infinity;
+  let maxY = -Infinity;
+  pointArr.forEach((point) => {
+    let [x2, y2] = point;
+    if (x2 < minX) {
+      minX = x2;
+    }
+    if (x2 > maxX) {
+      maxX = x2;
+    }
+    if (y2 < minY) {
+      minY = y2;
+    }
+    if (y2 > maxY) {
+      maxY = y2;
+    }
+  });
+  let x = minX;
+  let y = minY;
+  let width = maxX - minX;
+  let height = maxY - minY;
+  if (returnCorners) {
+    return [
+      {
+        x,
+        y
+      },
+      {
+        x: x + width,
+        y
+      },
+      {
+        x: x + width,
+        y: y + height
+      },
+      {
+        x,
+        y: y + height
+      }
+    ];
+  }
+  return {
+    x,
+    y,
+    width,
+    height
+  };
+};
+const deepCopy = (obj) => {
+  return JSON.parse(JSON.stringify(obj));
+};
+const getFontString = (fontSize, fontFamily) => {
+  return `${fontSize}px ${fontFamily}`;
+};
+const splitTextLines = (text) => {
+  return text.replace(/\r\n?/g, "\n").split("\n");
+};
+let textCheckEl = null;
+const getTextActWidth = (text, style) => {
+  if (!textCheckEl) {
+    textCheckEl = document.createElement("div");
+    textCheckEl.style.position = "fixed";
+    textCheckEl.style.left = "-99999px";
+    document.body.appendChild(textCheckEl);
+  }
+  let { fontSize, fontFamily } = style;
+  textCheckEl.innerText = text;
+  textCheckEl.style.fontSize = fontSize + "px";
+  textCheckEl.style.fontFamily = fontFamily;
+  let { width } = textCheckEl.getBoundingClientRect();
+  return width;
+};
+const getMaxFontSizeInWidth = (text, width, style) => {
+  let fontSize = 12;
+  while (getTextActWidth(text, __spreadProps(__spreadValues({}, style), {
+    fontSize: fontSize + 1
+  })) < width) {
+    fontSize++;
+  }
+  return fontSize;
+};
+const getWrapTextActWidth = (element) => {
+  let { text } = element;
+  let textArr = splitTextLines(text);
+  let maxWidth = -Infinity;
+  textArr.forEach((textRow) => {
+    let width = getTextActWidth(textRow, element.style);
+    if (width > maxWidth) {
+      maxWidth = width;
+    }
+  });
+  return maxWidth;
+};
+const getWrapTextMaxRowTextNumber = (text) => {
+  let textArr = splitTextLines(text);
+  let maxNumber = -Infinity;
+  textArr.forEach((textRow) => {
+    if (textRow.length > maxNumber) {
+      maxNumber = textRow.length;
+    }
+  });
+  return maxNumber;
+};
+const getTextElementSize = (element) => {
+  let { text, style } = element;
+  let width = getWrapTextActWidth(element);
+  const lines = Math.max(splitTextLines(text).length, 1);
+  let lineHeight = style.fontSize * style.lineHeightRatio;
+  let height = lines * lineHeight;
+  return {
+    width,
+    height
+  };
+};
+const throttle = (fn, ctx, time = 100) => {
+  let timer = null;
+  return (...args) => {
+    if (timer) {
+      return;
+    }
+    timer = setTimeout(() => {
+      fn.call(ctx, ...args);
+      timer = null;
+    }, time);
+  };
+};
+const computedLineWidthBySpeed = (speed, lastLineWidth, baseLineWidth = 2) => {
+  let lineWidth = 0;
+  let maxLineWidth = baseLineWidth;
+  let maxSpeed = 10;
+  let minSpeed = 0.5;
+  if (speed >= maxSpeed) {
+    lineWidth = baseLineWidth;
+  } else if (speed <= minSpeed) {
+    lineWidth = maxLineWidth + 1;
+  } else {
+    lineWidth = maxLineWidth - (speed - minSpeed) / (maxSpeed - minSpeed) * maxLineWidth;
+  }
+  if (lastLineWidth === -1) {
+    lastLineWidth = maxLineWidth;
+  }
+  return lineWidth * (1 / 2) + lastLineWidth * (1 / 2);
+};
+const downloadFile = (file, fileName) => {
+  let a = document.createElement("a");
+  a.href = file;
+  a.download = fileName;
+  a.click();
+};
+const getElementCorners = (element) => {
+  let topLeft = getElementRotatedCornerPoint(element, "topLeft");
+  let topRight = getElementRotatedCornerPoint(element, "topRight");
+  let bottomLeft = getElementRotatedCornerPoint(element, "bottomLeft");
+  let bottomRight = getElementRotatedCornerPoint(element, "bottomRight");
+  return [topLeft, topRight, bottomLeft, bottomRight];
+};
+const getMultiElementRectInfo = (elementList = []) => {
+  if (elementList.length <= 0) {
+    return {
+      minx: 0,
+      maxx: 0,
+      miny: 0,
+      maxy: 0
+    };
+  }
+  let minx = Infinity;
+  let maxx = -Infinity;
+  let miny = Infinity;
+  let maxy = -Infinity;
+  elementList.forEach((element) => {
+    let pointList = element.getEndpointList();
+    pointList.forEach(({ x, y }) => {
+      if (x < minx) {
+        minx = x;
+      }
+      if (x > maxx) {
+        maxx = x;
+      }
+      if (y < miny) {
+        miny = y;
+      }
+      if (y > maxy) {
+        maxy = y;
+      }
+    });
+  });
+  return {
+    minx,
+    maxx,
+    miny,
+    maxy
+  };
+};
+const createImageObj = (url) => {
+  return new Promise((resolve) => {
+    let img = new Image();
+    img.setAttribute("crossOrigin", "anonymous");
+    img.onload = () => {
+      resolve(img);
+    };
+    img.onerror = () => {
+      resolve(null);
+    };
+    img.src = url;
+  });
+};
+let nodeKeyIndex = 0;
+const createNodeKey = () => {
+  return nodeKeyIndex++;
+};
+var utils = /* @__PURE__ */ Object.freeze(/* @__PURE__ */ Object.defineProperty({
+  __proto__: null,
+  createCanvas,
+  getTowPointDistance,
+  getPointToLineDistance,
+  checkIsAtSegment,
+  radToDeg,
+  degToRad,
+  getTowPointRotate,
+  getRotatedPoint,
+  getElementCenterPoint,
+  transformPointReverseRotate,
+  transformPointOnElement,
+  getElementCornerPoint,
+  getElementRotatedCornerPoint,
+  checkPointIsInRectangle,
+  getBoundingRect,
+  deepCopy,
+  getFontString,
+  splitTextLines,
+  getTextActWidth,
+  getMaxFontSizeInWidth,
+  getWrapTextActWidth,
+  getWrapTextMaxRowTextNumber,
+  getTextElementSize,
+  throttle,
+  computedLineWidthBySpeed,
+  downloadFile,
+  getElementCorners,
+  getMultiElementRectInfo,
+  createImageObj,
+  createNodeKey
+}, Symbol.toStringTag, { value: "Module" }));
+const CORNERS = {
+  TOP_LEFT: "topLeft",
+  TOP_RIGHT: "topRight",
+  BOTTOM_RIGHT: "bottomRight",
+  BOTTOM_LEFT: "bottomLeft"
+};
+const DRAG_ELEMENT_PARTS = {
+  BODY: "body",
+  ROTATE: "rotate",
+  TOP_LEFT_BTN: "topLeftBtn",
+  TOP_RIGHT_BTN: "topRightBtn",
+  BOTTOM_RIGHT_BTN: "bottomRightBtn",
+  BOTTOM_LEFT_BTN: "bottomLeftBtn"
+};
+const HIT_DISTANCE = 10;
+const checkIsAtMultiSegment = (segments, rp) => {
+  let res = false;
+  segments.forEach((seg) => {
+    if (res)
+      return;
+    if (checkIsAtSegment(rp.x, rp.y, ...seg, HIT_DISTANCE)) {
+      res = true;
+    }
+  });
+  return res;
+};
+const checkIsAtRectangleEdge = (element, rp) => {
+  let { x, y, width, height } = element;
+  let segments = [
+    [x, y, x + width, y],
+    [x + width, y, x + width, y + height],
+    [x + width, y + height, x, y + height],
+    [x, y + height, x, y]
+  ];
+  return checkIsAtMultiSegment(segments, rp) ? element : null;
+};
+const checkIsAtRectangleInner = (element, rp) => {
+  return checkPointIsInRectangle(rp.x, rp.y, element) ? element : null;
+};
+const getCircleRadius = (width, height) => {
+  return Math.min(Math.abs(width), Math.abs(height)) / 2;
+};
+const checkIsAtCircleEdge = (element, rp) => {
+  let { width, height, x, y } = element;
+  let radius = getCircleRadius(width, height);
+  let dis = getTowPointDistance(rp.x, rp.y, x + radius, y + radius);
+  let onCircle = dis >= radius - HIT_DISTANCE && dis <= radius + HIT_DISTANCE;
+  return onCircle ? element : null;
+};
+const checkIsAtLineEdge = (element, rp) => {
+  let segments = [];
+  let len = element.pointArr.length;
+  let arr = element.pointArr;
+  for (let i = 0; i < len - 1; i++) {
+    segments.push([...arr[i], ...arr[i + 1]]);
+  }
+  return checkIsAtMultiSegment(segments, rp) ? element : null;
+};
+const checkIsAtFreedrawLineEdge = (element, rp) => {
+  let res = null;
+  element.pointArr.forEach((point) => {
+    if (res)
+      return;
+    let dis = getTowPointDistance(rp.x, rp.y, point[0], point[1]);
+    if (dis <= HIT_DISTANCE) {
+      res = element;
+    }
+  });
+  return res;
+};
+const checkIsAtDiamondEdge = (element, rp) => {
+  let { x, y, width, height } = element;
+  let segments = [
+    [x + width / 2, y, x + width, y + height / 2],
+    [x + width, y + height / 2, x + width / 2, y + height],
+    [x + width / 2, y + height, x, y + height / 2],
+    [x, y + height / 2, x + width / 2, y]
+  ];
+  return checkIsAtMultiSegment(segments, rp) ? element : null;
+};
+const checkIsAtTriangleEdge = (element, rp) => {
+  let { x, y, width, height } = element;
+  let segments = [
+    [x + width / 2, y, x + width, y + height],
+    [x + width, y + height, x, y + height],
+    [x, y + height, x + width / 2, y]
+  ];
+  return checkIsAtMultiSegment(segments, rp) ? element : null;
+};
+const checkIsAtArrowEdge = (element, rp) => {
+  let pointArr = element.pointArr;
+  let x = pointArr[0][0];
+  let y = pointArr[0][1];
+  let tx = pointArr[pointArr.length - 1][0];
+  let ty = pointArr[pointArr.length - 1][1];
+  let segments = [[x, y, tx, ty]];
+  return checkIsAtMultiSegment(segments, rp) ? element : null;
+};
+var checkHit = /* @__PURE__ */ Object.freeze(/* @__PURE__ */ Object.defineProperty({
+  __proto__: null,
+  checkIsAtMultiSegment,
+  checkIsAtRectangleEdge,
+  checkIsAtRectangleInner,
+  getCircleRadius,
+  checkIsAtCircleEdge,
+  checkIsAtLineEdge,
+  checkIsAtFreedrawLineEdge,
+  checkIsAtDiamondEdge,
+  checkIsAtTriangleEdge,
+  checkIsAtArrowEdge
+}, Symbol.toStringTag, { value: "Module" }));
+const drawWrap = (ctx, fn, fill = false) => {
+  ctx.beginPath();
+  fn();
+  ctx.stroke();
+  if (fill) {
+    ctx.fill();
+  }
+};
+const drawRect = (ctx, x, y, width, height, fill = false) => {
+  drawWrap(ctx, () => {
+    ctx.rect(x, y, width, height);
+    if (fill) {
+      ctx.fillRect(x, y, width, height);
+    }
+  });
+};
+const drawDiamond = (ctx, x, y, width, height, fill = false) => {
+  drawWrap(ctx, () => {
+    ctx.moveTo(x + width / 2, y);
+    ctx.lineTo(x + width, y + height / 2);
+    ctx.lineTo(x + width / 2, y + height);
+    ctx.lineTo(x, y + height / 2);
+    ctx.closePath();
+  }, fill);
+};
+const drawTriangle = (ctx, x, y, width, height, fill = false) => {
+  drawWrap(ctx, () => {
+    ctx.moveTo(x + width / 2, y);
+    ctx.lineTo(x + width, y + height);
+    ctx.lineTo(x, y + height);
+    ctx.closePath();
+  }, fill);
+};
+const drawCircle = (ctx, x, y, r, fill = false) => {
+  drawWrap(ctx, () => {
+    ctx.arc(x, y, r, 0, 2 * Math.PI);
+  }, fill);
+};
+const drawLine = (ctx, points) => {
+  drawWrap(ctx, () => {
+    let first = true;
+    points.forEach((point) => {
+      if (first) {
+        first = false;
+        ctx.moveTo(point[0], point[1]);
+      } else {
+        ctx.lineTo(point[0], point[1]);
+      }
+    });
+  });
+};
+const drawArrow = (ctx, pointArr) => {
+  let x = pointArr[0][0];
+  let y = pointArr[0][1];
+  let tx = pointArr[pointArr.length - 1][0];
+  let ty = pointArr[pointArr.length - 1][1];
+  drawWrap(ctx, () => {
+    ctx.moveTo(x, y);
+    ctx.lineTo(tx, ty);
+  }, true);
+  let l = 30;
+  let deg = 30;
+  let lineDeg = radToDeg(Math.atan2(ty - y, tx - x));
+  drawWrap(ctx, () => {
+    let plusDeg = deg - lineDeg;
+    let _x = tx - l * Math.cos(degToRad(plusDeg));
+    let _y = ty + l * Math.sin(degToRad(plusDeg));
+    ctx.moveTo(_x, _y);
+    ctx.lineTo(tx, ty);
+  }, true);
+  drawWrap(ctx, () => {
+    let plusDeg = 90 - lineDeg - deg;
+    let _x = tx - l * Math.sin(degToRad(plusDeg));
+    let _y = ty - l * Math.cos(degToRad(plusDeg));
+    ctx.moveTo(_x, _y);
+    ctx.lineTo(tx, ty);
+  });
+};
+const transformFreeLinePoint = (point, opt) => {
+  let { x, y } = opt.app.coordinate.transform(point[0], point[1]);
+  return [x - opt.cx, y - opt.cy, ...point.slice(2)];
+};
+const drawFreeLine = (ctx, points, opt) => {
+  for (let i = 0; i < points.length - 1; i++) {
+    drawWrap(ctx, () => {
+      let point = transformFreeLinePoint(points[i], opt);
+      let nextPoint = transformFreeLinePoint(points[i + 1], opt);
+      drawLineSegment(ctx, point[0], point[1], nextPoint[0], nextPoint[1], nextPoint[2]);
+    }, true);
+  }
+};
+const drawLineSegment = (ctx, mx, my, tx, ty, lineWidth = 0) => {
+  drawWrap(ctx, () => {
+    if (lineWidth > 0) {
+      ctx.lineWidth = lineWidth;
+    }
+    ctx.moveTo(mx, my);
+    ctx.lineTo(tx, ty);
+    ctx.lineCap = "round";
+    ctx.lineJoin = "round";
+  });
+};
+const drawText = (ctx, textObj, x, y, width, height) => {
+  let { text, style } = textObj;
+  let lineHeight = style.fontSize * style.lineHeightRatio;
+  drawWrap(ctx, () => {
+    ctx.font = getFontString(style.fontSize, style.fontFamily);
+    ctx.textBaseline = "middle";
+    let textArr = splitTextLines(text);
+    textArr.forEach((textRow, index) => {
+      ctx.fillText(textRow, x, y + (index * lineHeight + lineHeight / 2));
+    });
+  });
+};
+const drawImage = (ctx, element, x, y, width, height) => {
+  drawWrap(ctx, () => {
+    let ratio = width / height;
+    let showWidth = 0;
+    let showHeight = 0;
+    if (ratio > element.ratio) {
+      showHeight = height;
+      showWidth = element.ratio * height;
+    } else {
+      showWidth = width;
+      showHeight = width / element.ratio;
+    }
+    ctx.drawImage(element.imageObj, x, y, showWidth, showHeight);
+  });
+};
+var draw = /* @__PURE__ */ Object.freeze(/* @__PURE__ */ Object.defineProperty({
+  __proto__: null,
+  drawWrap,
+  drawRect,
+  drawDiamond,
+  drawTriangle,
+  drawCircle,
+  drawLine,
+  drawArrow,
+  drawFreeLine,
+  drawLineSegment,
+  drawText,
+  drawImage
+}, Symbol.toStringTag, { value: "Module" }));
+class Coordinate {
+  constructor(app) {
+    this.app = app;
+  }
+  addScrollY(y) {
+    return y + this.app.state.scrollY;
+  }
+  addScrollX(x) {
+    return x + this.app.state.scrollX;
+  }
+  subScrollY(y) {
+    return y - this.app.state.scrollY;
+  }
+  subScrollX(x) {
+    return x - this.app.state.scrollX;
+  }
+  transformToCanvasCoordinate(x, y) {
+    x -= this.app.width / 2;
+    y -= this.app.height / 2;
+    return {
+      x,
+      y
+    };
+  }
+  transformToScreenCoordinate(x, y) {
+    x += this.app.width / 2;
+    y += this.app.height / 2;
+    return {
+      x,
+      y
+    };
+  }
+  transform(x, y) {
+    let t = this.transformToCanvasCoordinate(x, y);
+    return {
+      x: this.subScrollX(t.x),
+      y: this.subScrollY(t.y)
+    };
+  }
+  windowToContainer(x, y) {
+    return {
+      x: x - this.app.left,
+      y: y - this.app.top
+    };
+  }
+  containerToWindow(x, y) {
+    return {
+      x: x + this.app.left,
+      y: y + this.app.top
+    };
+  }
+  scale(x, y) {
+    let { state } = this.app;
+    let wp = this.transformToCanvasCoordinate(x, y);
+    let sp = this.transformToScreenCoordinate(wp.x * state.scale, wp.y * state.scale);
+    return {
+      x: sp.x,
+      y: sp.y
+    };
+  }
+  reverseScale(x, y) {
+    let { state } = this.app;
+    let tp = this.transformToCanvasCoordinate(x, y);
+    let sp = this.transformToScreenCoordinate(tp.x / state.scale, tp.y / state.scale);
+    return {
+      x: sp.x,
+      y: sp.y
+    };
+  }
+  gridAdsorbent(x, y) {
+    let { gridConfig, showGrid } = this.app.state;
+    if (!showGrid) {
+      return {
+        x,
+        y
+      };
+    }
+    let gridSize = gridConfig.size;
+    return {
+      x: x - x % gridSize,
+      y: y - y % gridSize
+    };
+  }
+}
+class Event extends EventEmitter {
+  constructor(app) {
+    super();
+    this.app = app;
+    this.coordinate = app.coordinate;
+    this.isMousedown = false;
+    this.mousedownPos = {
+      x: 0,
+      y: 0,
+      unGridClientX: 0,
+      unGridClientY: 0,
+      originClientX: 0,
+      originClientY: 0
+    };
+    this.mouseOffset = {
+      x: 0,
+      y: 0,
+      originX: 0,
+      originY: 0
+    };
+    this.lastMousePos = {
+      x: 0,
+      y: 0
+    };
+    this.mouseDistance = 0;
+    this.lastMouseTime = Date.now();
+    this.mouseDuration = 0;
+    this.mouseSpeed = 0;
+    this.onMousedown = this.onMousedown.bind(this);
+    this.onMousemove = this.onMousemove.bind(this);
+    this.onMouseup = this.onMouseup.bind(this);
+    this.onDblclick = this.onDblclick.bind(this);
+    this.onMousewheel = this.onMousewheel.bind(this);
+    this.onKeydown = this.onKeydown.bind(this);
+    this.onKeyup = this.onKeyup.bind(this);
+    this.onContextmenu = this.onContextmenu.bind(this);
+    this.bindEvent();
+  }
+  bindEvent() {
+    this.app.container.addEventListener("mousedown", this.onMousedown);
+    this.app.container.addEventListener("mousemove", this.onMousemove);
+    this.app.container.addEventListener("mouseup", this.onMouseup);
+    this.app.container.addEventListener("dblclick", this.onDblclick);
+    this.app.container.addEventListener("mousewheel", this.onMousewheel);
+    this.app.container.addEventListener("contextmenu", this.onContextmenu);
+    window.addEventListener("keydown", this.onKeydown);
+    window.addEventListener("keyup", this.onKeyup);
+  }
+  unbindEvent() {
+    this.app.container.removeEventListener("mousedown", this.onMousedown);
+    this.app.container.removeEventListener("mousemove", this.onMousemove);
+    this.app.container.removeEventListener("mouseup", this.onMouseup);
+    this.app.container.removeEventListener("dblclick", this.onDblclick);
+    this.app.container.removeEventListener("mousewheel", this.onMousewheel);
+    this.app.container.removeEventListener("contextmenu", this.onContextmenu);
+    window.removeEventListener("keydown", this.onKeydown);
+    window.removeEventListener("keyup", this.onKeyup);
+  }
+  transformEvent(e) {
+    let { coordinate } = this.app;
+    let wp = coordinate.windowToContainer(e.clientX, e.clientY);
+    let { x, y } = coordinate.reverseScale(wp.x, wp.y);
+    x = coordinate.addScrollX(x);
+    y = coordinate.addScrollY(y);
+    let unGridClientX = x;
+    let unGridClientY = y;
+    let gp = coordinate.gridAdsorbent(x, y);
+    let newEvent = {
+      originEvent: e,
+      unGridClientX,
+      unGridClientY,
+      clientX: gp.x,
+      clientY: gp.y
+    };
+    return newEvent;
+  }
+  onMousedown(e) {
+    e = this.transformEvent(e);
+    this.isMousedown = true;
+    this.mousedownPos.x = e.clientX;
+    this.mousedownPos.y = e.clientY;
+    this.mousedownPos.unGridClientX = e.unGridClientX;
+    this.mousedownPos.unGridClientY = e.unGridClientY;
+    this.mousedownPos.originClientX = e.originEvent.clientX;
+    this.mousedownPos.originClientY = e.originEvent.clientY;
+    this.emit("mousedown", e, this);
+  }
+  onMousemove(e) {
+    e = this.transformEvent(e);
+    let x = e.clientX;
+    let y = e.clientY;
+    if (this.isMousedown) {
+      this.mouseOffset.x = x - this.mousedownPos.x;
+      this.mouseOffset.y = y - this.mousedownPos.y;
+      this.mouseOffset.originX = e.originEvent.clientX - this.mousedownPos.originClientX;
+      this.mouseOffset.originY = e.originEvent.clientY - this.mousedownPos.originClientY;
+    }
+    let curTime = Date.now();
+    this.mouseDuration = curTime - this.lastMouseTime;
+    this.mouseDistance = getTowPointDistance(x, y, this.lastMousePos.x, this.lastMousePos.y);
+    this.mouseSpeed = this.mouseDistance / this.mouseDuration;
+    this.emit("mousemove", e, this);
+    this.lastMouseTime = curTime;
+    this.lastMousePos.x = x;
+    this.lastMousePos.y = y;
+  }
+  onMouseup(e) {
+    e = this.transformEvent(e);
+    this.isMousedown = false;
+    this.mousedownPos.x = 0;
+    this.mousedownPos.y = 0;
+    this.emit("mouseup", e, this);
+  }
+  onDblclick(e) {
+    e = this.transformEvent(e);
+    this.emit("dblclick", e, this);
+  }
+  onMousewheel(e) {
+    e = this.transformEvent(e);
+    this.emit("mousewheel", e.originEvent.wheelDelta < 0 ? "down" : "up");
+  }
+  onContextmenu(e) {
+    e.stopPropagation();
+    e.preventDefault();
+    e = this.transformEvent(e);
+    this.emit("contextmenu", e, this);
+  }
+  onKeydown(e) {
+    this.emit("keydown", e, this);
+  }
+  onKeyup(e) {
+    this.emit("keyup", e, this);
+  }
+}
+class BaseElement extends EventEmitter {
+  constructor(opts = {}, app) {
+    super();
+    this.app = app;
+    this.groupId = opts.groupId || "";
+    this.type = opts.type || "";
+    this.key = createNodeKey();
+    this.isCreating = true;
+    this.isActive = true;
+    this.isSelected = false;
+    this.startX = 0;
+    this.startY = 0;
+    this.x = opts.x || 0;
+    this.y = opts.y || 0;
+    this.width = opts.width || 0;
+    this.height = opts.height || 0;
+    this.startRotate = 0;
+    this.rotate = opts.rotate || 0;
+    this.noRender = false;
+    this.style = __spreadValues({
+      strokeStyle: "",
+      fillStyle: "",
+      lineWidth: "small",
+      lineDash: 0,
+      globalAlpha: 1
+    }, opts.style || {});
+    this.dragElement = null;
+  }
+  serialize() {
+    return {
+      groupId: this.groupId,
+      type: this.type,
+      width: this.width,
+      height: this.height,
+      x: this.x,
+      y: this.y,
+      rotate: this.rotate,
+      style: __spreadValues({}, this.style)
+    };
+  }
+  render() {
+    throw new Error("\u5B50\u7C7B\u9700\u8981\u5B9E\u73B0\u8BE5\u65B9\u6CD5\uFF01");
+  }
+  setGroupId(groupId) {
+    this.groupId = groupId;
+  }
+  getGroupId() {
+    return this.groupId;
+  }
+  removeGroupId() {
+    this.groupId = "";
+  }
+  hasGroup() {
+    return !!this.groupId;
+  }
+  renderDragElement() {
+    if (this.isActive && !this.isCreating) {
+      this.dragElement.showAll();
+      this.dragElement.render();
+    } else if (this.isSelected) {
+      this.dragElement.onlyShowBody();
+      this.dragElement.render();
+    }
+  }
+  handleStyle(style) {
+    Object.keys(style).forEach((key) => {
+      if (key === "lineWidth") {
+        if (style[key] === "small") {
+          style[key] = 2;
+        } else if (style[key] === "middle") {
+          style[key] = 4;
+        } else if (style[key] === "large") {
+          style[key] = 6;
+        }
+      }
+      if (style[key] === "") {
+        if (this.app.state[key] !== void 0 && this.app.state[key] !== null && this.app.state[key] !== "") {
+          style[key] = this.app.state[key];
+        }
+      }
+    });
+    return style;
+  }
+  setStyle(style = {}) {
+    let _style = this.handleStyle(style);
+    Object.keys(_style).forEach((key) => {
+      if (key === "lineDash") {
+        if (_style.lineDash > 0) {
+          this.app.ctx.setLineDash([_style.lineDash]);
+        }
+      } else if (_style[key] !== void 0 && _style[key] !== "" && _style[key] !== null) {
+        this.app.ctx[key] = _style[key];
+      }
+    });
+    return this;
+  }
+  warpRender(renderFn) {
+    let { x, y, width, height, rotate, style } = this;
+    let { x: tx, y: ty } = this.app.coordinate.transform(x, y);
+    let halfWidth = width / 2;
+    let halfHeight = height / 2;
+    let cx = tx + halfWidth;
+    let cy = ty + halfHeight;
+    this.app.ctx.save();
+    this.app.ctx.translate(cx, cy);
+    this.app.ctx.rotate(degToRad(rotate));
+    this.setStyle(style);
+    renderFn({
+      halfWidth,
+      halfHeight,
+      tx,
+      ty,
+      cx,
+      cy
+    });
+    this.app.ctx.restore();
+    return this;
+  }
+  saveState() {
+    let { rotate, x, y } = this;
+    this.startRotate = rotate;
+    this.startX = x;
+    this.startY = y;
+    return this;
+  }
+  move(ox, oy) {
+    let { startX, startY } = this;
+    this.x = startX + ox;
+    this.y = startY + oy;
+    this.emit("elementPositionChange", this.x, this.y);
+    return this;
+  }
+  updateRect(x, y, width, height) {
+    this.updatePos(x, y);
+    this.updateSize(width, height);
+    return this;
+  }
+  updateSize(width, height) {
+    this.width = width;
+    this.height = height;
+    this.emit("elementSizeChange", this.width, this.height);
+    return this;
+  }
+  updatePos(x, y) {
+    this.x = x;
+    this.y = y;
+    this.emit("elementPositionChange", this.x, this.y);
+    return this;
+  }
+  offsetRotate(or) {
+    this.updateRotate(this.startRotate + or);
+    return this;
+  }
+  updateRotate(rotate) {
+    rotate = rotate % 360;
+    if (rotate < 0) {
+      rotate = 360 + rotate;
+    }
+    this.rotate = parseInt(rotate);
+    this.emit("elementRotateChange", this.rotate);
+  }
+  rotateByCenter(rotate, cx, cy) {
+    this.offsetRotate(rotate);
+    let np = getRotatedPoint(this.startX, this.startY, cx, cy, rotate);
+    this.updatePos(np.x, np.y);
+  }
+  isHit(x, y) {
+    throw new Error("\u5B50\u7C7B\u9700\u8981\u5B9E\u73B0\u8BE5\u65B9\u6CD5!");
+  }
+  startResize(resizeType, e) {
+    this.dragElement.startResize(resizeType, e);
+    return this;
+  }
+  endResize() {
+    this.dragElement.endResize();
+    return this;
+  }
+  resize(...args) {
+    this.dragElement.handleResizeElement(...args);
+    return this;
+  }
+  getEndpointList() {
+    return getElementCorners(this);
+  }
+}
+class DragElement extends BaseElement {
+  constructor(element, app, opts = {}) {
+    super({
+      type: "dragElement",
+      notNeedDragElement: true
+    }, app);
+    this.opts = __spreadValues({
+      lockRatio: false
+    }, opts);
+    this.style = {
+      strokeStyle: this.app.state.dragStrokeStyle,
+      fillStyle: "transparent",
+      lineWidth: "small",
+      lineDash: 0,
+      globalAlpha: 1
+    };
+    this.element = element;
+    this.offset = 5;
+    this.size = 10;
+    this.resizeType = "";
+    this.diagonalPoint = {
+      x: 0,
+      y: 0
+    };
+    this.mousedownPosAndElementPosOffset = {
+      x: 0,
+      y: 0
+    };
+    this.elementRatio = 0;
+    this.hideParts = [];
+  }
+  setHideParts(parts = []) {
+    this.hideParts = parts;
+  }
+  showAll() {
+    this.setHideParts([]);
+  }
+  onlyShowBody() {
+    this.setHideParts([
+      DRAG_ELEMENT_PARTS.ROTATE,
+      DRAG_ELEMENT_PARTS.TOP_LEFT_BTN,
+      DRAG_ELEMENT_PARTS.TOP_RIGHT_BTN,
+      DRAG_ELEMENT_PARTS.BOTTOM_RIGHT_BTN,
+      DRAG_ELEMENT_PARTS.BOTTOM_LEFT_BTN
+    ]);
+  }
+  update() {
+    this.x = this.element.x - this.offset;
+    this.y = this.element.y - this.offset;
+    this.width = this.element.width + this.offset * 2;
+    this.height = this.element.height + this.offset * 2;
+    this.rotate = this.element.rotate;
+  }
+  render() {
+    if (this.element.hasGroup())
+      return;
+    this.update();
+    let { width, height } = this;
+    this.warpRender(({ halfWidth, halfHeight }) => {
+      this.app.ctx.save();
+      if (!this.hideParts.includes(DRAG_ELEMENT_PARTS.BODY)) {
+        this.app.ctx.setLineDash([5]);
+        drawRect(this.app.ctx, -halfWidth, -halfHeight, width, height);
+        this.app.ctx.restore();
+      }
+      if (!this.hideParts.includes(DRAG_ELEMENT_PARTS.TOP_LEFT_BTN)) {
+        drawRect(this.app.ctx, -halfWidth - this.size, -halfHeight - this.size, this.size, this.size);
+      }
+      if (!this.hideParts.includes(DRAG_ELEMENT_PARTS.TOP_RIGHT_BTN)) {
+        drawRect(this.app.ctx, -halfWidth + this.element.width + this.size, -halfHeight - this.size, this.size, this.size);
+      }
+      if (!this.hideParts.includes(DRAG_ELEMENT_PARTS.BOTTOM_RIGHT_BTN)) {
+        drawRect(this.app.ctx, -halfWidth + this.element.width + this.size, -halfHeight + this.element.height + this.size, this.size, this.size);
+      }
+      if (!this.hideParts.includes(DRAG_ELEMENT_PARTS.BOTTOM_LEFT_BTN)) {
+        drawRect(this.app.ctx, -halfWidth - this.size, -halfHeight + this.element.height + this.size, this.size, this.size);
+      }
+      if (!this.hideParts.includes(DRAG_ELEMENT_PARTS.ROTATE)) {
+        drawCircle(this.app.ctx, -halfWidth + this.element.width / 2 + this.size / 2, -halfHeight - this.size * 2, this.size);
+      }
+    });
+  }
+  checkPointInDragElementWhere(x, y) {
+    let part = "";
+    let rp = transformPointOnElement(x, y, this.element);
+    if (checkPointIsInRectangle(rp.x, rp.y, this)) {
+      part = DRAG_ELEMENT_PARTS.BODY;
+    } else if (getTowPointDistance(rp.x, rp.y, this.x + this.width / 2, this.y - this.size * 2) <= this.size) {
+      part = DRAG_ELEMENT_PARTS.ROTATE;
+    } else if (this._checkPointIsInBtn(rp.x, rp.y, CORNERS.TOP_LEFT)) {
+      part = DRAG_ELEMENT_PARTS.TOP_LEFT_BTN;
+    } else if (this._checkPointIsInBtn(rp.x, rp.y, CORNERS.TOP_RIGHT)) {
+      part = DRAG_ELEMENT_PARTS.TOP_RIGHT_BTN;
+    } else if (this._checkPointIsInBtn(rp.x, rp.y, CORNERS.BOTTOM_RIGHT)) {
+      part = DRAG_ELEMENT_PARTS.BOTTOM_RIGHT_BTN;
+    } else if (this._checkPointIsInBtn(rp.x, rp.y, CORNERS.BOTTOM_LEFT)) {
+      part = DRAG_ELEMENT_PARTS.BOTTOM_LEFT_BTN;
+    }
+    if (this.hideParts.includes(part)) {
+      part = "";
+    }
+    return part;
+  }
+  _checkPointIsInBtn(x, y, dir) {
+    let _x = 0;
+    let _y = 0;
+    switch (dir) {
+      case CORNERS.TOP_LEFT:
+        _x = this.x - this.size;
+        _y = this.y - this.size;
+        break;
+      case CORNERS.TOP_RIGHT:
+        _x = this.x + this.width;
+        _y = this.y - this.size;
+        break;
+      case CORNERS.BOTTOM_RIGHT:
+        _x = this.x + this.width;
+        _y = this.y + this.height;
+        break;
+      case CORNERS.BOTTOM_LEFT:
+        _x = this.x - this.size;
+        _y = this.y + this.height;
+        break;
+    }
+    return checkPointIsInRectangle(x, y, _x, _y, this.size, this.size);
+  }
+  startResize(resizeType, e) {
+    this.resizeType = resizeType;
+    if (this.opts.lockRatio) {
+      this.elementRatio = this.element.width / this.element.height;
+    }
+    if (resizeType === DRAG_ELEMENT_PARTS.BODY) {
+      this.element.saveState();
+    } else if (resizeType === DRAG_ELEMENT_PARTS.ROTATE) {
+      this.element.saveState();
+    } else if (resizeType === DRAG_ELEMENT_PARTS.TOP_LEFT_BTN) {
+      this.handleDragMousedown(e, CORNERS.TOP_LEFT);
+    } else if (resizeType === DRAG_ELEMENT_PARTS.TOP_RIGHT_BTN) {
+      this.handleDragMousedown(e, CORNERS.TOP_RIGHT);
+    } else if (resizeType === DRAG_ELEMENT_PARTS.BOTTOM_RIGHT_BTN) {
+      this.handleDragMousedown(e, CORNERS.BOTTOM_RIGHT);
+    } else if (resizeType === DRAG_ELEMENT_PARTS.BOTTOM_LEFT_BTN) {
+      this.handleDragMousedown(e, CORNERS.BOTTOM_LEFT);
+    }
+  }
+  endResize() {
+    this.resizeType = "";
+    this.diagonalPoint = {
+      x: 0,
+      y: 0
+    };
+    this.mousedownPosAndElementPosOffset = {
+      x: 0,
+      y: 0
+    };
+    this.elementRatio = 0;
+  }
+  handleDragMousedown(e, corner) {
+    let centerPos = getElementCenterPoint(this.element);
+    let pos = getElementRotatedCornerPoint(this.element, corner);
+    this.diagonalPoint.x = 2 * centerPos.x - pos.x;
+    this.diagonalPoint.y = 2 * centerPos.y - pos.y;
+    this.mousedownPosAndElementPosOffset.x = e.clientX - pos.x;
+    this.mousedownPosAndElementPosOffset.y = e.clientY - pos.y;
+    this.element.saveState();
+  }
+  handleResizeElement(e, mx, my, offsetX, offsetY) {
+    let resizeType = this.resizeType;
+    if (resizeType === DRAG_ELEMENT_PARTS.BODY) {
+      this.handleMoveElement(offsetX, offsetY);
+    } else if (resizeType === DRAG_ELEMENT_PARTS.ROTATE) {
+      this.handleRotateElement(e, mx, my);
+    } else if (resizeType === DRAG_ELEMENT_PARTS.TOP_LEFT_BTN) {
+      this.handleStretchElement(e, (newCenter, rp) => {
+        return {
+          width: (newCenter.x - rp.x) * 2,
+          height: (newCenter.y - rp.y) * 2
+        };
+      }, (rp) => {
+        return {
+          x: rp.x,
+          y: rp.y
+        };
+      }, (newRatio, newRect) => {
+        let x = newRect.x;
+        let y = newRect.y;
+        if (newRatio > this.elementRatio) {
+          x = newRect.x + newRect.width - this.elementRatio * newRect.height;
+        } else if (newRatio < this.elementRatio) {
+          y = newRect.y + (newRect.height - newRect.width / this.elementRatio);
+        }
+        return {
+          x,
+          y
+        };
+      });
+    } else if (resizeType === DRAG_ELEMENT_PARTS.TOP_RIGHT_BTN) {
+      this.handleStretchElement(e, (newCenter, rp) => {
+        return {
+          width: (rp.x - newCenter.x) * 2,
+          height: (newCenter.y - rp.y) * 2
+        };
+      }, (rp, newSize) => {
+        return {
+          x: rp.x - newSize.width,
+          y: rp.y
+        };
+      }, (newRatio, newRect) => {
+        let x = newRect.x;
+        let y = newRect.y;
+        if (newRatio > this.elementRatio) {
+          x = newRect.x + this.elementRatio * newRect.height;
+        } else if (newRatio < this.elementRatio) {
+          x = newRect.x + newRect.width;
+          y = newRect.y + (newRect.height - newRect.width / this.elementRatio);
+        }
+        return {
+          x,
+          y
+        };
+      });
+    } else if (resizeType === DRAG_ELEMENT_PARTS.BOTTOM_RIGHT_BTN) {
+      this.handleStretchElement(e, (newCenter, rp) => {
+        return {
+          width: (rp.x - newCenter.x) * 2,
+          height: (rp.y - newCenter.y) * 2
+        };
+      }, (rp, newSize) => {
+        return {
+          x: rp.x - newSize.width,
+          y: rp.y - newSize.height
+        };
+      }, (newRatio, newRect) => {
+        let x = newRect.x;
+        let y = newRect.y;
+        if (newRatio > this.elementRatio) {
+          x = newRect.x + this.elementRatio * newRect.height;
+          y = newRect.y + newRect.height;
+        } else if (newRatio < this.elementRatio) {
+          x = newRect.x + newRect.width;
+          y = newRect.y + newRect.width / this.elementRatio;
+        }
+        return {
+          x,
+          y
+        };
+      });
+    } else if (resizeType === DRAG_ELEMENT_PARTS.BOTTOM_LEFT_BTN) {
+      this.handleStretchElement(e, (newCenter, rp) => {
+        return {
+          width: (newCenter.x - rp.x) * 2,
+          height: (rp.y - newCenter.y) * 2
+        };
+      }, (rp, newSize) => {
+        return {
+          x: rp.x,
+          y: rp.y - newSize.height
+        };
+      }, (newRatio, newRect) => {
+        let x = newRect.x;
+        let y = newRect.y;
+        if (newRatio > this.elementRatio) {
+          x = newRect.x + newRect.width - this.elementRatio * newRect.height;
+          y = newRect.y + newRect.height;
+        } else if (newRatio < this.elementRatio) {
+          y = newRect.y + newRect.width / this.elementRatio;
+        }
+        return {
+          x,
+          y
+        };
+      });
+    }
+  }
+  handleMoveElement(offsetX, offsetY) {
+    this.element.move(offsetX, offsetY);
+  }
+  handleRotateElement(e, mx, my) {
+    let centerPos = getElementCenterPoint(this.element);
+    let rotate = getTowPointRotate(centerPos.x, centerPos.y, e.clientX, e.clientY, mx, my);
+    this.element.offsetRotate(rotate);
+  }
+  stretchCalc(x, y, calcSize, calcPos) {
+    let newCenter = {
+      x: (x + this.diagonalPoint.x) / 2,
+      y: (y + this.diagonalPoint.y) / 2
+    };
+    let rp = transformPointReverseRotate(x, y, newCenter.x, newCenter.y, this.element.rotate);
+    let newSize = calcSize(newCenter, rp);
+    let isWidthReverse = false;
+    if (newSize.width < 0) {
+      newSize.width = 0;
+      isWidthReverse = true;
+    }
+    let isHeightReverse = false;
+    if (newSize.height < 0) {
+      newSize.height = 0;
+      isHeightReverse = true;
+    }
+    let newPos = calcPos(rp, newSize);
+    let newRect = {
+      x: newPos.x,
+      y: newPos.y,
+      width: newSize.width,
+      height: newSize.height
+    };
+    if (isWidthReverse || isHeightReverse) {
+      newRect.x = this.element.x;
+      newRect.y = this.element.y;
+    }
+    return {
+      newRect,
+      newCenter
+    };
+  }
+  handleStretchElement(e, calcSize, calcPos, fixPos) {
+    let actClientX = e.clientX - this.mousedownPosAndElementPosOffset.x;
+    let actClientY = e.clientY - this.mousedownPosAndElementPosOffset.y;
+    let { newRect, newCenter } = this.stretchCalc(actClientX, actClientY, calcSize, calcPos);
+    if (this.opts.lockRatio) {
+      this.fixStretch(newRect, newCenter, calcSize, calcPos, fixPos);
+      return;
+    }
+    this.element.updateRect(newRect.x, newRect.y, newRect.width, newRect.height);
+  }
+  fixStretch(newRect, newCenter, calcSize, calcPos, fixPos) {
+    let newRatio = newRect.width / newRect.height;
+    let fp = fixPos(newRatio, newRect);
+    let rp = getRotatedPoint(fp.x, fp.y, newCenter.x, newCenter.y, this.element.rotate);
+    let fixNewRect = this.stretchCalc(rp.x, rp.y, calcSize, calcPos).newRect;
+    if (fixNewRect.width === 0 && fixNewRect.height === 0) {
+      return;
+    }
+    this.element.updateRect(fixNewRect.x, fixNewRect.y, fixNewRect.width, fixNewRect.height);
+  }
+}
+class Rectangle extends BaseElement {
+  constructor(...args) {
+    super(...args);
+    this.dragElement = new DragElement(this, this.app);
+  }
+  render() {
+    let { width, height } = this;
+    this.warpRender(({ halfWidth, halfHeight }) => {
+      drawRect(this.app.ctx, -halfWidth, -halfHeight, width, height, true);
+    });
+    this.renderDragElement();
+  }
+  isHit(x, y) {
+    let rp = transformPointOnElement(x, y, this);
+    return checkIsAtRectangleEdge(this, rp);
+  }
+}
+class Circle extends BaseElement {
+  constructor(...args) {
+    super(...args);
+    this.dragElement = new DragElement(this, this.app, {
+      lockRatio: true
+    });
+  }
+  render() {
+    let { width, height } = this;
+    this.warpRender(({ halfWidth, halfHeight }) => {
+      drawCircle(this.app.ctx, 0, 0, getCircleRadius(width, height), true);
+    });
+    this.renderDragElement();
+  }
+  isHit(x, y) {
+    let rp = transformPointOnElement(x, y, this);
+    return checkIsAtCircleEdge(this, rp);
+  }
+}
+class Diamond extends BaseElement {
+  constructor(...args) {
+    super(...args);
+    this.dragElement = new DragElement(this, this.app);
+  }
+  render() {
+    let { width, height } = this;
+    this.warpRender(({ halfWidth, halfHeight }) => {
+      drawDiamond(this.app.ctx, -halfWidth, -halfHeight, width, height, true);
+    });
+    this.renderDragElement();
+  }
+  isHit(x, y) {
+    let rp = transformPointOnElement(x, y, this);
+    return checkIsAtDiamondEdge(this, rp);
+  }
+  getEndpointList() {
+    let { x, y, width, height, rotate } = this;
+    let points = [
+      [x + width / 2, y],
+      [x + width, y + height / 2],
+      [x + width / 2, y + height],
+      [x, y + height / 2]
+    ];
+    let center = getElementCenterPoint(this);
+    return points.map((point) => {
+      return getRotatedPoint(point[0], point[1], center.x, center.y, rotate);
+    });
+  }
+}
+class Triangle extends BaseElement {
+  constructor(...args) {
+    super(...args);
+    this.dragElement = new DragElement(this, this.app);
+  }
+  render() {
+    let { width, height } = this;
+    this.warpRender(({ halfWidth, halfHeight }) => {
+      drawTriangle(this.app.ctx, -halfWidth, -halfHeight, width, height, true);
+    });
+    this.renderDragElement();
+  }
+  isHit(x, y) {
+    let rp = transformPointOnElement(x, y, this);
+    return checkIsAtTriangleEdge(this, rp);
+  }
+  getEndpointList() {
+    let { x, y, width, height, rotate } = this;
+    let points = [
+      [x + width / 2, y],
+      [x + width, y + height],
+      [x, y + height]
+    ];
+    let center = getElementCenterPoint(this);
+    return points.map((point) => {
+      return getRotatedPoint(point[0], point[1], center.x, center.y, rotate);
+    });
+  }
+}
+class BaseMultiPointElement extends BaseElement {
+  constructor(opts = {}, app) {
+    super(opts, app);
+    this.startPointArr = [];
+    this.pointArr = opts.pointArr || [];
+    this.startWidth = 0;
+    this.startHeight = 0;
+    this.fictitiousPoint = {
+      x: 0,
+      y: 0
+    };
+  }
+  serialize() {
+    let base = super.serialize();
+    return __spreadProps(__spreadValues({}, base), {
+      pointArr: [...this.pointArr]
+    });
+  }
+  addPoint(x, y, ...args) {
+    if (!Array.isArray(this.pointArr)) {
+      return;
+    }
+    this.pointArr.push([x, y, ...args]);
+    return this;
+  }
+  updateMultiPointBoundingRect() {
+    let rect = getBoundingRect(this.pointArr);
+    this.x = rect.x;
+    this.y = rect.y;
+    this.width = rect.width;
+    this.height = rect.height;
+    return this;
+  }
+  updateFictitiousPoint(x, y) {
+    this.fictitiousPoint.x = x;
+    this.fictitiousPoint.y = y;
+  }
+  saveState() {
+    let { rotate, x, y, width, height, pointArr } = this;
+    this.startRotate = rotate;
+    this.startX = x;
+    this.startY = y;
+    this.startPointArr = deepCopy(pointArr);
+    this.startWidth = width;
+    this.startHeight = height;
+    return this;
+  }
+  move(ox, oy) {
+    this.pointArr = this.startPointArr.map((point) => {
+      return [point[0] + ox, point[1] + oy, ...point.slice(2)];
+    });
+    let { startX, startY } = this;
+    this.x = startX + ox;
+    this.y = startY + oy;
+    return this;
+  }
+  updateRect(x, y, width, height) {
+    let { startWidth, startHeight, startPointArr } = this;
+    let scaleX = width / startWidth;
+    let scaleY = height / startHeight;
+    this.pointArr = startPointArr.map((point) => {
+      let nx = point[0] * scaleX;
+      let ny = point[1] * scaleY;
+      return [nx, ny, ...point.slice(2)];
+    });
+    let rect = getBoundingRect(this.pointArr);
+    let offsetX = rect.x - x;
+    let offsetY = rect.y - y;
+    this.pointArr = this.pointArr.map((point) => {
+      return [point[0] - offsetX, point[1] - offsetY, ...point.slice(2)];
+    });
+    this.updatePos(x, y);
+    this.updateSize(width, height);
+    return this;
+  }
+  rotateByCenter(rotate, cx, cy) {
+    this.pointArr = this.startPointArr.map((point) => {
+      let np = getRotatedPoint(point[0], point[1], cx, cy, rotate);
+      return [np.x, np.y, ...point.slice(2)];
+    });
+    this.updateMultiPointBoundingRect();
+  }
+  getEndpointList() {
+    return this.pointArr.map((point) => {
+      let center = getElementCenterPoint(this);
+      let np = getRotatedPoint(point[0], point[1], center.x, center.y, this.rotate);
+      return {
+        x: np.x,
+        y: np.y
+      };
+    });
+  }
+}
+class Freedraw extends BaseMultiPointElement {
+  constructor(...args) {
+    super(...args);
+    this.dragElement = new DragElement(this, this.app);
+    this.lastLineWidth = -1;
+  }
+  render() {
+    let { pointArr } = this;
+    this.warpRender(({ cx, cy }) => {
+      drawFreeLine(this.app.ctx, pointArr, {
+        app: this.app,
+        cx,
+        cy
+      });
+    });
+    this.renderDragElement();
+  }
+  isHit(x, y) {
+    let rp = transformPointOnElement(x, y, this);
+    return checkIsAtFreedrawLineEdge(this, rp);
+  }
+  singleRender(mx, my, tx, ty, lineWidth) {
+    this.app.ctx.save();
+    this.setStyle(this.style);
+    drawLineSegment(this.app.ctx, mx, my, tx, ty, lineWidth);
+    this.app.ctx.restore();
+  }
+}
+class Arrow extends BaseMultiPointElement {
+  constructor(...args) {
+    super(...args);
+    this.dragElement = new DragElement(this, this.app);
+  }
+  render() {
+    let { pointArr, fictitiousPoint } = this;
+    this.warpRender(({ cx, cy }) => {
+      let realtimePoint = [];
+      if (pointArr.length > 0 && this.isCreating) {
+        let { x: fx, y: fy } = this.app.coordinate.transform(fictitiousPoint.x - cx, fictitiousPoint.y - cy);
+        realtimePoint = [[fx, fy]];
+      }
+      drawArrow(this.app.ctx, pointArr.map((point) => {
+        let { x, y } = this.app.coordinate.transform(point[0], point[1]);
+        return [x - cx, y - cy];
+      }).concat(realtimePoint));
+    });
+    this.renderDragElement();
+  }
+  isHit(x, y) {
+    let rp = transformPointOnElement(x, y, this);
+    return checkIsAtArrowEdge(this, rp);
+  }
+}
+class Image$1 extends BaseElement {
+  constructor(opts = {}, app) {
+    super(opts, app);
+    this.dragElement = new DragElement(this, this.app, {
+      lockRatio: true
+    });
+    this.url = opts.url || "";
+    this.imageObj = opts.imageObj || null;
+    this.ratio = opts.ratio || 1;
+  }
+  serialize() {
+    let base = super.serialize();
+    return __spreadProps(__spreadValues({}, base), {
+      url: this.url,
+      ratio: this.ratio
+    });
+  }
+  render() {
+    let { width, height } = this;
+    this.warpRender(({ halfWidth, halfHeight }) => {
+      drawImage(this.app.ctx, this, -halfWidth, -halfHeight, width, height);
+    });
+    this.renderDragElement();
+  }
+  isHit(x, y) {
+    let rp = transformPointOnElement(x, y, this);
+    return checkIsAtRectangleInner(this, rp);
+  }
+}
+class Line extends BaseMultiPointElement {
+  constructor(opts = {}, app) {
+    super(opts, app);
+    this.dragElement = new DragElement(this, this.app);
+    this.isSingle = opts.isSingle;
+  }
+  render() {
+    let { pointArr, fictitiousPoint } = this;
+    this.warpRender(({ cx, cy }) => {
+      let realtimePoint = [];
+      if (pointArr.length > 0 && this.isCreating) {
+        let { x: fx, y: fy } = this.app.coordinate.transform(fictitiousPoint.x - cx, fictitiousPoint.y - cy);
+        realtimePoint = [[fx, fy]];
+      }
+      drawLine(this.app.ctx, pointArr.map((point) => {
+        let { x, y } = this.app.coordinate.transform(point[0], point[1]);
+        return [x - cx, y - cy];
+      }).concat(realtimePoint));
+    });
+    this.renderDragElement();
+  }
+  isHit(x, y) {
+    let rp = transformPointOnElement(x, y, this);
+    return checkIsAtLineEdge(this, rp);
+  }
+}
+class Text extends BaseElement {
+  constructor(opts = {}, app) {
+    var _a, _b, _c, _d;
+    super(opts, app);
+    this.dragElement = new DragElement(this, this.app, {
+      lockRatio: true
+    });
+    this.text = opts.text || "";
+    this.style.fillStyle = ((_a = opts.style) == null ? void 0 : _a.fillStyle) || this.app.state.strokeStyle || "#000";
+    this.style.fontSize = ((_b = opts.style) == null ? void 0 : _b.fontSize) || this.app.state.fontSize || 18;
+    this.style.lineHeightRatio = ((_c = opts.style) == null ? void 0 : _c.lineHeightRatio) || 1.5;
+    this.style.fontFamily = ((_d = opts.style) == null ? void 0 : _d.fontFamily) || this.app.state.fontFamily || "\u5FAE\u8F6F\u96C5\u9ED1, Microsoft YaHei";
+  }
+  serialize() {
+    let base = super.serialize();
+    return __spreadProps(__spreadValues({}, base), {
+      text: this.text
+    });
+  }
+  render() {
+    this.warpRender(({ halfWidth, halfHeight }) => {
+      drawText(this.app.ctx, this, -halfWidth, -halfHeight);
+    });
+    this.renderDragElement();
+  }
+  isHit(x, y) {
+    let rp = transformPointOnElement(x, y, this);
+    return checkIsAtRectangleInner(this, rp);
+  }
+  updateRect(x, y, width, height) {
+    let { text, style } = this;
+    let fontSize = Math.floor(height / splitTextLines(text).length / style.lineHeightRatio);
+    this.style.fontSize = fontSize;
+    super.updateRect(x, y, width, height);
+  }
+  updateTextSize() {
+    let { width, height } = getTextElementSize(this);
+    this.width = width;
+    this.height = height;
+  }
+}
+class Elements$1 {
+  constructor(app) {
+    this.app = app;
+    this.elementList = [];
+    this.activeElement = null;
+    this.isCreatingElement = false;
+    this.isResizing = false;
+    this.resizingElement = null;
+    this.handleResize = throttle(this.handleResize, this, 16);
+  }
+  serialize(stringify = false) {
+    let data = this.elementList.map((element) => {
+      return element.serialize();
+    });
+    return stringify ? JSON.stringify(data) : data;
+  }
+  getElementsNum() {
+    return this.elementList.length;
+  }
+  hasElements() {
+    return this.elementList.length > 0;
+  }
+  addElement(element) {
+    this.elementList.push(element);
+    return this;
+  }
+  unshiftElement(element) {
+    this.elementList.unshift(element);
+    return this;
+  }
+  insertElement(element, index) {
+    this.elementList.splice(index, 0, element);
+  }
+  deleteElement(element) {
+    let index = this.getElementIndex(element);
+    if (index !== -1) {
+      this.elementList.splice(index, 1);
+      if (element.isActive) {
+        this.cancelActiveElement(element);
+      }
+    }
+    return this;
+  }
+  deleteAllElements() {
+    this.activeElement = null;
+    this.elementList = [];
+    this.isCreatingElement = false;
+    this.isResizing = false;
+    this.resizingElement = null;
+    return this;
+  }
+  getElementIndex(element) {
+    return this.elementList.findIndex((item) => {
+      return item === element;
+    });
+  }
+  createElementsFromData(data) {
+    data.forEach((item) => {
+      let element = this.pureCreateElement(item);
+      element.isActive = false;
+      element.isCreating = false;
+      this.addElement(element);
+    });
+    this.app.group.initIdToElementList(this.elementList);
+    return this;
+  }
+  hasActiveElement() {
+    return !!this.activeElement;
+  }
+  setActiveElement(element) {
+    this.cancelActiveElement();
+    this.activeElement = element;
+    if (element) {
+      element.isActive = true;
+    }
+    this.app.emit("activeElementChange", this.activeElement);
+    return this;
+  }
+  cancelActiveElement() {
+    if (!this.hasActiveElement()) {
+      return this;
+    }
+    this.activeElement.isActive = false;
+    this.activeElement = null;
+    this.app.emit("activeElementChange", this.activeElement);
+    return this;
+  }
+  checkIsHitElement(e) {
+    let x = e.unGridClientX;
+    let y = e.unGridClientY;
+    for (let i = this.elementList.length - 1; i >= 0; i--) {
+      let element = this.elementList[i];
+      if (element.isHit(x, y)) {
+        return element;
+      }
+    }
+    return null;
+  }
+  pureCreateElement(opts = {}) {
+    switch (opts.type) {
+      case "rectangle":
+        return new Rectangle(opts, this.app);
+      case "diamond":
+        return new Diamond(opts, this.app);
+      case "triangle":
+        return new Triangle(opts, this.app);
+      case "circle":
+        return new Circle(opts, this.app);
+      case "freedraw":
+        return new Freedraw(opts, this.app);
+      case "image":
+        return new Image$1(opts, this.app);
+      case "arrow":
+        return new Arrow(opts, this.app);
+      case "line":
+        return new Line(opts, this.app);
+      case "text":
+        return new Text(opts, this.app);
+      default:
+        return null;
+    }
+  }
+  createElement(opts = {}, callback = () => {
+  }, ctx = null, notActive) {
+    if (this.hasActiveElement() || this.isCreatingElement) {
+      return this;
+    }
+    let element = this.pureCreateElement(opts);
+    if (!element) {
+      return this;
+    }
+    this.addElement(element);
+    if (!notActive) {
+      this.setActiveElement(element);
+    }
+    this.isCreatingElement = true;
+    callback.call(ctx, element);
+    return this;
+  }
+  copyElement(element, notActive = false, pos) {
+    return new Promise((resolve) => __async(this, null, function* () {
+      if (!element) {
+        return resolve();
+      }
+      let data = this.app.group.handleCopyElementData(element.serialize());
+      if (data.type === "image") {
+        data.imageObj = yield createImageObj(data.url);
+      }
+      this.createElement(data, (element2) => {
+        this.app.group.handleCopyElement(element2);
+        element2.startResize(DRAG_ELEMENT_PARTS.BODY);
+        let ox = 20;
+        let oy = 20;
+        if (pos) {
+          ox = pos.x - element2.x - element2.width / 2;
+          oy = pos.y - element2.y - element2.height / 2;
+        }
+        let gridAdsorbentPos = this.app.coordinate.gridAdsorbent(ox, oy);
+        element2.resize(null, null, null, gridAdsorbentPos.x, gridAdsorbentPos.y);
+        element2.isCreating = false;
+        if (notActive) {
+          element2.isActive = false;
+        }
+        this.isCreatingElement = false;
+        resolve(element2);
+      }, this, notActive);
+    }));
+  }
+  creatingRectangleLikeElement(type, x, y, offsetX, offsetY) {
+    this.createElement({
+      type,
+      x,
+      y,
+      width: offsetX,
+      height: offsetY
+    });
+    this.activeElement.updateSize(offsetX, offsetY);
+  }
+  creatingCircle(x, y, e) {
+    this.createElement({
+      type: "circle",
+      x,
+      y
+    });
+    let radius = getTowPointDistance(e.clientX, e.clientY, x, y);
+    this.activeElement.updateSize(radius, radius);
+  }
+  creatingFreedraw(e, event) {
+    this.createElement({
+      type: "freedraw"
+    });
+    let element = this.activeElement;
+    let lineWidth = computedLineWidthBySpeed(event.mouseSpeed, element.lastLineWidth);
+    element.lastLineWidth = lineWidth;
+    element.addPoint(e.clientX, e.clientY, lineWidth);
+    let { coordinate, ctx, state } = this.app;
+    let tfp = coordinate.transformToCanvasCoordinate(coordinate.subScrollX(event.lastMousePos.x), coordinate.subScrollY(event.lastMousePos.y));
+    let ttp = coordinate.transformToCanvasCoordinate(coordinate.subScrollX(e.clientX), coordinate.subScrollY(e.clientY));
+    ctx.save();
+    ctx.scale(state.scale, state.scale);
+    element.singleRender(tfp.x, tfp.y, ttp.x, ttp.y, lineWidth);
+    ctx.restore();
+  }
+  creatingImage(e, { width, height, imageObj, url, ratio }) {
+    let gp = this.app.coordinate.gridAdsorbent(e.unGridClientX - width / 2, e.unGridClientY - height / 2);
+    this.createElement({
+      type: "image",
+      x: gp.x,
+      y: gp.y,
+      url,
+      imageObj,
+      width,
+      height,
+      ratio
+    });
+  }
+  editingText(element) {
+    if (element.type !== "text") {
+      return;
+    }
+    element.noRender = true;
+    this.setActiveElement(element);
+  }
+  completeEditingText() {
+    let element = this.activeElement;
+    if (!element || element.type !== "text") {
+      return;
+    }
+    if (!element.text.trim()) {
+      this.deleteElement(element);
+      this.setActiveElement(null);
+      return;
+    }
+    element.noRender = false;
+  }
+  completeCreateArrow(e) {
+    this.activeElement.addPoint(e.clientX, e.clientY);
+  }
+  creatingArrow(x, y, e) {
+    this.createElement({
+      type: "arrow",
+      x,
+      y
+    }, (element) => {
+      element.addPoint(x, y);
+    });
+    this.activeElement.updateFictitiousPoint(e.clientX, e.clientY);
+  }
+  creatingLine(x, y, e, isSingle = false, notCreate = false) {
+    if (!notCreate) {
+      this.createElement({
+        type: "line",
+        x,
+        y,
+        isSingle
+      }, (element2) => {
+        element2.addPoint(x, y);
+      });
+    }
+    let element = this.activeElement;
+    if (element) {
+      element.updateFictitiousPoint(e.clientX, e.clientY);
+    }
+  }
+  completeCreateLine(e, completeCallback = () => {
+  }) {
+    let element = this.activeElement;
+    let x = e.clientX;
+    let y = e.clientY;
+    if (element && element.isSingle) {
+      element.addPoint(x, y);
+      completeCallback();
+    } else {
+      this.createElement({
+        type: "line",
+        isSingle: false
+      });
+      element = this.activeElement;
+      element.addPoint(x, y);
+      element.updateFictitiousPoint(x, y);
+    }
+  }
+  completeCreateElement() {
+    this.isCreatingElement = false;
+    let element = this.activeElement;
+    if (!element) {
+      return this;
+    }
+    if (["freedraw", "arrow", "line"].includes(element.type)) {
+      element.updateMultiPointBoundingRect();
+    }
+    element.isCreating = false;
+    this.app.emitChange();
+    return this;
+  }
+  setActiveElementStyle(style = {}) {
+    if (!this.hasActiveElement()) {
+      return this;
+    }
+    Object.keys(style).forEach((key) => {
+      this.activeElement.style[key] = style[key];
+      if (key === "fontSize" && this.activeElement.type === "text") {
+        this.activeElement.updateTextSize();
+      }
+    });
+    return this;
+  }
+  checkInResizeHand(x, y) {
+    let element = this.activeElement;
+    let hand = element.dragElement.checkPointInDragElementWhere(x, y);
+    if (hand) {
+      return {
+        element,
+        hand
+      };
+    }
+    return null;
+  }
+  checkIsResize(x, y, e) {
+    if (!this.hasActiveElement()) {
+      return false;
+    }
+    let res = this.checkInResizeHand(x, y);
+    if (res) {
+      this.isResizing = true;
+      this.resizingElement = res.element;
+      this.resizingElement.startResize(res.hand, e);
+      this.app.cursor.setResize(res.hand);
+      return true;
+    }
+    return false;
+  }
+  handleResize(...args) {
+    if (!this.isResizing) {
+      return;
+    }
+    this.resizingElement.resize(...args);
+    this.app.render.render();
+  }
+  endResize() {
+    this.isResizing = false;
+    this.resizingElement.endResize();
+    this.resizingElement = null;
+  }
+}
+class ImageEdit extends EventEmitter {
+  constructor(app) {
+    super();
+    this.app = app;
+    this.el = null;
+    this.isReady = false;
+    this.previewEl = null;
+    this.imageData = null;
+    this.maxWidth = 750;
+    this.maxHeight = 450;
+    this.maxRatio = this.maxWidth / this.maxHeight;
+    this.onImageSelectChange = this.onImageSelectChange.bind(this);
+  }
+  reset() {
+    this.el.value = "";
+    this.isReady = false;
+    document.body.removeChild(this.previewEl);
+    this.previewEl = null;
+    this.imageData = null;
+  }
+  selectImage() {
+    if (!this.el) {
+      this.el = document.createElement("input");
+      this.el.type = "file";
+      this.el.accept = "image/*";
+      this.el.style.position = "fixed";
+      this.el.style.left = "-999999px";
+      this.el.addEventListener("change", this.onImageSelectChange);
+      document.body.appendChild(this.el);
+    }
+    this.el.click();
+  }
+  updatePreviewElPos(x, y) {
+    let width = 100;
+    let height = width / this.imageData.ratio;
+    if (!this.previewEl) {
+      this.previewEl = document.createElement("div");
+      this.previewEl.style.position = "fixed";
+      this.previewEl.style.width = width + "px";
+      this.previewEl.style.height = height + "px";
+      this.previewEl.style.backgroundImage = `url('${this.imageData.url}')`;
+      this.previewEl.style.backgroundSize = "cover";
+      this.previewEl.style.pointerEvents = "none";
+      document.body.appendChild(this.previewEl);
+    }
+    let tp = this.app.coordinate.containerToWindow(x, y);
+    this.previewEl.style.left = tp.x - width / 2 + "px";
+    this.previewEl.style.top = tp.y - height / 2 + "px";
+  }
+  getImageSize(url) {
+    return __async(this, null, function* () {
+      return new Promise((resolve, reject) => {
+        let img = new Image();
+        img.setAttribute("crossOrigin", "anonymous");
+        img.onload = () => {
+          let width = img.width;
+          let height = img.height;
+          let ratio = img.width / img.height;
+          if (img.width > this.maxWidth || img.height > this.maxHeight) {
+            if (ratio > this.maxRatio) {
+              width = this.maxWidth;
+              height = this.maxWidth / ratio;
+            } else {
+              height = this.maxHeight;
+              width = this.maxHeight * ratio;
+            }
+          }
+          resolve({
+            imageObj: img,
+            size: {
+              width,
+              height
+            },
+            ratio
+          });
+        };
+        img.onerror = () => {
+          reject();
+        };
+        img.src = url;
+      });
+    });
+  }
+  onImageSelectChange(e) {
+    return __async(this, null, function* () {
+      let url = yield this.getImageUrl(e.target.files[0]);
+      let { imageObj, size, ratio } = yield this.getImageSize(url);
+      this.isReady = true;
+      this.imageData = __spreadProps(__spreadValues({
+        url
+      }, size), {
+        ratio,
+        imageObj
+      });
+      this.emit("imageSelectChange", this.imageData);
+    });
+  }
+  getImageUrl(file) {
+    return __async(this, null, function* () {
+      return new Promise((resolve, reject) => {
+        let reader = new FileReader();
+        reader.onloadend = () => {
+          resolve(reader.result);
+        };
+        reader.onerror = () => {
+          reject();
+        };
+        reader.readAsDataURL(file);
+      });
+    });
+  }
+}
+class Cursor {
+  constructor(app) {
+    this.app = app;
+    this.currentType = "default";
+  }
+  set(type = "default") {
+    this.currentType = type;
+    let style = type;
+    if (type === "eraser") {
+      style = `url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABQAAAAUCAYAAACNiR0NAAAAAXNSR0IArs4c6QAAARRJREFUOE/dlDFLxEAQhd+BVouFZ3vlQuwSyI+5a7PBRkk6k9KzTOwStJFsWv0xgaQzkNLWszim0kL2OOFc9oKRYHFTz37Lm/dmJhi5JiPzcBjAOYDz7WheADz3jalP8oIxds85P3Zd90RBqqpad133SUSXAJ5M4H3AhWVZd1EUzYQQP96VZYkkSV7btr02QY1Axtgqz/NTz/OM6qSUCMNwRURneoMJOLdt+7Gu643MfeU4zrppmgt9pibgjRBiWRRFb0R934eUcgngdrfxX4CjSwZj7C3Lsqnu8Lc05XQQBO9ENP2NKapnE5s4jme608rhNE2HxWb7qwr2A+f8SAv2BxFdDQ32rpLRVu9Pl+0wztcg6V/VPW4Vw1FsawAAAABJRU5ErkJggg==) 10 10, auto`;
+    }
+    this.app.container.style.cursor = style;
+  }
+  hide() {
+    this.set("none");
+  }
+  reset() {
+    this.set();
+  }
+  setCrosshair() {
+    this.set("crosshair");
+  }
+  setMove() {
+    this.set("move");
+  }
+  setResize(dir) {
+    let type = "";
+    switch (dir) {
+      case DRAG_ELEMENT_PARTS.BODY:
+        type = "move";
+        break;
+      case DRAG_ELEMENT_PARTS.ROTATE:
+        type = "grab";
+        break;
+      case DRAG_ELEMENT_PARTS.TOP_LEFT_BTN:
+        type = "nw-resize";
+        break;
+      case DRAG_ELEMENT_PARTS.TOP_RIGHT_BTN:
+        type = "ne-resize";
+        break;
+      case DRAG_ELEMENT_PARTS.BOTTOM_RIGHT_BTN:
+        type = "se-resize";
+        break;
+      case DRAG_ELEMENT_PARTS.BOTTOM_LEFT_BTN:
+        type = "sw-resize";
+        break;
+    }
+    this.set(type);
+  }
+  setEraser() {
+    this.set("eraser");
+  }
+}
+class TextEdit extends EventEmitter {
+  constructor(app) {
+    super();
+    this.app = app;
+    this.editable = null;
+    this.isEditing = false;
+    this.onTextInput = this.onTextInput.bind(this);
+    this.onTextBlur = this.onTextBlur.bind(this);
+  }
+  crateTextInputEl() {
+    this.editable = document.createElement("textarea");
+    this.editable.dir = "auto";
+    this.editable.tabIndex = 0;
+    this.editable.wrap = "off";
+    this.editable.className = "textInput";
+    Object.assign(this.editable.style, {
+      position: "fixed",
+      display: "block",
+      minHeight: "1em",
+      backfaceVisibility: "hidden",
+      margin: 0,
+      padding: 0,
+      border: 0,
+      outline: 0,
+      resize: "none",
+      background: "transparent",
+      overflow: "hidden",
+      whiteSpace: "pre"
+    });
+    this.editable.addEventListener("input", this.onTextInput);
+    this.editable.addEventListener("blur", this.onTextBlur);
+    document.body.appendChild(this.editable);
+  }
+  updateTextInputStyle() {
+    let activeElement = this.app.elements.activeElement;
+    if (!activeElement) {
+      return;
+    }
+    let { x, y, width, height, style, text, rotate } = activeElement;
+    let { coordinate, state } = this.app;
+    this.editable.value = text;
+    x = coordinate.subScrollX(x);
+    y = coordinate.subScrollY(y);
+    let sp = coordinate.scale(x, y);
+    let tp = coordinate.containerToWindow(sp.x, sp.y);
+    let fontSize = style.fontSize * state.scale;
+    let styles = {
+      font: getFontString(fontSize, style.fontFamily),
+      lineHeight: `${fontSize * style.lineHeightRatio}px`,
+      left: `${tp.x}px`,
+      top: `${tp.y}px`,
+      color: style.fillStyle,
+      width: Math.max(width, 100) * state.scale + "px",
+      height: height * state.scale + "px",
+      transform: `rotate(${rotate}deg)`,
+      opacity: style.globalAlpha
+    };
+    Object.assign(this.editable.style, styles);
+  }
+  onTextInput() {
+    let activeElement = this.app.elements.activeElement;
+    if (!activeElement) {
+      return;
+    }
+    activeElement.text = this.editable.value;
+    let { width, height } = getTextElementSize(activeElement);
+    activeElement.width = width;
+    activeElement.height = height;
+    this.updateTextInputStyle();
+  }
+  onTextBlur() {
+    this.editable.style.display = "none";
+    this.editable.value = "";
+    this.emit("blur");
+    this.isEditing = false;
+  }
+  showTextEdit() {
+    if (!this.editable) {
+      this.crateTextInputEl();
+    } else {
+      this.editable.style.display = "block";
+    }
+    this.updateTextInputStyle();
+    this.editable.focus();
+    this.editable.select();
+    this.isEditing = true;
+  }
+}
+class History {
+  constructor(app) {
+    this.app = app;
+    this.historyStack = [];
+    this.length = 0;
+    this.index = -1;
+  }
+  add(data) {
+    let prev = this.length > 0 ? this.historyStack[this.length - 1] : null;
+    let copyData = deepCopy(data);
+    if (copyData === prev) {
+      return;
+    }
+    this.historyStack.push(copyData);
+    this.length++;
+    this.index = this.length - 1;
+    this.emitChange();
+  }
+  undo() {
+    if (this.index <= 0) {
+      return;
+    }
+    this.index--;
+    this.shuttle();
+  }
+  redo() {
+    if (this.index >= this.length - 1) {
+      return;
+    }
+    this.index++;
+    this.shuttle();
+  }
+  shuttle() {
+    return __async(this, null, function* () {
+      let data = this.historyStack[this.index];
+      yield this.app.setData(data, true);
+      this.emitChange();
+      this.app.emit("change", data);
+    });
+  }
+  clear() {
+    this.index = -1;
+    this.length = 0;
+    this.historyStack = [];
+    this.emitChange();
+  }
+  emitChange() {
+    this.app.emit("shuttle", this.index, this.length);
+  }
+}
+class Export {
+  constructor(app) {
+    this.app = app;
+    this.openTest = false;
+    this.saveState = {
+      scale: 0,
+      scrollX: 0,
+      scrollY: 0,
+      width: 0,
+      height: 0
+    };
+  }
+  show(canvas) {
+    if (this.openTest) {
+      canvas.style.cssText = `
+        position: absolute;
+        left: 0;
+        top: 0;
+        background-color: #fff;
+      `;
+      document.body.appendChild(canvas);
+    }
+  }
+  getElementList(onlySelected = true) {
+    if (!onlySelected) {
+      return this.app.elements.elementList;
+    } else {
+      let selectedElements = [];
+      if (this.app.elements.activeElement) {
+        selectedElements.push(this.app.elements.activeElement);
+      } else if (this.app.selection.hasSelectionElements()) {
+        selectedElements = this.app.selection.getSelectionElements();
+      }
+      let res = this.app.elements.elementList.filter((element) => {
+        return selectedElements.includes(element);
+      });
+      return res;
+    }
+  }
+  exportImage({
+    type = "image/png",
+    renderBg = true,
+    useBlob = false,
+    paddingX = 10,
+    paddingY = 10,
+    onlySelected
+  } = {}) {
+    let { minx, maxx, miny, maxy } = getMultiElementRectInfo(this.getElementList(onlySelected));
+    let width = maxx - minx + paddingX * 2;
+    let height = maxy - miny + paddingY * 2;
+    let { canvas, ctx } = createCanvas(width, height, {
+      noStyle: true,
+      noTranslate: true
+    });
+    this.show(canvas);
+    this.saveAppState();
+    this.changeAppState(minx - paddingX, miny - paddingY, ctx);
+    if (renderBg && this.app.state.backgroundColor) {
+      this.app.background.canvasAddBackgroundColor(ctx, width, height, this.app.state.backgroundColor);
+    }
+    this.render(ctx, onlySelected);
+    this.recoveryAppState();
+    if (useBlob) {
+      return new Promise((resolve, reject) => {
+        canvas.toBlob((blob) => {
+          if (blob) {
+            resolve(blob);
+          } else {
+            reject();
+          }
+        }, type);
+      });
+    } else {
+      return canvas.toDataURL(type);
+    }
+  }
+  saveAppState() {
+    let { width, height, state, ctx } = this.app;
+    this.saveState.width = width;
+    this.saveState.height = height;
+    this.saveState.scale = state.scale;
+    this.saveState.scrollX = state.scrollX;
+    this.saveState.scrollY = state.scrollY;
+    this.saveState.ctx = ctx;
+  }
+  changeAppState(minx, miny, ctx) {
+    this.app.ctx = ctx;
+    this.app.state.scale = 1;
+    this.app.state.scrollX = 0;
+    this.app.state.scrollY = 0;
+    this.app.width = minx * 2;
+    this.app.height = miny * 2;
+  }
+  recoveryAppState() {
+    let { width, height, scale, scrollX, scrollY, ctx } = this.saveState;
+    this.app.state.scale = scale;
+    this.app.state.scrollX = scrollX;
+    this.app.state.scrollY = scrollY;
+    this.app.width = width;
+    this.app.height = height;
+    this.app.ctx = ctx;
+  }
+  render(ctx, onlySelected) {
+    ctx.save();
+    this.getElementList(onlySelected).forEach((element) => {
+      if (element.noRender) {
+        return;
+      }
+      let cacheActive = element.isActive;
+      let cacheSelected = element.isSelected;
+      element.isActive = false;
+      element.isSelected = false;
+      element.render();
+      element.isActive = cacheActive;
+      element.isSelected = cacheSelected;
+    });
+    ctx.restore();
+  }
+  exportJson() {
+    return this.app.getData();
+  }
+}
+class Background {
+  constructor(app) {
+    this.app = app;
+  }
+  set() {
+    if (this.app.state.backgroundColor) {
+      this.addBackgroundColor();
+    } else {
+      this.remove();
+    }
+  }
+  addBackgroundColor() {
+    this.app.container.style.backgroundColor = this.app.state.backgroundColor;
+  }
+  remove() {
+    this.app.container.style.backgroundColor = "";
+  }
+  canvasAddBackgroundColor(ctx, width, height, backgroundColor) {
+    ctx.save();
+    ctx.rect(0, 0, width, height);
+    ctx.fillStyle = backgroundColor;
+    ctx.fill();
+    ctx.restore();
+  }
+}
+class Canvas {
+  constructor(width, height, opt) {
+    this.width = width;
+    this.height = height;
+    let { canvas, ctx } = createCanvas(width, height, opt);
+    this.el = canvas;
+    this.ctx = ctx;
+  }
+  clearCanvas() {
+    let { width, height } = this;
+    this.ctx.clearRect(-width / 2, -height / 2, width, height);
+  }
+}
+class MultiSelectElement extends BaseElement {
+  constructor(opts = {}, app) {
+    super(opts, app);
+    this.dragElement = new DragElement(this, this.app);
+    this.selectedElementList = [];
+    this.wholeCenterPos = { x: 0, y: 0 };
+  }
+  setSelectedElementList(list) {
+    this.selectedElementList.forEach((element) => {
+      element.isSelected = false;
+    });
+    this.selectedElementList = list;
+    this.selectedElementList.forEach((element) => {
+      element.isSelected = true;
+    });
+  }
+  updateElements(elements) {
+    let exists = [];
+    this.selectedElementList.forEach((element) => {
+      if (elements.includes(element)) {
+        exists.push(element);
+      }
+    });
+    this.setSelectedElementList(exists);
+  }
+  updateRect() {
+    if (this.selectedElementList.length <= 0) {
+      super.updateRect(0, 0, 0, 0);
+      return;
+    }
+    let { minx, maxx, miny, maxy } = getMultiElementRectInfo(this.selectedElementList);
+    super.updateRect(minx, miny, maxx - minx, maxy - miny);
+  }
+  startResize(...args) {
+    this.selectedElementList.forEach((element) => {
+      if (args[0] === "rotate") {
+        this.wholeCenterPos = getElementCenterPoint(this);
+      }
+      element.startResize(...args);
+    });
+  }
+  resize(...args) {
+    this.selectedElementList.forEach((element) => {
+      if (element.dragElement.resizeType === "rotate") {
+        this.handleRotate(element, ...args);
+      } else {
+        element.resize(...args);
+      }
+    });
+  }
+  handleRotate(element, e, mx, my, offsetX, offsetY) {
+    let rotate = getTowPointRotate(this.wholeCenterPos.x, this.wholeCenterPos.y, e.clientX, e.clientY, mx, my);
+    element.rotateByCenter(rotate, this.wholeCenterPos.x, this.wholeCenterPos.y);
+  }
+  endResize() {
+    this.selectedElementList.forEach((element) => {
+      element.endResize();
+    });
+  }
+  render() {
+    if (this.selectedElementList.length > 0) {
+      if (this.width <= 0 || this.height <= 0) {
+        return;
+      }
+      this.dragElement.render();
+    }
+  }
+}
+class Selection {
+  constructor(app) {
+    this.app = app;
+    this.canvas = null;
+    this.ctx = null;
+    this.creatingSelection = false;
+    this.hasSelection = false;
+    this.isResizing = false;
+    this.state = this.app.state;
+    this.width = this.app.width;
+    this.height = this.app.height;
+    this.coordinate = new Coordinate(this);
+    this.rectangle = new Rectangle({
+      type: "rectangle",
+      style: {
+        strokeStyle: "rgba(9,132,227,0.3)",
+        fillStyle: "rgba(9,132,227,0.3)"
+      }
+    }, this);
+    this.multiSelectElement = new MultiSelectElement({
+      type: "multiSelectElement"
+    }, this);
+    this.checkInNodes = throttle(this.checkInNodes, this, 500);
+    this.handleResize = throttle(this.handleResize, this, 16);
+    this.init();
+    this.bindEvent();
+  }
+  init() {
+    if (this.canvas) {
+      this.app.container.removeChild(this.canvas.el);
+    }
+    this.width = this.app.width;
+    this.height = this.app.height;
+    this.canvas = new Canvas(this.width, this.height, {
+      className: "selection"
+    });
+    this.ctx = this.canvas.ctx;
+    this.app.container.appendChild(this.canvas.el);
+  }
+  bindEvent() {
+    this.app.on("change", () => {
+      this.state = this.app.state;
+      this.multiSelectElement.updateElements(this.app.elements.elementList);
+      this.renderSelection();
+    });
+    this.app.on("scrollChange", () => {
+      this.renderSelection();
+    });
+    this.app.on("zoomChange", () => {
+      this.renderSelection();
+    });
+  }
+  onMousedown(e, event) {
+    if (e.originEvent.which !== 1) {
+      return;
+    }
+    this.creatingSelection = true;
+    this.rectangle.updatePos(event.mousedownPos.x, event.mousedownPos.y);
+  }
+  onMousemove(e, event) {
+    if (Math.abs(event.mouseOffset.x) <= 10 && Math.abs(event.mouseOffset.y) <= 10) {
+      return;
+    }
+    this.onMove(e, event);
+  }
+  onMouseup() {
+    this.creatingSelection = false;
+    this.rectangle.updateRect(0, 0, 0, 0);
+    this.hasSelection = this.hasSelectionElements();
+    this.multiSelectElement.updateRect();
+    this.renderSelection();
+    this.emitChange();
+  }
+  reset() {
+    this.setMultiSelectElements([]);
+    this.hasSelection = false;
+    this.renderSelection();
+    this.emitChange();
+  }
+  renderSelection() {
+    this.canvas.clearCanvas();
+    this.ctx.save();
+    this.ctx.scale(this.app.state.scale, this.app.state.scale);
+    this.rectangle.render();
+    this.multiSelectElement.render();
+    this.ctx.restore();
+  }
+  onMove(e, event) {
+    this.rectangle.updateSize(event.mouseOffset.x, event.mouseOffset.y);
+    this.renderSelection();
+    this.checkInElements(e, event);
+  }
+  checkInElements(e, event) {
+    let minx = Math.min(event.mousedownPos.x, e.clientX);
+    let miny = Math.min(event.mousedownPos.y, e.clientY);
+    let maxx = Math.max(event.mousedownPos.x, e.clientX);
+    let maxy = Math.max(event.mousedownPos.y, e.clientY);
+    let selectedElementList = [];
+    this.app.elements.elementList.forEach((element) => {
+      let _minx = Infinity;
+      let _maxx = -Infinity;
+      let _miny = Infinity;
+      let _maxy = -Infinity;
+      let endPointList = element.getEndpointList();
+      let rect = getBoundingRect(endPointList.map((point) => {
+        return [point.x, point.y];
+      }), true);
+      rect.forEach(({ x, y }) => {
+        if (x < _minx) {
+          _minx = x;
+        }
+        if (x > _maxx) {
+          _maxx = x;
+        }
+        if (y < _miny) {
+          _miny = y;
+        }
+        if (y > _maxy) {
+          _maxy = y;
+        }
+      });
+      if (_minx >= minx && _maxx <= maxx && _miny >= miny && _maxy <= maxy) {
+        selectedElementList.push(element);
+      }
+    });
+    let finalList = [...selectedElementList];
+    selectedElementList.forEach((item) => {
+      if (item.hasGroup()) {
+        finalList.push(...this.app.group.getGroupElements(item));
+      }
+    });
+    finalList = new Set(finalList);
+    finalList = Array.from(finalList);
+    this.setMultiSelectElements(finalList, true);
+    this.app.render.render();
+  }
+  checkInResizeHand(x, y) {
+    return this.multiSelectElement.dragElement.checkPointInDragElementWhere(x, y);
+  }
+  checkIsResize(x, y, e) {
+    if (!this.hasSelection) {
+      return false;
+    }
+    let hand = this.multiSelectElement.dragElement.checkPointInDragElementWhere(x, y);
+    if (hand) {
+      this.isResizing = true;
+      this.multiSelectElement.startResize(hand, e);
+      this.app.cursor.setResize(hand);
+      return true;
+    }
+    return false;
+  }
+  handleResize(...args) {
+    if (!this.isResizing) {
+      return;
+    }
+    this.multiSelectElement.resize(...args);
+    this.app.render.render();
+    this.multiSelectElement.updateRect();
+    this.renderSelection();
+  }
+  endResize() {
+    this.isResizing = false;
+    this.multiSelectElement.endResize();
+  }
+  setSelectedElementStyle(style = {}) {
+    if (!this.hasSelectionElements()) {
+      return;
+    }
+    Object.keys(style).forEach((key) => {
+      this.getSelectionElements().forEach((element) => {
+        element.style[key] = style[key];
+        if (key === "fontSize" && element.type === "text") {
+          element.updateTextSize();
+          this.multiSelectElement.updateRect();
+        }
+      });
+    });
+    this.app.render.render();
+    this.app.emitChange();
+  }
+  deleteSelectedElements() {
+    this.getSelectionElements().forEach((element) => {
+      this.app.elements.deleteElement(element);
+    });
+    this.selectElements([]);
+    this.app.emitChange();
+  }
+  hasSelectionElements() {
+    return this.getSelectionElements().length > 0;
+  }
+  getSelectionElements() {
+    return this.multiSelectElement.selectedElementList;
+  }
+  copySelectionElements(pos) {
+    return __async(this, null, function* () {
+      let task = this.getSelectionElements().map((element) => {
+        return this.app.elements.copyElement(element, true);
+      });
+      this.app.group.clearCopyMap();
+      let elements = yield Promise.all(task);
+      this.setMultiSelectElements(elements);
+      if (pos) {
+        this.multiSelectElement.startResize(DRAG_ELEMENT_PARTS.BODY);
+        let ox = pos.x - this.multiSelectElement.x - this.multiSelectElement.width / 2;
+        let oy = pos.y - this.multiSelectElement.y - this.multiSelectElement.height / 2;
+        let gridAdsorbentPos = this.app.coordinate.gridAdsorbent(ox, oy);
+        this.multiSelectElement.resize(null, null, null, gridAdsorbentPos.x, gridAdsorbentPos.y);
+        this.multiSelectElement.endResize();
+        this.multiSelectElement.updateRect();
+      }
+      this.app.render.render();
+      this.renderSelection();
+      this.app.emitChange();
+    });
+  }
+  selectElements(elements = []) {
+    this.hasSelection = elements.length > 0;
+    this.setMultiSelectElements(elements);
+    this.app.render.render();
+    this.renderSelection();
+    this.emitChange();
+  }
+  setMultiSelectElements(elements = [], notUpdateRect) {
+    this.multiSelectElement.setSelectedElementList(elements);
+    if (!notUpdateRect) {
+      this.multiSelectElement.updateRect();
+    }
+  }
+  emitChange() {
+    this.app.emit("multiSelectChange", this.getSelectionElements());
+  }
+}
+class Grid {
+  constructor(app) {
+    this.app = app;
+    this.canvas = null;
+    this.ctx = null;
+    this.init();
+    this.app.on("zoomChange", this.renderGrid, this);
+    this.app.on("scrollChange", this.renderGrid, this);
+  }
+  init() {
+    if (this.canvas) {
+      this.app.container.removeChild(this.canvas.el);
+    }
+    let { width, height } = this.app;
+    this.canvas = new Canvas(width, height, {
+      className: "grid"
+    });
+    this.ctx = this.canvas.ctx;
+    this.app.container.insertBefore(this.canvas.el, this.app.container.children[0]);
+  }
+  drawHorizontalLine(i) {
+    let { coordinate, width, state } = this.app;
+    let _i = coordinate.subScrollY(i);
+    this.ctx.beginPath();
+    this.ctx.moveTo(-width / state.scale / 2, _i);
+    this.ctx.lineTo(width / state.scale / 2, _i);
+    this.ctx.stroke();
+  }
+  renderHorizontalLines() {
+    let { coordinate, height, state } = this.app;
+    let { gridConfig, scale } = state;
+    let maxBottom = 0;
+    for (let i = -height / 2; i < height / 2; i += gridConfig.size) {
+      this.drawHorizontalLine(i);
+      maxBottom = i;
+    }
+    for (let i = -height / 2 - gridConfig.size; i > -coordinate.subScrollY(height / scale / 2); i -= gridConfig.size) {
+      this.drawHorizontalLine(i);
+    }
+    for (let i = maxBottom + gridConfig.size; i < coordinate.addScrollY(height / scale / 2); i += gridConfig.size) {
+      this.drawHorizontalLine(i);
+    }
+  }
+  drawVerticalLine(i) {
+    let { coordinate, height, state } = this.app;
+    let _i = coordinate.subScrollX(i);
+    this.ctx.beginPath();
+    this.ctx.moveTo(_i, -height / state.scale / 2);
+    this.ctx.lineTo(_i, height / state.scale / 2);
+    this.ctx.stroke();
+  }
+  renderVerticalLines() {
+    let { coordinate, width, state } = this.app;
+    let { gridConfig, scale } = state;
+    let maxRight = 0;
+    for (let i = -width / 2; i < width / 2; i += gridConfig.size) {
+      this.drawVerticalLine(i);
+      maxRight = i;
+    }
+    for (let i = -width / 2 - gridConfig.size; i > -coordinate.subScrollX(width / scale / 2); i -= gridConfig.size) {
+      this.drawVerticalLine(i);
+    }
+    for (let i = maxRight + gridConfig.size; i < coordinate.addScrollX(width / scale / 2); i += gridConfig.size) {
+      this.drawVerticalLine(i);
+    }
+  }
+  renderGrid() {
+    this.canvas.clearCanvas();
+    let { gridConfig, scale, showGrid } = this.app.state;
+    if (!showGrid) {
+      return;
+    }
+    this.ctx.save();
+    this.ctx.scale(scale, scale);
+    this.ctx.strokeStyle = gridConfig.strokeStyle;
+    this.ctx.lineWidth = gridConfig.lineWidth;
+    this.renderHorizontalLines();
+    this.renderVerticalLines();
+    this.ctx.restore();
+  }
+  showGrid() {
+    this.app.updateState({
+      showGrid: true
+    });
+    this.renderGrid();
+  }
+  hideGrid() {
+    this.app.updateState({
+      showGrid: false
+    });
+    this.canvas.clearCanvas();
+  }
+  updateGrid(config = {}) {
+    this.app.updateState({
+      gridConfig: __spreadValues(__spreadValues({}, this.app.state.gridConfig), config)
+    });
+    if (this.app.state.showGrid) {
+      this.hideGrid();
+      this.showGrid();
+    }
+  }
+}
+const map = {
+  Backspace: 8,
+  Tab: 9,
+  Enter: 13,
+  Shift: 16,
+  Control: 17,
+  Alt: 18,
+  CapsLock: 20,
+  Esc: 27,
+  Space: 32,
+  PageUp: 33,
+  PageDown: 34,
+  End: 35,
+  Home: 36,
+  Insert: 45,
+  Left: 37,
+  Up: 38,
+  Right: 39,
+  Down: 40,
+  Del: 46,
+  NumLock: 144,
+  Cmd: 91,
+  CmdFF: 224,
+  F1: 112,
+  F2: 113,
+  F3: 114,
+  F4: 115,
+  F5: 116,
+  F6: 117,
+  F7: 118,
+  F8: 119,
+  F9: 120,
+  F10: 121,
+  F11: 122,
+  F12: 123,
+  "`": 192,
+  "=": 187,
+  "+": 187,
+  "-": 189,
+  "'": 222,
+  "/": 191,
+  ".": 190
+};
+for (let i = 0; i <= 9; i++) {
+  map[i] = i + 48;
+}
+"abcdefghijklmnopqrstuvwxyz".split("").forEach((n, index) => {
+  map[n] = index + 65;
+});
+const keyMap = map;
+class Mode {
+  constructor(app) {
+    this.app = app;
+    this.startScrollX = 0;
+    this.startScrollY = 0;
+    this.isDragMode = false;
+    this.onMove = throttle(this.onMove, this, 16);
+    this.bindEvent();
+  }
+  bindEvent() {
+    this.app.event.on("keydown", (e) => {
+      if (e.keyCode === keyMap.Space) {
+        this.isDragMode = true;
+        this.app.cursor.set("grab");
+      }
+    });
+    this.app.event.on("keyup", (e) => {
+      if (this.isDragMode) {
+        this.isDragMode = false;
+        this.app.cursor.set("default");
+      }
+    });
+  }
+  setEditMode() {
+    this.app.cursor.set("default");
+    this.app.updateState({
+      readonly: false
+    });
+  }
+  setReadonlyMode() {
+    this.app.cursor.set("grab");
+    this.app.updateState({
+      readonly: true
+    });
+  }
+  onStart() {
+    this.startScrollX = this.app.state.scrollX;
+    this.startScrollY = this.app.state.scrollY;
+  }
+  onMove(e, event) {
+    this.app.scrollTo(this.startScrollX - event.mouseOffset.originX / this.app.state.scale, this.startScrollY - event.mouseOffset.originY / this.app.state.scale);
+  }
+  onEnd() {
+    this.startScrollX = 0;
+    this.startScrollY = 0;
+  }
+}
+class KeyCommand {
+  constructor(app) {
+    this.app = app;
+    this.keyMap = keyMap;
+    this.shortcutMap = {};
+    this.bindEvent();
+  }
+  bindEvent() {
+    this.app.event.on("keydown", this.onKeydown, this);
+  }
+  unBindEvent() {
+    this.app.event.off("keydown", this.onKeydown);
+  }
+  onKeydown(e) {
+    Object.keys(this.shortcutMap).forEach((key) => {
+      if (this.checkKey(e, key)) {
+        e.stopPropagation();
+        e.preventDefault();
+        this.shortcutMap[key].forEach((f) => {
+          f.fn.call(f.ctx);
+        });
+      }
+    });
+  }
+  checkKey(e, key) {
+    let o = this.getOriginEventCodeArr(e);
+    let k = this.getKeyCodeArr(key);
+    if (o.length !== k.length) {
+      return false;
+    }
+    for (let i = 0; i < o.length; i++) {
+      let index = k.findIndex((item) => {
+        return item === o[i];
+      });
+      if (index === -1) {
+        return false;
+      } else {
+        k.splice(index, 1);
+      }
+    }
+    return true;
+  }
+  getOriginEventCodeArr(e) {
+    let arr = [];
+    if (e.ctrlKey || e.metaKey) {
+      arr.push(keyMap["Control"]);
+    }
+    if (e.altKey) {
+      arr.push(keyMap["Alt"]);
+    }
+    if (e.shiftKey) {
+      arr.push(keyMap["Shift"]);
+    }
+    if (!arr.includes(e.keyCode)) {
+      arr.push(e.keyCode);
+    }
+    return arr;
+  }
+  getKeyCodeArr(key) {
+    key = key.replace(/\+\+/, "+add");
+    let keyArr = key.split(/\s*\+\s*/).map((item) => {
+      return item === "add" ? "+" : item;
+    });
+    let arr = [];
+    keyArr.forEach((item) => {
+      arr.push(keyMap[item]);
+    });
+    return arr;
+  }
+  addShortcut(key, fn, ctx) {
+    key.split(/\s*\|\s*/).forEach((item) => {
+      if (this.shortcutMap[item]) {
+        this.shortcutMap[item].push({
+          fn,
+          ctx
+        });
+      } else {
+        this.shortcutMap[item] = [
+          {
+            fn,
+            ctx
+          }
+        ];
+      }
+    });
+  }
+  removeShortcut(key, fn) {
+    key.split(/\s*\|\s*/).forEach((item) => {
+      if (this.shortcutMap[item]) {
+        if (fn) {
+          let index = this.shortcutMap[item].findIndex((f) => {
+            return f.fn === fn;
+          });
+          if (index !== -1) {
+            this.shortcutMap[item].splice(index, 1);
+          }
+        } else {
+          this.shortcutMap[item] = [];
+          delete this.shortcutMap[item];
+        }
+      }
+    });
+  }
+}
+class Render {
+  constructor(app) {
+    this.app = app;
+    this.beingCopyActiveElement = null;
+    this.beingCopySelectedElements = [];
+    this.registerShortcutKeys();
+  }
+  clearCanvas() {
+    let { width, height } = this.app;
+    this.app.ctx.clearRect(-width / 2, -height / 2, width, height);
+    return this;
+  }
+  render() {
+    let { state } = this.app;
+    this.clearCanvas();
+    this.app.ctx.save();
+    this.app.ctx.scale(state.scale, state.scale);
+    this.app.elements.elementList.forEach((element) => {
+      if (element.noRender) {
+        return;
+      }
+      element.render();
+    });
+    this.app.group.render();
+    this.app.ctx.restore();
+    return this;
+  }
+  registerShortcutKeys() {
+    this.app.keyCommand.addShortcut("Del|Backspace", () => {
+      this.deleteCurrentElements();
+    });
+    this.app.keyCommand.addShortcut("Control+c", () => {
+      this.copyCurrentElement();
+    });
+    this.app.keyCommand.addShortcut("Control+x", () => {
+      this.cutCurrentElement();
+    });
+    this.app.keyCommand.addShortcut("Control+z", () => {
+      this.app.history.undo();
+    });
+    this.app.keyCommand.addShortcut("Control+y", () => {
+      this.app.history.redo();
+    });
+    this.app.keyCommand.addShortcut("Control+v", () => {
+      this.pasteCurrentElement(true);
+    });
+    this.app.keyCommand.addShortcut("Control++", () => {
+      this.zoomIn();
+    });
+    this.app.keyCommand.addShortcut("Control+-", () => {
+      this.zoomOut();
+    });
+    this.app.keyCommand.addShortcut("Shift+1", () => {
+      this.fit();
+    });
+    this.app.keyCommand.addShortcut("Control+a", () => {
+      this.selectAll();
+    });
+    this.app.keyCommand.addShortcut("Control+0", () => {
+      this.setZoom(1);
+    });
+    this.app.keyCommand.addShortcut("Control+'", () => {
+      if (this.app.state.showGrid) {
+        this.app.grid.hideGrid();
+      } else {
+        this.app.grid.showGrid();
+      }
+    });
+  }
+  copyCurrentElement() {
+    if (this.app.elements.activeElement) {
+      this.beingCopySelectedElements = [];
+      this.beingCopyElement = this.app.elements.activeElement;
+    } else if (this.app.selection.hasSelectionElements()) {
+      this.beingCopyElement = null;
+      this.beingCopySelectedElements = this.app.selection.getSelectionElements();
+    }
+  }
+  cutCurrentElement() {
+    if (this.app.elements.activeElement) {
+      this.copyCurrentElement();
+      this.deleteCurrentElements();
+    } else if (this.app.selection.hasSelectionElements()) {
+      this.copyCurrentElement();
+      this.deleteCurrentElements();
+      this.app.selection.setMultiSelectElements(this.beingCopySelectedElements);
+      this.app.selection.emitChange();
+    }
+  }
+  pasteCurrentElement(useCurrentEventPos = false) {
+    let pos = null;
+    if (useCurrentEventPos) {
+      let x = this.app.event.lastMousePos.x;
+      let y = this.app.event.lastMousePos.y;
+      pos = {
+        x,
+        y
+      };
+    }
+    if (this.beingCopyElement) {
+      this.copyElement(this.beingCopyElement, false, pos);
+    } else if (this.beingCopySelectedElements.length > 0) {
+      this.app.selection.selectElements(this.beingCopySelectedElements);
+      this.app.selection.copySelectionElements(useCurrentEventPos ? pos : null);
+    }
+  }
+  deleteElement(element) {
+    this.app.elements.deleteElement(element);
+    this.render();
+    this.app.emitChange();
+  }
+  copyElement(element, notActive = false, pos) {
+    return __async(this, null, function* () {
+      this.app.elements.cancelActiveElement();
+      yield this.app.elements.copyElement(element, notActive, pos);
+      this.app.group.clearCopyMap();
+      this.render();
+      this.app.emitChange();
+    });
+  }
+  deleteActiveElement() {
+    if (!this.app.elements.hasActiveElement()) {
+      return;
+    }
+    this.deleteElement(this.app.elements.activeElement);
+  }
+  deleteCurrentElements() {
+    this.deleteActiveElement();
+    this.app.selection.deleteSelectedElements();
+  }
+  moveUpCurrentElement() {
+    this.moveLevelCurrentElement("up");
+  }
+  moveDownCurrentElement() {
+    this.moveLevelCurrentElement("down");
+  }
+  moveTopCurrentElement() {
+    this.moveLevelCurrentElement("top");
+  }
+  moveBottomCurrentElement() {
+    this.moveLevelCurrentElement("bottom");
+  }
+  moveLevelCurrentElement(level) {
+    let element = null;
+    if (this.app.elements.hasActiveElement()) {
+      element = this.app.elements.activeElement;
+    } else if (this.app.selection.getSelectionElements().length === 1) {
+      element = this.app.selection.getSelectionElements()[0];
+    }
+    if (!element) {
+      return;
+    }
+    let index = this.app.elements.getElementIndex(element);
+    this.app.elements.elementList.splice(index, 1);
+    if (level === "up") {
+      this.app.elements.insertElement(element, index + 1);
+    } else if (level === "down") {
+      this.app.elements.insertElement(element, index - 1);
+    } else if (level === "top") {
+      this.app.elements.addElement(element);
+    } else if (level === "bottom") {
+      this.app.elements.unshiftElement(element);
+    }
+  }
+  setActiveElementStyle(style = {}) {
+    if (!this.app.elements.hasActiveElement()) {
+      return this;
+    }
+    this.app.elements.setActiveElementStyle(style);
+    this.render();
+    if (!this.app.elements.isCreatingElement) {
+      this.app.emitChange();
+    }
+    return this;
+  }
+  setCurrentElementsStyle(style = {}) {
+    this.setActiveElementStyle(style);
+    this.app.selection.setSelectedElementStyle(style);
+  }
+  cancelActiveElement() {
+    if (!this.app.elements.hasActiveElement()) {
+      return this;
+    }
+    this.app.elements.cancelActiveElement();
+    this.render();
+    return this;
+  }
+  updateActiveElementPosition(x, y) {
+    if (!this.app.elements.hasActiveElement()) {
+      return this;
+    }
+    this.app.elements.activeElement.updatePos(x, y);
+    this.render();
+    return this;
+  }
+  updateActiveElementSize(width, height) {
+    if (!this.app.elements.hasActiveElement()) {
+      return this;
+    }
+    this.app.elements.activeElement.updateSize(width, height);
+    this.render();
+    return this;
+  }
+  updateActiveElementRotate(rotate) {
+    if (!this.app.elements.hasActiveElement()) {
+      return this;
+    }
+    this.app.elements.activeElement.updateRotate(rotate);
+    this.render();
+    return this;
+  }
+  empty() {
+    this.app.elements.deleteAllElements();
+    this.render();
+    this.app.history.clear();
+    this.app.emitChange();
+  }
+  zoomIn(num = 0.1) {
+    this.app.updateState({
+      scale: this.app.state.scale + num
+    });
+    this.render();
+    this.app.emit("zoomChange", this.app.state.scale);
+  }
+  zoomOut(num = 0.1) {
+    this.app.updateState({
+      scale: this.app.state.scale - num > 0 ? this.app.state.scale - num : 0
+    });
+    this.render();
+    this.app.emit("zoomChange", this.app.state.scale);
+  }
+  setZoom(zoom) {
+    if (zoom < 0) {
+      return;
+    }
+    this.app.updateState({
+      scale: zoom
+    });
+    this.render();
+    this.app.emit("zoomChange", this.app.state.scale);
+  }
+  fit() {
+    if (!this.app.elements.hasElements()) {
+      return;
+    }
+    this.scrollToCenter();
+    let { minx, maxx, miny, maxy } = getMultiElementRectInfo(this.app.elements.elementList);
+    let width = maxx - minx;
+    let height = maxy - miny;
+    let maxScale = Math.min(this.app.width / width, this.app.height / height);
+    console.log(maxScale);
+    this.setZoom(maxScale);
+  }
+  scrollTo(scrollX, scrollY) {
+    this.app.updateState({
+      scrollX,
+      scrollY
+    });
+    this.render();
+    this.app.emit("scrollChange", this.app.state.scrollX, this.app.state.scrollY);
+  }
+  scrollToCenter() {
+    if (!this.app.elements.hasElements()) {
+      this.scrollTo(0, 0);
+      return;
+    }
+    let { minx, maxx, miny, maxy } = getMultiElementRectInfo(this.app.elements.elementList);
+    let width = maxx - minx;
+    let height = maxy - miny;
+    this.scrollTo(minx - (this.app.width - width) / 2, miny - (this.app.height - height) / 2);
+  }
+  copyPasteCurrentElements() {
+    this.copyCurrentElement();
+    this.pasteCurrentElement();
+  }
+  setBackgroundColor(color) {
+    this.app.updateState({
+      backgroundColor: color
+    });
+    this.app.background.set();
+  }
+  selectAll() {
+    this.app.selection.selectElements(this.app.elements.elementList);
+  }
+}
+class Elements {
+  constructor(app) {
+    this.app = app;
+    this.elementList = [];
+    this.activeElement = null;
+    this.isCreatingElement = false;
+    this.isResizing = false;
+    this.resizingElement = null;
+    this.handleResize = throttle(this.handleResize, this, 16);
+  }
+  serialize(stringify = false) {
+    let data = this.elementList.map((element) => {
+      return element.serialize();
+    });
+    return stringify ? JSON.stringify(data) : data;
+  }
+  getElementsNum() {
+    return this.elementList.length;
+  }
+  hasElements() {
+    return this.elementList.length > 0;
+  }
+  addElement(element) {
+    this.elementList.push(element);
+    return this;
+  }
+  unshiftElement(element) {
+    this.elementList.unshift(element);
+    return this;
+  }
+  insertElement(element, index) {
+    this.elementList.splice(index, 0, element);
+  }
+  deleteElement(element) {
+    let index = this.getElementIndex(element);
+    if (index !== -1) {
+      this.elementList.splice(index, 1);
+      if (element.isActive) {
+        this.cancelActiveElement(element);
+      }
+    }
+    return this;
+  }
+  deleteAllElements() {
+    this.activeElement = null;
+    this.elementList = [];
+    this.isCreatingElement = false;
+    this.isResizing = false;
+    this.resizingElement = null;
+    return this;
+  }
+  getElementIndex(element) {
+    return this.elementList.findIndex((item) => {
+      return item === element;
+    });
+  }
+  createElementsFromData(data) {
+    data.forEach((item) => {
+      let element = this.pureCreateElement(item);
+      element.isActive = false;
+      element.isCreating = false;
+      this.addElement(element);
+    });
+    this.app.group.initIdToElementList(this.elementList);
+    return this;
+  }
+  hasActiveElement() {
+    return !!this.activeElement;
+  }
+  setActiveElement(element) {
+    this.cancelActiveElement();
+    this.activeElement = element;
+    if (element) {
+      element.isActive = true;
+    }
+    this.app.emit("activeElementChange", this.activeElement);
+    return this;
+  }
+  cancelActiveElement() {
+    if (!this.hasActiveElement()) {
+      return this;
+    }
+    this.activeElement.isActive = false;
+    this.activeElement = null;
+    this.app.emit("activeElementChange", this.activeElement);
+    return this;
+  }
+  checkIsHitElement(e) {
+    let x = e.unGridClientX;
+    let y = e.unGridClientY;
+    for (let i = this.elementList.length - 1; i >= 0; i--) {
+      let element = this.elementList[i];
+      if (element.isHit(x, y)) {
+        return element;
+      }
+    }
+    return null;
+  }
+  pureCreateElement(opts = {}) {
+    switch (opts.type) {
+      case "rectangle":
+        return new Rectangle(opts, this.app);
+      case "diamond":
+        return new Diamond(opts, this.app);
+      case "triangle":
+        return new Triangle(opts, this.app);
+      case "circle":
+        return new Circle(opts, this.app);
+      case "freedraw":
+        return new Freedraw(opts, this.app);
+      case "image":
+        return new Image$1(opts, this.app);
+      case "arrow":
+        return new Arrow(opts, this.app);
+      case "line":
+        return new Line(opts, this.app);
+      case "text":
+        return new Text(opts, this.app);
+      default:
+        return null;
+    }
+  }
+  createElement(opts = {}, callback = () => {
+  }, ctx = null, notActive) {
+    if (this.hasActiveElement() || this.isCreatingElement) {
+      return this;
+    }
+    let element = this.pureCreateElement(opts);
+    if (!element) {
+      return this;
+    }
+    this.addElement(element);
+    if (!notActive) {
+      this.setActiveElement(element);
+    }
+    this.isCreatingElement = true;
+    callback.call(ctx, element);
+    return this;
+  }
+  copyElement(element, notActive = false, pos) {
+    return new Promise((resolve) => __async(this, null, function* () {
+      if (!element) {
+        return resolve();
+      }
+      let data = this.app.group.handleCopyElementData(element.serialize());
+      if (data.type === "image") {
+        data.imageObj = yield createImageObj(data.url);
+      }
+      this.createElement(data, (element2) => {
+        this.app.group.handleCopyElement(element2);
+        element2.startResize(DRAG_ELEMENT_PARTS.BODY);
+        let ox = 20;
+        let oy = 20;
+        if (pos) {
+          ox = pos.x - element2.x - element2.width / 2;
+          oy = pos.y - element2.y - element2.height / 2;
+        }
+        let gridAdsorbentPos = this.app.coordinate.gridAdsorbent(ox, oy);
+        element2.resize(null, null, null, gridAdsorbentPos.x, gridAdsorbentPos.y);
+        element2.isCreating = false;
+        if (notActive) {
+          element2.isActive = false;
+        }
+        this.isCreatingElement = false;
+        resolve(element2);
+      }, this, notActive);
+    }));
+  }
+  creatingRectangleLikeElement(type, x, y, offsetX, offsetY) {
+    this.createElement({
+      type,
+      x,
+      y,
+      width: offsetX,
+      height: offsetY
+    });
+    this.activeElement.updateSize(offsetX, offsetY);
+  }
+  creatingCircle(x, y, e) {
+    this.createElement({
+      type: "circle",
+      x,
+      y
+    });
+    let radius = getTowPointDistance(e.clientX, e.clientY, x, y);
+    this.activeElement.updateSize(radius, radius);
+  }
+  creatingFreedraw(e, event) {
+    this.createElement({
+      type: "freedraw"
+    });
+    let element = this.activeElement;
+    let lineWidth = computedLineWidthBySpeed(event.mouseSpeed, element.lastLineWidth);
+    element.lastLineWidth = lineWidth;
+    element.addPoint(e.clientX, e.clientY, lineWidth);
+    let { coordinate, ctx, state } = this.app;
+    let tfp = coordinate.transformToCanvasCoordinate(coordinate.subScrollX(event.lastMousePos.x), coordinate.subScrollY(event.lastMousePos.y));
+    let ttp = coordinate.transformToCanvasCoordinate(coordinate.subScrollX(e.clientX), coordinate.subScrollY(e.clientY));
+    ctx.save();
+    ctx.scale(state.scale, state.scale);
+    element.singleRender(tfp.x, tfp.y, ttp.x, ttp.y, lineWidth);
+    ctx.restore();
+  }
+  creatingImage(e, { width, height, imageObj, url, ratio }) {
+    let gp = this.app.coordinate.gridAdsorbent(e.unGridClientX - width / 2, e.unGridClientY - height / 2);
+    this.createElement({
+      type: "image",
+      x: gp.x,
+      y: gp.y,
+      url,
+      imageObj,
+      width,
+      height,
+      ratio
+    });
+  }
+  editingText(element) {
+    if (element.type !== "text") {
+      return;
+    }
+    element.noRender = true;
+    this.setActiveElement(element);
+  }
+  completeEditingText() {
+    let element = this.activeElement;
+    if (!element || element.type !== "text") {
+      return;
+    }
+    if (!element.text.trim()) {
+      this.deleteElement(element);
+      this.setActiveElement(null);
+      return;
+    }
+    element.noRender = false;
+  }
+  completeCreateArrow(e) {
+    this.activeElement.addPoint(e.clientX, e.clientY);
+  }
+  creatingArrow(x, y, e) {
+    this.createElement({
+      type: "arrow",
+      x,
+      y
+    }, (element) => {
+      element.addPoint(x, y);
+    });
+    this.activeElement.updateFictitiousPoint(e.clientX, e.clientY);
+  }
+  creatingLine(x, y, e, isSingle = false, notCreate = false) {
+    if (!notCreate) {
+      this.createElement({
+        type: "line",
+        x,
+        y,
+        isSingle
+      }, (element2) => {
+        element2.addPoint(x, y);
+      });
+    }
+    let element = this.activeElement;
+    if (element) {
+      element.updateFictitiousPoint(e.clientX, e.clientY);
+    }
+  }
+  completeCreateLine(e, completeCallback = () => {
+  }) {
+    let element = this.activeElement;
+    let x = e.clientX;
+    let y = e.clientY;
+    if (element && element.isSingle) {
+      element.addPoint(x, y);
+      completeCallback();
+    } else {
+      this.createElement({
+        type: "line",
+        isSingle: false
+      });
+      element = this.activeElement;
+      element.addPoint(x, y);
+      element.updateFictitiousPoint(x, y);
+    }
+  }
+  completeCreateElement() {
+    this.isCreatingElement = false;
+    let element = this.activeElement;
+    if (!element) {
+      return this;
+    }
+    if (["freedraw", "arrow", "line"].includes(element.type)) {
+      element.updateMultiPointBoundingRect();
+    }
+    element.isCreating = false;
+    this.app.emitChange();
+    return this;
+  }
+  setActiveElementStyle(style = {}) {
+    if (!this.hasActiveElement()) {
+      return this;
+    }
+    Object.keys(style).forEach((key) => {
+      this.activeElement.style[key] = style[key];
+      if (key === "fontSize" && this.activeElement.type === "text") {
+        this.activeElement.updateTextSize();
+      }
+    });
+    return this;
+  }
+  checkInResizeHand(x, y) {
+    let element = this.activeElement;
+    let hand = element.dragElement.checkPointInDragElementWhere(x, y);
+    if (hand) {
+      return {
+        element,
+        hand
+      };
+    }
+    return null;
+  }
+  checkIsResize(x, y, e) {
+    if (!this.hasActiveElement()) {
+      return false;
+    }
+    let res = this.checkInResizeHand(x, y);
+    if (res) {
+      this.isResizing = true;
+      this.resizingElement = res.element;
+      this.resizingElement.startResize(res.hand, e);
+      this.app.cursor.setResize(res.hand);
+      return true;
+    }
+    return false;
+  }
+  handleResize(...args) {
+    if (!this.isResizing) {
+      return;
+    }
+    this.resizingElement.resize(...args);
+    this.app.render.render();
+  }
+  endResize() {
+    this.isResizing = false;
+    this.resizingElement.endResize();
+    this.resizingElement = null;
+  }
+}
+let getRandomValues;
+const rnds8 = new Uint8Array(16);
+function rng() {
+  if (!getRandomValues) {
+    getRandomValues = typeof crypto !== "undefined" && crypto.getRandomValues && crypto.getRandomValues.bind(crypto);
+    if (!getRandomValues) {
+      throw new Error("crypto.getRandomValues() not supported. See https://github.com/uuidjs/uuid#getrandomvalues-not-supported");
+    }
+  }
+  return getRandomValues(rnds8);
+}
+const byteToHex = [];
+for (let i = 0; i < 256; ++i) {
+  byteToHex.push((i + 256).toString(16).slice(1));
+}
+function unsafeStringify(arr, offset = 0) {
+  return (byteToHex[arr[offset + 0]] + byteToHex[arr[offset + 1]] + byteToHex[arr[offset + 2]] + byteToHex[arr[offset + 3]] + "-" + byteToHex[arr[offset + 4]] + byteToHex[arr[offset + 5]] + "-" + byteToHex[arr[offset + 6]] + byteToHex[arr[offset + 7]] + "-" + byteToHex[arr[offset + 8]] + byteToHex[arr[offset + 9]] + "-" + byteToHex[arr[offset + 10]] + byteToHex[arr[offset + 11]] + byteToHex[arr[offset + 12]] + byteToHex[arr[offset + 13]] + byteToHex[arr[offset + 14]] + byteToHex[arr[offset + 15]]).toLowerCase();
+}
+const randomUUID = typeof crypto !== "undefined" && crypto.randomUUID && crypto.randomUUID.bind(crypto);
+var native = {
+  randomUUID
+};
+function v4(options, buf, offset) {
+  if (native.randomUUID && !buf && !options) {
+    return native.randomUUID();
+  }
+  options = options || {};
+  const rnds = options.random || (options.rng || rng)();
+  rnds[6] = rnds[6] & 15 | 64;
+  rnds[8] = rnds[8] & 63 | 128;
+  if (buf) {
+    offset = offset || 0;
+    for (let i = 0; i < 16; ++i) {
+      buf[offset + i] = rnds[i];
+    }
+    return buf;
+  }
+  return unsafeStringify(rnds);
+}
+class Group {
+  constructor(app) {
+    this.app = app;
+    this.groupIdToElementList = {};
+    this.newGroupIdMap = {};
+  }
+  render() {
+    Object.keys(this.groupIdToElementList).forEach((groupId) => {
+      let group = this.groupIdToElementList[groupId];
+      let selected = group[0].isSelected;
+      if (selected) {
+        let mElement = new MultiSelectElement({
+          type: "multiSelectElement"
+        }, this.app);
+        mElement.setSelectedElementList(group);
+        mElement.updateRect();
+        mElement.dragElement.onlyShowBody();
+        mElement.render();
+      }
+    });
+  }
+  setToMap(element) {
+    let groupId = element.getGroupId();
+    if (groupId) {
+      if (!this.groupIdToElementList[groupId]) {
+        this.groupIdToElementList[groupId] = [];
+      }
+      this.groupIdToElementList[groupId].push(element);
+    }
+  }
+  initIdToElementList(elementList) {
+    this.groupIdToElementList = {};
+    elementList.forEach((element) => {
+      this.setToMap(element);
+    });
+  }
+  handleCopyElementData(data) {
+    if (data.groupId) {
+      if (this.newGroupIdMap[data.groupId]) {
+        data.groupId = this.newGroupIdMap[data.groupId];
+      } else {
+        data.groupId = this.newGroupIdMap[data.groupId] = v4();
+      }
+    }
+    return data;
+  }
+  clearCopyMap() {
+    this.newGroupIdMap = {};
+  }
+  handleCopyElement(element) {
+    this.setToMap(element);
+  }
+  dogroup() {
+    if (!this.app.selection.hasSelection || this.app.selection.multiSelectElement.selectedElementList.length <= 1) {
+      return;
+    }
+    let groupElement = this.app.selection.multiSelectElement.selectedElementList;
+    let groupId = v4();
+    this.groupIdToElementList[groupId] = groupElement;
+    groupElement.forEach((element) => {
+      element.setGroupId(groupId);
+    });
+    this.app.render.render();
+    this.app.emitChange();
+  }
+  ungroup() {
+    if (!this.app.selection.hasSelection || this.app.selection.multiSelectElement.selectedElementList.length <= 1) {
+      return;
+    }
+    let groupElement = this.app.selection.multiSelectElement.selectedElementList;
+    let groupId = groupElement[0].getGroupId();
+    this.groupIdToElementList[groupId] = [];
+    delete this.groupIdToElementList[groupId];
+    groupElement.forEach((element) => {
+      element.removeGroupId(groupId);
+    });
+    this.app.render.render();
+    this.app.emitChange();
+  }
+  setSelection(element) {
+    let groupId = element.getGroupId();
+    if (this.groupIdToElementList[groupId]) {
+      this.app.selection.selectElements(this.groupIdToElementList[groupId]);
+    }
+  }
+  getGroupElements(element) {
+    let groupId = element.getGroupId();
+    return this.groupIdToElementList[groupId] || [];
+  }
+}
+class TinyWhiteboard extends EventEmitter {
+  constructor(opts = {}) {
+    super();
+    this.opts = opts;
+    this.container = opts.container;
+    this.drawType = opts.drawType || "selection";
+    if (!this.container) {
+      throw new Error("\u7F3A\u5C11 container \u53C2\u6570\uFF01");
+    }
+    if (!["absolute", "fixed", "relative"].includes(window.getComputedStyle(this.container).position)) {
+      throw new Error("container\u5143\u7D20\u9700\u8981\u8BBE\u7F6E\u5B9A\u4F4D\uFF01");
+    }
+    this.width = 0;
+    this.height = 0;
+    this.left = 0;
+    this.top = 0;
+    this.canvas = null;
+    this.ctx = null;
+    this.state = __spreadValues({
+      scale: 1,
+      scrollX: 0,
+      scrollY: 0,
+      scrollStep: 50,
+      backgroundColor: "",
+      strokeStyle: "#000000",
+      fillStyle: "transparent",
+      fontFamily: "\u5FAE\u8F6F\u96C5\u9ED1, Microsoft YaHei",
+      fontSize: 18,
+      dragStrokeStyle: "#666",
+      showGrid: false,
+      readonly: false,
+      gridConfig: {
+        size: 20,
+        strokeStyle: "#dfe0e1",
+        lineWidth: 1
+      }
+    }, opts.state || {});
+    this.initCanvas();
+    this.coordinate = new Coordinate(this);
+    this.event = new Event(this);
+    this.event.on("mousedown", this.onMousedown, this);
+    this.event.on("mousemove", this.onMousemove, this);
+    this.event.on("mouseup", this.onMouseup, this);
+    this.event.on("dblclick", this.onDblclick, this);
+    this.event.on("mousewheel", this.onMousewheel, this);
+    this.event.on("contextmenu", this.onContextmenu, this);
+    this.keyCommand = new KeyCommand(this);
+    this.imageEdit = new ImageEdit(this);
+    this.imageEdit.on("imageSelectChange", this.onImageSelectChange, this);
+    this.textEdit = new TextEdit(this);
+    this.textEdit.on("blur", this.onTextInputBlur, this);
+    this.cursor = new Cursor(this);
+    this.history = new History(this);
+    this.export = new Export(this);
+    this.background = new Background(this);
+    this.selection = new Selection(this);
+    this.group = new Group(this);
+    this.grid = new Grid(this);
+    this.mode = new Mode(this);
+    this.elements = new Elements$1(this);
+    this.render = new Render(this);
+    this.proxy();
+    this.checkIsOnElement = throttle(this.checkIsOnElement, this);
+    this.emitChange();
+    this.helpUpdate();
+  }
+  proxy() {
+    ["undo", "redo"].forEach((method) => {
+      this[method] = this.history[method].bind(this.history);
+    });
+    [].forEach((method) => {
+      this[method] = this.elements[method].bind(this.elements);
+    });
+    [
+      "deleteElement",
+      "setActiveElementStyle",
+      "setCurrentElementsStyle",
+      "cancelActiveElement",
+      "deleteActiveElement",
+      "deleteCurrentElements",
+      "empty",
+      "zoomIn",
+      "zoomOut",
+      "setZoom",
+      "scrollTo",
+      "scrollToCenter",
+      "copyPasteCurrentElements",
+      "setBackgroundColor",
+      "copyElement",
+      "copyCurrentElement",
+      "cutCurrentElement",
+      "pasteCurrentElement",
+      "updateActiveElementRotate",
+      "updateActiveElementSize",
+      "updateActiveElementPosition",
+      "moveBottomCurrentElement",
+      "moveTopCurrentElement",
+      "moveUpCurrentElement",
+      "moveDownCurrentElement",
+      "selectAll",
+      "fit"
+    ].forEach((method) => {
+      this[method] = this.render[method].bind(this.render);
+    });
+    ["exportImage", "exportJson"].forEach((method) => {
+      this[method] = this.export[method].bind(this.export);
+    });
+    ["setSelectedElementStyle"].forEach((method) => {
+      this[method] = this.selection[method].bind(this.selection);
+    });
+    ["dogroup", "ungroup"].forEach((method) => {
+      this[method] = this.group[method].bind(this.group);
+    });
+    ["showGrid", "hideGrid", "updateGrid"].forEach((method) => {
+      this[method] = this.grid[method].bind(this.grid);
+    });
+    ["setEditMode", "setReadonlyMode"].forEach((method) => {
+      this[method] = this.mode[method].bind(this.mode);
+    });
+  }
+  getContainerRectInfo() {
+    let { width, height, left, top } = this.container.getBoundingClientRect();
+    this.width = width;
+    this.height = height;
+    this.left = left;
+    this.top = top;
+  }
+  helpUpdate() {
+    this.background.set();
+    if (this.state.showGrid) {
+      this.grid.showGrid();
+    }
+    if (this.state.readonly) {
+      this.setReadonlyMode();
+    }
+  }
+  setData(_0, _1) {
+    return __async(this, arguments, function* ({ state = {}, elements = [] }, noEmitChange) {
+      this.state = state;
+      for (let i = 0; i < elements.length; i++) {
+        if (elements[i].type === "image") {
+          elements[i].imageObj = yield createImageObj(elements[i].url);
+        }
+      }
+      this.helpUpdate();
+      this.elements.deleteAllElements().createElementsFromData(elements);
+      this.render.render();
+      if (!noEmitChange) {
+        this.emitChange();
+      }
+    });
+  }
+  initCanvas() {
+    this.getContainerRectInfo();
+    if (this.canvas) {
+      this.container.removeChild(this.canvas);
+    }
+    let { canvas, ctx } = createCanvas(this.width, this.height, {
+      className: "main"
+    });
+    this.canvas = canvas;
+    this.ctx = ctx;
+    this.container.appendChild(this.canvas);
+  }
+  resize() {
+    this.initCanvas();
+    this.render.render();
+    this.selection.init();
+    this.grid.init();
+    this.grid.renderGrid();
+  }
+  updateState(data = {}) {
+    this.state = __spreadValues(__spreadValues({}, this.state), data);
+    this.emitChange();
+  }
+  updateCurrentType(drawType) {
+    this.drawType = drawType;
+    if (this.drawType === "image") {
+      this.imageEdit.selectImage();
+    }
+    if (this.drawType === "eraser") {
+      this.cursor.setEraser();
+      this.cancelActiveElement();
+    } else if (this.drawType !== "selection") {
+      this.cursor.setCrosshair();
+    } else {
+      this.cursor.reset();
+    }
+    this.emit("currentTypeChange", this.drawType);
+  }
+  getData() {
+    return {
+      state: __spreadValues({}, this.state),
+      elements: this.elements.serialize()
+    };
+  }
+  onImageSelectChange() {
+    this.cursor.hide();
+  }
+  onMousedown(e, event) {
+    if (this.state.readonly || this.mode.isDragMode) {
+      this.mode.onStart();
+      return;
+    }
+    if (!this.elements.isCreatingElement && !this.textEdit.isEditing) {
+      let hitElement = this.elements.checkIsHitElement(e);
+      if (this.drawType === "selection") {
+        if (this.elements.hasActiveElement()) {
+          let isResizing = this.elements.checkIsResize(event.mousedownPos.unGridClientX, event.mousedownPos.unGridClientY, e);
+          if (!isResizing) {
+            this.elements.setActiveElement(hitElement);
+            this.render.render();
+          }
+        } else {
+          if (this.selection.hasSelection) {
+            let isResizing = this.selection.checkIsResize(event.mousedownPos.unGridClientX, event.mousedownPos.unGridClientY, e);
+            if (!isResizing) {
+              this.selection.reset();
+              this.elements.setActiveElement(hitElement);
+              this.render.render();
+            }
+          } else if (hitElement) {
+            if (hitElement.hasGroup()) {
+              this.group.setSelection(hitElement);
+              this.onMousedown(e, event);
+            } else {
+              this.elements.setActiveElement(hitElement);
+              this.render.render();
+              this.onMousedown(e, event);
+            }
+          } else {
+            this.selection.onMousedown(e, event);
+          }
+        }
+      } else if (this.drawType === "eraser") {
+        this.deleteElement(hitElement);
+      }
+    }
+  }
+  onMousemove(e, event) {
+    if (this.state.readonly || this.mode.isDragMode) {
+      if (event.isMousedown) {
+        this.mode.onMove(e, event);
+      }
+      return;
+    }
+    if (event.isMousedown) {
+      let mx = event.mousedownPos.x;
+      let my = event.mousedownPos.y;
+      let offsetX = Math.max(event.mouseOffset.x, 0);
+      let offsetY = Math.max(event.mouseOffset.y, 0);
+      if (this.drawType === "selection") {
+        if (this.selection.isResizing) {
+          this.selection.handleResize(e, mx, my, event.mouseOffset.x, event.mouseOffset.y);
+        } else if (this.selection.creatingSelection) {
+          this.selection.onMousemove(e, event);
+        } else {
+          this.elements.handleResize(e, mx, my, event.mouseOffset.x, event.mouseOffset.y);
+        }
+      } else if (["rectangle", "diamond", "triangle"].includes(this.drawType)) {
+        this.elements.creatingRectangleLikeElement(this.drawType, mx, my, offsetX, offsetY);
+        this.render.render();
+      } else if (this.drawType === "circle") {
+        this.elements.creatingCircle(mx, my, e);
+        this.render.render();
+      } else if (this.drawType === "freedraw") {
+        this.elements.creatingFreedraw(e, event);
+      } else if (this.drawType === "arrow") {
+        this.elements.creatingArrow(mx, my, e);
+        this.render.render();
+      } else if (this.drawType === "line") {
+        if (getTowPointDistance(mx, my, e.clientX, e.clientY) > 3) {
+          this.elements.creatingLine(mx, my, e, true);
+          this.render.render();
+        }
+      }
+    } else {
+      if (this.imageEdit.isReady) {
+        this.cursor.hide();
+        this.imageEdit.updatePreviewElPos(e.originEvent.clientX, e.originEvent.clientY);
+      } else if (this.drawType === "selection") {
+        if (this.elements.hasActiveElement()) {
+          let handData = "";
+          if (handData = this.elements.checkInResizeHand(e.unGridClientX, e.unGridClientY)) {
+            this.cursor.setResize(handData.hand);
+          } else {
+            this.checkIsOnElement(e);
+          }
+        } else if (this.selection.hasSelection) {
+          let hand = this.selection.checkInResizeHand(e.unGridClientX, e.unGridClientY);
+          if (hand) {
+            this.cursor.setResize(hand);
+          } else {
+            this.checkIsOnElement(e);
+          }
+        } else {
+          this.checkIsOnElement(e);
+        }
+      } else if (this.drawType === "line") {
+        this.elements.creatingLine(null, null, e, false, true);
+        this.render.render();
+      }
+    }
+  }
+  checkIsOnElement(e) {
+    let hitElement = this.elements.checkIsHitElement(e);
+    if (hitElement) {
+      this.cursor.setMove();
+    } else {
+      this.cursor.reset();
+    }
+  }
+  resetCurrentType() {
+    if (this.drawType !== "selection") {
+      this.drawType = "selection";
+      this.emit("currentTypeChange", "selection");
+    }
+  }
+  completeCreateNewElement() {
+    this.resetCurrentType();
+    this.elements.completeCreateElement();
+    this.render.render();
+  }
+  onMouseup(e) {
+    if (this.state.readonly || this.mode.isDragMode) {
+      return;
+    }
+    if (this.drawType === "text") {
+      if (!this.textEdit.isEditing) {
+        this.createTextElement(e);
+        this.resetCurrentType();
+      }
+    } else if (this.imageEdit.isReady) {
+      this.elements.creatingImage(e, this.imageEdit.imageData);
+      this.completeCreateNewElement();
+      this.cursor.reset();
+      this.imageEdit.reset();
+    } else if (this.drawType === "arrow") {
+      this.elements.completeCreateArrow(e);
+      this.completeCreateNewElement();
+    } else if (this.drawType === "line") {
+      this.elements.completeCreateLine(e, () => {
+        this.completeCreateNewElement();
+      });
+      this.render.render();
+    } else if (this.elements.isCreatingElement) {
+      if (this.drawType === "freedraw") {
+        this.elements.completeCreateElement();
+        this.elements.setActiveElement();
+      } else {
+        this.completeCreateNewElement();
+      }
+    } else if (this.elements.isResizing) {
+      this.elements.endResize();
+      this.emitChange();
+    } else if (this.selection.creatingSelection) {
+      this.selection.onMouseup(e);
+    } else if (this.selection.isResizing) {
+      this.selection.endResize();
+      this.emitChange();
+    }
+  }
+  onDblclick(e) {
+    if (this.drawType === "line") {
+      this.completeCreateNewElement();
+    } else {
+      let hitElement = this.elements.checkIsHitElement(e);
+      if (hitElement) {
+        if (hitElement.type === "text") {
+          this.elements.editingText(hitElement);
+          this.render.render();
+          this.keyCommand.unBindEvent();
+          this.textEdit.showTextEdit();
+        }
+      } else {
+        if (!this.textEdit.isEditing) {
+          this.createTextElement(e);
+        }
+      }
+    }
+  }
+  onTextInputBlur() {
+    this.keyCommand.bindEvent();
+    this.elements.completeEditingText();
+    this.render.render();
+    this.emitChange();
+  }
+  createTextElement(e) {
+    this.elements.createElement({
+      type: "text",
+      x: e.clientX,
+      y: e.clientY
+    });
+    this.keyCommand.unBindEvent();
+    this.textEdit.showTextEdit();
+  }
+  onMousewheel(dir) {
+    let stepNum = this.state.scrollStep / this.state.scale;
+    let step = dir === "down" ? stepNum : -stepNum;
+    this.scrollTo(this.state.scrollX, this.state.scrollY + step);
+  }
+  onContextmenu(e) {
+    let elements = [];
+    if (this.elements.hasActiveElement()) {
+      elements = [this.elements.activeElement];
+    } else if (this.selection.hasSelectionElements()) {
+      elements = this.selection.getSelectionElements();
+    }
+    this.emit("contextmenu", e.originEvent, elements);
+  }
+  emitChange() {
+    let data = this.getData();
+    this.history.add(data);
+    this.emit("change", data);
+  }
+}
+TinyWhiteboard.utils = utils;
+TinyWhiteboard.checkHit = checkHit;
+TinyWhiteboard.draw = draw;
+TinyWhiteboard.elements = Elements;
+export { TinyWhiteboard as default };

Những thai đổi đã bị hủy bỏ vì nó quá lớn
+ 0 - 0
tiny-whiteboard/dist/tiny-whiteboard.umd.js


+ 581 - 0
tiny-whiteboard/package-lock.json

@@ -0,0 +1,581 @@
+{
+  "name": "tiny-whiteboard",
+  "version": "0.1.12",
+  "lockfileVersion": 3,
+  "requires": true,
+  "packages": {
+    "": {
+      "name": "tiny-whiteboard",
+      "version": "0.1.12",
+      "license": "MIT",
+      "dependencies": {
+        "eventemitter3": "^4.0.7",
+        "uuid": "^9.0.0"
+      },
+      "devDependencies": {
+        "prettier": "^2.7.1",
+        "vite": "^2.9.5"
+      }
+    },
+    "node_modules/@esbuild/linux-loong64": {
+      "version": "0.14.54",
+      "resolved": "https://registry.npmmirror.com/@esbuild/linux-loong64/-/linux-loong64-0.14.54.tgz",
+      "integrity": "sha512-bZBrLAIX1kpWelV0XemxBZllyRmM6vgFQQG2GdNb+r3Fkp0FOh1NJSvekXDs7jq70k4euu1cryLMfU+mTXlEpw==",
+      "cpu": [
+        "loong64"
+      ],
+      "dev": true,
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/esbuild": {
+      "version": "0.14.54",
+      "resolved": "https://registry.npmmirror.com/esbuild/-/esbuild-0.14.54.tgz",
+      "integrity": "sha512-Cy9llcy8DvET5uznocPyqL3BFRrFXSVqbgpMJ9Wz8oVjZlh/zUSNbPRbov0VX7VxN2JH1Oa0uNxZ7eLRb62pJA==",
+      "dev": true,
+      "hasInstallScript": true,
+      "bin": {
+        "esbuild": "bin/esbuild"
+      },
+      "engines": {
+        "node": ">=12"
+      },
+      "optionalDependencies": {
+        "@esbuild/linux-loong64": "0.14.54",
+        "esbuild-android-64": "0.14.54",
+        "esbuild-android-arm64": "0.14.54",
+        "esbuild-darwin-64": "0.14.54",
+        "esbuild-darwin-arm64": "0.14.54",
+        "esbuild-freebsd-64": "0.14.54",
+        "esbuild-freebsd-arm64": "0.14.54",
+        "esbuild-linux-32": "0.14.54",
+        "esbuild-linux-64": "0.14.54",
+        "esbuild-linux-arm": "0.14.54",
+        "esbuild-linux-arm64": "0.14.54",
+        "esbuild-linux-mips64le": "0.14.54",
+        "esbuild-linux-ppc64le": "0.14.54",
+        "esbuild-linux-riscv64": "0.14.54",
+        "esbuild-linux-s390x": "0.14.54",
+        "esbuild-netbsd-64": "0.14.54",
+        "esbuild-openbsd-64": "0.14.54",
+        "esbuild-sunos-64": "0.14.54",
+        "esbuild-windows-32": "0.14.54",
+        "esbuild-windows-64": "0.14.54",
+        "esbuild-windows-arm64": "0.14.54"
+      }
+    },
+    "node_modules/esbuild-android-64": {
+      "version": "0.14.54",
+      "resolved": "https://registry.npmmirror.com/esbuild-android-64/-/esbuild-android-64-0.14.54.tgz",
+      "integrity": "sha512-Tz2++Aqqz0rJ7kYBfz+iqyE3QMycD4vk7LBRyWaAVFgFtQ/O8EJOnVmTOiDWYZ/uYzB4kvP+bqejYdVKzE5lAQ==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "optional": true,
+      "os": [
+        "android"
+      ],
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/esbuild-android-arm64": {
+      "version": "0.14.54",
+      "resolved": "https://registry.npmmirror.com/esbuild-android-arm64/-/esbuild-android-arm64-0.14.54.tgz",
+      "integrity": "sha512-F9E+/QDi9sSkLaClO8SOV6etqPd+5DgJje1F9lOWoNncDdOBL2YF59IhsWATSt0TLZbYCf3pNlTHvVV5VfHdvg==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "optional": true,
+      "os": [
+        "android"
+      ],
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/esbuild-darwin-64": {
+      "version": "0.14.54",
+      "resolved": "https://registry.npmmirror.com/esbuild-darwin-64/-/esbuild-darwin-64-0.14.54.tgz",
+      "integrity": "sha512-jtdKWV3nBviOd5v4hOpkVmpxsBy90CGzebpbO9beiqUYVMBtSc0AL9zGftFuBon7PNDcdvNCEuQqw2x0wP9yug==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "optional": true,
+      "os": [
+        "darwin"
+      ],
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/esbuild-darwin-arm64": {
+      "version": "0.14.54",
+      "resolved": "https://registry.npmmirror.com/esbuild-darwin-arm64/-/esbuild-darwin-arm64-0.14.54.tgz",
+      "integrity": "sha512-OPafJHD2oUPyvJMrsCvDGkRrVCar5aVyHfWGQzY1dWnzErjrDuSETxwA2HSsyg2jORLY8yBfzc1MIpUkXlctmw==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "optional": true,
+      "os": [
+        "darwin"
+      ],
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/esbuild-freebsd-64": {
+      "version": "0.14.54",
+      "resolved": "https://registry.npmmirror.com/esbuild-freebsd-64/-/esbuild-freebsd-64-0.14.54.tgz",
+      "integrity": "sha512-OKwd4gmwHqOTp4mOGZKe/XUlbDJ4Q9TjX0hMPIDBUWWu/kwhBAudJdBoxnjNf9ocIB6GN6CPowYpR/hRCbSYAg==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "optional": true,
+      "os": [
+        "freebsd"
+      ],
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/esbuild-freebsd-arm64": {
+      "version": "0.14.54",
+      "resolved": "https://registry.npmmirror.com/esbuild-freebsd-arm64/-/esbuild-freebsd-arm64-0.14.54.tgz",
+      "integrity": "sha512-sFwueGr7OvIFiQT6WeG0jRLjkjdqWWSrfbVwZp8iMP+8UHEHRBvlaxL6IuKNDwAozNUmbb8nIMXa7oAOARGs1Q==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "optional": true,
+      "os": [
+        "freebsd"
+      ],
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/esbuild-linux-32": {
+      "version": "0.14.54",
+      "resolved": "https://registry.npmmirror.com/esbuild-linux-32/-/esbuild-linux-32-0.14.54.tgz",
+      "integrity": "sha512-1ZuY+JDI//WmklKlBgJnglpUL1owm2OX+8E1syCD6UAxcMM/XoWd76OHSjl/0MR0LisSAXDqgjT3uJqT67O3qw==",
+      "cpu": [
+        "ia32"
+      ],
+      "dev": true,
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/esbuild-linux-64": {
+      "version": "0.14.54",
+      "resolved": "https://registry.npmmirror.com/esbuild-linux-64/-/esbuild-linux-64-0.14.54.tgz",
+      "integrity": "sha512-EgjAgH5HwTbtNsTqQOXWApBaPVdDn7XcK+/PtJwZLT1UmpLoznPd8c5CxqsH2dQK3j05YsB3L17T8vE7cp4cCg==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/esbuild-linux-arm": {
+      "version": "0.14.54",
+      "resolved": "https://registry.npmmirror.com/esbuild-linux-arm/-/esbuild-linux-arm-0.14.54.tgz",
+      "integrity": "sha512-qqz/SjemQhVMTnvcLGoLOdFpCYbz4v4fUo+TfsWG+1aOu70/80RV6bgNpR2JCrppV2moUQkww+6bWxXRL9YMGw==",
+      "cpu": [
+        "arm"
+      ],
+      "dev": true,
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/esbuild-linux-arm64": {
+      "version": "0.14.54",
+      "resolved": "https://registry.npmmirror.com/esbuild-linux-arm64/-/esbuild-linux-arm64-0.14.54.tgz",
+      "integrity": "sha512-WL71L+0Rwv+Gv/HTmxTEmpv0UgmxYa5ftZILVi2QmZBgX3q7+tDeOQNqGtdXSdsL8TQi1vIaVFHUPDe0O0kdig==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/esbuild-linux-mips64le": {
+      "version": "0.14.54",
+      "resolved": "https://registry.npmmirror.com/esbuild-linux-mips64le/-/esbuild-linux-mips64le-0.14.54.tgz",
+      "integrity": "sha512-qTHGQB8D1etd0u1+sB6p0ikLKRVuCWhYQhAHRPkO+OF3I/iSlTKNNS0Lh2Oc0g0UFGguaFZZiPJdJey3AGpAlw==",
+      "cpu": [
+        "mips64el"
+      ],
+      "dev": true,
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/esbuild-linux-ppc64le": {
+      "version": "0.14.54",
+      "resolved": "https://registry.npmmirror.com/esbuild-linux-ppc64le/-/esbuild-linux-ppc64le-0.14.54.tgz",
+      "integrity": "sha512-j3OMlzHiqwZBDPRCDFKcx595XVfOfOnv68Ax3U4UKZ3MTYQB5Yz3X1mn5GnodEVYzhtZgxEBidLWeIs8FDSfrQ==",
+      "cpu": [
+        "ppc64"
+      ],
+      "dev": true,
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/esbuild-linux-riscv64": {
+      "version": "0.14.54",
+      "resolved": "https://registry.npmmirror.com/esbuild-linux-riscv64/-/esbuild-linux-riscv64-0.14.54.tgz",
+      "integrity": "sha512-y7Vt7Wl9dkOGZjxQZnDAqqn+XOqFD7IMWiewY5SPlNlzMX39ocPQlOaoxvT4FllA5viyV26/QzHtvTjVNOxHZg==",
+      "cpu": [
+        "riscv64"
+      ],
+      "dev": true,
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/esbuild-linux-s390x": {
+      "version": "0.14.54",
+      "resolved": "https://registry.npmmirror.com/esbuild-linux-s390x/-/esbuild-linux-s390x-0.14.54.tgz",
+      "integrity": "sha512-zaHpW9dziAsi7lRcyV4r8dhfG1qBidQWUXweUjnw+lliChJqQr+6XD71K41oEIC3Mx1KStovEmlzm+MkGZHnHA==",
+      "cpu": [
+        "s390x"
+      ],
+      "dev": true,
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/esbuild-netbsd-64": {
+      "version": "0.14.54",
+      "resolved": "https://registry.npmmirror.com/esbuild-netbsd-64/-/esbuild-netbsd-64-0.14.54.tgz",
+      "integrity": "sha512-PR01lmIMnfJTgeU9VJTDY9ZerDWVFIUzAtJuDHwwceppW7cQWjBBqP48NdeRtoP04/AtO9a7w3viI+PIDr6d+w==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "optional": true,
+      "os": [
+        "netbsd"
+      ],
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/esbuild-openbsd-64": {
+      "version": "0.14.54",
+      "resolved": "https://registry.npmmirror.com/esbuild-openbsd-64/-/esbuild-openbsd-64-0.14.54.tgz",
+      "integrity": "sha512-Qyk7ikT2o7Wu76UsvvDS5q0amJvmRzDyVlL0qf5VLsLchjCa1+IAvd8kTBgUxD7VBUUVgItLkk609ZHUc1oCaw==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "optional": true,
+      "os": [
+        "openbsd"
+      ],
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/esbuild-sunos-64": {
+      "version": "0.14.54",
+      "resolved": "https://registry.npmmirror.com/esbuild-sunos-64/-/esbuild-sunos-64-0.14.54.tgz",
+      "integrity": "sha512-28GZ24KmMSeKi5ueWzMcco6EBHStL3B6ubM7M51RmPwXQGLe0teBGJocmWhgwccA1GeFXqxzILIxXpHbl9Q/Kw==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "optional": true,
+      "os": [
+        "sunos"
+      ],
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/esbuild-windows-32": {
+      "version": "0.14.54",
+      "resolved": "https://registry.npmmirror.com/esbuild-windows-32/-/esbuild-windows-32-0.14.54.tgz",
+      "integrity": "sha512-T+rdZW19ql9MjS7pixmZYVObd9G7kcaZo+sETqNH4RCkuuYSuv9AGHUVnPoP9hhuE1WM1ZimHz1CIBHBboLU7w==",
+      "cpu": [
+        "ia32"
+      ],
+      "dev": true,
+      "optional": true,
+      "os": [
+        "win32"
+      ],
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/esbuild-windows-64": {
+      "version": "0.14.54",
+      "resolved": "https://registry.npmmirror.com/esbuild-windows-64/-/esbuild-windows-64-0.14.54.tgz",
+      "integrity": "sha512-AoHTRBUuYwXtZhjXZbA1pGfTo8cJo3vZIcWGLiUcTNgHpJJMC1rVA44ZereBHMJtotyN71S8Qw0npiCIkW96cQ==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "optional": true,
+      "os": [
+        "win32"
+      ],
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/esbuild-windows-arm64": {
+      "version": "0.14.54",
+      "resolved": "https://registry.npmmirror.com/esbuild-windows-arm64/-/esbuild-windows-arm64-0.14.54.tgz",
+      "integrity": "sha512-M0kuUvXhot1zOISQGXwWn6YtS+Y/1RT9WrVIOywZnJHo3jCDyewAc79aKNQWFCQm+xNHVTq9h8dZKvygoXQQRg==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "optional": true,
+      "os": [
+        "win32"
+      ],
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/eventemitter3": {
+      "version": "4.0.7",
+      "resolved": "https://registry.npmmirror.com/eventemitter3/-/eventemitter3-4.0.7.tgz",
+      "integrity": "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw=="
+    },
+    "node_modules/fsevents": {
+      "version": "2.3.3",
+      "resolved": "https://registry.npmmirror.com/fsevents/-/fsevents-2.3.3.tgz",
+      "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
+      "dev": true,
+      "hasInstallScript": true,
+      "optional": true,
+      "os": [
+        "darwin"
+      ],
+      "engines": {
+        "node": "^8.16.0 || ^10.6.0 || >=11.0.0"
+      }
+    },
+    "node_modules/function-bind": {
+      "version": "1.1.2",
+      "resolved": "https://registry.npmmirror.com/function-bind/-/function-bind-1.1.2.tgz",
+      "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==",
+      "dev": true
+    },
+    "node_modules/hasown": {
+      "version": "2.0.2",
+      "resolved": "https://registry.npmmirror.com/hasown/-/hasown-2.0.2.tgz",
+      "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==",
+      "dev": true,
+      "dependencies": {
+        "function-bind": "^1.1.2"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      }
+    },
+    "node_modules/is-core-module": {
+      "version": "2.13.1",
+      "resolved": "https://registry.npmmirror.com/is-core-module/-/is-core-module-2.13.1.tgz",
+      "integrity": "sha512-hHrIjvZsftOsvKSn2TRYl63zvxsgE0K+0mYMoH6gD4omR5IWB2KynivBQczo3+wF1cCkjzvptnI9Q0sPU66ilw==",
+      "dev": true,
+      "dependencies": {
+        "hasown": "^2.0.0"
+      }
+    },
+    "node_modules/nanoid": {
+      "version": "3.3.7",
+      "resolved": "https://registry.npmmirror.com/nanoid/-/nanoid-3.3.7.tgz",
+      "integrity": "sha512-eSRppjcPIatRIMC1U6UngP8XFcz8MQWGQdt1MTBQ7NaAmvXDfvNxbvWV3x2y6CdEUciCSsDHDQZbhYaB8QEo2g==",
+      "dev": true,
+      "bin": {
+        "nanoid": "bin/nanoid.cjs"
+      },
+      "engines": {
+        "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
+      }
+    },
+    "node_modules/path-parse": {
+      "version": "1.0.7",
+      "resolved": "https://registry.npmmirror.com/path-parse/-/path-parse-1.0.7.tgz",
+      "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==",
+      "dev": true
+    },
+    "node_modules/picocolors": {
+      "version": "1.0.0",
+      "resolved": "https://registry.npmmirror.com/picocolors/-/picocolors-1.0.0.tgz",
+      "integrity": "sha512-1fygroTLlHu66zi26VoTDv8yRgm0Fccecssto+MhsZ0D/DGW2sm8E8AjW7NU5VVTRt5GxbeZ5qBuJr+HyLYkjQ==",
+      "dev": true
+    },
+    "node_modules/postcss": {
+      "version": "8.4.38",
+      "resolved": "https://registry.npmmirror.com/postcss/-/postcss-8.4.38.tgz",
+      "integrity": "sha512-Wglpdk03BSfXkHoQa3b/oulrotAkwrlLDRSOb9D0bN86FdRyE9lppSp33aHNPgBa0JKCoB+drFLZkQoRRYae5A==",
+      "dev": true,
+      "dependencies": {
+        "nanoid": "^3.3.7",
+        "picocolors": "^1.0.0",
+        "source-map-js": "^1.2.0"
+      },
+      "engines": {
+        "node": "^10 || ^12 || >=14"
+      }
+    },
+    "node_modules/prettier": {
+      "version": "2.8.8",
+      "resolved": "https://registry.npmmirror.com/prettier/-/prettier-2.8.8.tgz",
+      "integrity": "sha512-tdN8qQGvNjw4CHbY+XXk0JgCXn9QiF21a55rBe5LJAU+kDyC4WQn4+awm2Xfk2lQMk5fKup9XgzTZtGkjBdP9Q==",
+      "dev": true,
+      "bin": {
+        "prettier": "bin-prettier.js"
+      },
+      "engines": {
+        "node": ">=10.13.0"
+      }
+    },
+    "node_modules/resolve": {
+      "version": "1.22.8",
+      "resolved": "https://registry.npmmirror.com/resolve/-/resolve-1.22.8.tgz",
+      "integrity": "sha512-oKWePCxqpd6FlLvGV1VU0x7bkPmmCNolxzjMf4NczoDnQcIWrAF+cPtZn5i6n+RfD2d9i0tzpKnG6Yk168yIyw==",
+      "dev": true,
+      "dependencies": {
+        "is-core-module": "^2.13.0",
+        "path-parse": "^1.0.7",
+        "supports-preserve-symlinks-flag": "^1.0.0"
+      },
+      "bin": {
+        "resolve": "bin/resolve"
+      }
+    },
+    "node_modules/rollup": {
+      "version": "2.77.3",
+      "resolved": "https://registry.npmmirror.com/rollup/-/rollup-2.77.3.tgz",
+      "integrity": "sha512-/qxNTG7FbmefJWoeeYJFbHehJ2HNWnjkAFRKzWN/45eNBBF/r8lo992CwcJXEzyVxs5FmfId+vTSTQDb+bxA+g==",
+      "dev": true,
+      "bin": {
+        "rollup": "dist/bin/rollup"
+      },
+      "engines": {
+        "node": ">=10.0.0"
+      },
+      "optionalDependencies": {
+        "fsevents": "~2.3.2"
+      }
+    },
+    "node_modules/source-map-js": {
+      "version": "1.2.0",
+      "resolved": "https://registry.npmmirror.com/source-map-js/-/source-map-js-1.2.0.tgz",
+      "integrity": "sha512-itJW8lvSA0TXEphiRoawsCksnlf8SyvmFzIhltqAHluXd88pkCd+cXJVHTDwdCr0IzwptSm035IHQktUu1QUMg==",
+      "dev": true,
+      "engines": {
+        "node": ">=0.10.0"
+      }
+    },
+    "node_modules/supports-preserve-symlinks-flag": {
+      "version": "1.0.0",
+      "resolved": "https://registry.npmmirror.com/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz",
+      "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==",
+      "dev": true,
+      "engines": {
+        "node": ">= 0.4"
+      }
+    },
+    "node_modules/uuid": {
+      "version": "9.0.1",
+      "resolved": "https://registry.npmmirror.com/uuid/-/uuid-9.0.1.tgz",
+      "integrity": "sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==",
+      "bin": {
+        "uuid": "dist/bin/uuid"
+      }
+    },
+    "node_modules/vite": {
+      "version": "2.9.18",
+      "resolved": "https://registry.npmmirror.com/vite/-/vite-2.9.18.tgz",
+      "integrity": "sha512-sAOqI5wNM9QvSEE70W3UGMdT8cyEn0+PmJMTFvTB8wB0YbYUWw3gUbY62AOyrXosGieF2htmeLATvNxpv/zNyQ==",
+      "dev": true,
+      "dependencies": {
+        "esbuild": "^0.14.27",
+        "postcss": "^8.4.13",
+        "resolve": "^1.22.0",
+        "rollup": ">=2.59.0 <2.78.0"
+      },
+      "bin": {
+        "vite": "bin/vite.js"
+      },
+      "engines": {
+        "node": ">=12.2.0"
+      },
+      "optionalDependencies": {
+        "fsevents": "~2.3.2"
+      },
+      "peerDependencies": {
+        "less": "*",
+        "sass": "*",
+        "stylus": "*"
+      },
+      "peerDependenciesMeta": {
+        "less": {
+          "optional": true
+        },
+        "sass": {
+          "optional": true
+        },
+        "stylus": {
+          "optional": true
+        }
+      }
+    }
+  }
+}

+ 33 - 0
tiny-whiteboard/package.json

@@ -0,0 +1,33 @@
+{
+  "name": "tiny-whiteboard",
+  "version": "0.1.12",
+  "description": "一个简单的在线白板",
+  "authors": [
+    {
+      "name": "街角小林",
+      "email": "1013335014@qq.com"
+    },
+    {
+      "name": "理想青年实验室",
+      "url": "http://lxqnsys.com/"
+    }
+  ],
+  "license": "MIT",
+  "repository": {
+    "type": "git",
+    "url": "https://github.com/wanglin2/tiny_whiteboard"
+  },
+  "scripts": {
+    "build": "vite build",
+    "format": "prettier --write ."
+  },
+  "main": "./src/index.js",
+  "dependencies": {
+    "eventemitter3": "^4.0.7",
+    "uuid": "^9.0.0"
+  },
+  "devDependencies": {
+    "prettier": "^2.7.1",
+    "vite": "^2.9.5"
+  }
+}

+ 35 - 0
tiny-whiteboard/src/Background.js

@@ -0,0 +1,35 @@
+// 背景
+export default class Background {
+  constructor(app) {
+    this.app = app
+  }
+
+  // 设置背景
+  set() {
+    if (this.app.state.backgroundColor) {
+      this.addBackgroundColor()
+    } else {
+      this.remove()
+    }
+  }
+
+  // 添加背景颜色
+  addBackgroundColor() {
+    this.app.container.style.backgroundColor = this.app.state.backgroundColor
+  }
+
+  // 移除背景
+  remove() {
+    this.app.container.style.backgroundColor = ''
+  }
+
+  // 在canvas内设置背景颜色,非css样式
+  canvasAddBackgroundColor(ctx, width, height, backgroundColor) {
+    // 背景颜色
+    ctx.save()
+    ctx.rect(0, 0, width, height)
+    ctx.fillStyle = backgroundColor
+    ctx.fill()
+    ctx.restore()
+  }
+}

+ 18 - 0
tiny-whiteboard/src/Canvas.js

@@ -0,0 +1,18 @@
+import { createCanvas } from './utils'
+
+// 画布类
+export default class Canvas {
+  constructor(width, height, opt) {
+    this.width = width
+    this.height = height
+    let { canvas, ctx } = createCanvas(width, height, opt)
+    this.el = canvas
+    this.ctx = ctx
+  }
+
+  // 清除画布
+  clearCanvas() {
+    let { width, height } = this
+    this.ctx.clearRect(-width / 2, -height / 2, width, height)
+  }
+}

+ 117 - 0
tiny-whiteboard/src/Coordinate.js

@@ -0,0 +1,117 @@
+// 坐标转换类
+export default class Coordinate {
+  constructor(app) {
+    this.app = app
+  }
+
+  // 添加垂直滚动距离
+  addScrollY(y) {
+    return y + this.app.state.scrollY
+  }
+
+  // 添加水平滚动距离
+  addScrollX(x) {
+    return x + this.app.state.scrollX
+  }
+
+  // 减去垂直滚动距离
+  subScrollY(y) {
+    return y - this.app.state.scrollY
+  }
+
+  // 减去水平滚动距离
+  subScrollX(x) {
+    return x - this.app.state.scrollX
+  }
+
+  // 屏幕坐标转换成画布坐标
+  transformToCanvasCoordinate(x, y) {
+    x -= this.app.width / 2
+    y -= this.app.height / 2
+    return {
+      x,
+      y
+    }
+  }
+
+  // 画布转换转屏幕坐标
+  transformToScreenCoordinate(x, y) {
+    x += this.app.width / 2
+    y += this.app.height / 2
+    return {
+      x,
+      y
+    }
+  }
+
+  // 综合转换,屏幕坐标转画布坐标,再减去滚动值
+  transform(x, y) {
+    let t = this.transformToCanvasCoordinate(x, y)
+    return {
+      x: this.subScrollX(t.x),
+      y: this.subScrollY(t.y)
+    }
+  }
+
+  // 相对窗口的坐标转换成相对容器的,用于当容器非全屏的时候
+  windowToContainer(x, y) {
+    return {
+      x: x - this.app.left,
+      y: y - this.app.top
+    }
+  }
+
+  // 相对容器的坐标转换成相对窗口的,用于当容器非全屏的时候
+  containerToWindow(x, y) {
+    return {
+      x: x + this.app.left,
+      y: y + this.app.top
+    }
+  }
+
+  // 屏幕坐标在应用画布缩放后的位置
+  scale(x, y) {
+    let { state } = this.app
+    // 屏幕坐标转画布坐标
+    let wp = this.transformToCanvasCoordinate(x, y)
+    let sp = this.transformToScreenCoordinate(
+      wp.x * state.scale,
+      wp.y * state.scale
+    )
+    return {
+      x: sp.x,
+      y: sp.y
+    }
+  }
+
+  // 屏幕坐标在反向应用画布缩放后的位置
+  reverseScale(x, y) {
+    let { state } = this.app
+    // 屏幕坐标转画布坐标
+    let tp = this.transformToCanvasCoordinate(x, y)
+    let sp = this.transformToScreenCoordinate(
+      tp.x / state.scale,
+      tp.y / state.scale
+    )
+    return {
+      x: sp.x,
+      y: sp.y
+    }
+  }
+
+  // 网格吸附
+  gridAdsorbent(x, y) {
+    let { gridConfig, showGrid } = this.app.state
+    if (!showGrid) {
+      return {
+        x,
+        y
+      }
+    }
+    let gridSize = gridConfig.size
+    return {
+      x: x - (x % gridSize),
+      y: y - (y % gridSize)
+    }
+  }
+}

+ 72 - 0
tiny-whiteboard/src/Cursor.js

@@ -0,0 +1,72 @@
+import { CORNERS, DRAG_ELEMENT_PARTS } from './constants'
+
+// 鼠标样式类
+export default class Cursor {
+  constructor(app) {
+    this.app = app
+    this.currentType = 'default'
+  }
+
+  // 设置鼠标指针样式
+  set(type = 'default') {
+    this.currentType = type
+    let style = type
+    if (type === 'eraser') {
+      style = `url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABQAAAAUCAYAAACNiR0NAAAAAXNSR0IArs4c6QAAARRJREFUOE/dlDFLxEAQhd+BVouFZ3vlQuwSyI+5a7PBRkk6k9KzTOwStJFsWv0xgaQzkNLWszim0kL2OOFc9oKRYHFTz37Lm/dmJhi5JiPzcBjAOYDz7WheADz3jalP8oIxds85P3Zd90RBqqpad133SUSXAJ5M4H3AhWVZd1EUzYQQP96VZYkkSV7btr02QY1Axtgqz/NTz/OM6qSUCMNwRURneoMJOLdt+7Gu643MfeU4zrppmgt9pibgjRBiWRRFb0R934eUcgngdrfxX4CjSwZj7C3Lsqnu8Lc05XQQBO9ENP2NKapnE5s4jme608rhNE2HxWb7qwr2A+f8SAv2BxFdDQ32rpLRVu9Pl+0wztcg6V/VPW4Vw1FsawAAAABJRU5ErkJggg==) 10 10, auto`
+    }
+    this.app.container.style.cursor = style
+  }
+
+  // 隐藏鼠标指针
+  hide() {
+    this.set('none')
+  }
+
+  // 复位鼠标指针
+  reset() {
+    this.set()
+  }
+
+  // 设置为 ✚ 字型
+  setCrosshair() {
+    this.set('crosshair')
+  }
+
+  // 设置为 可移动 状态
+  setMove() {
+    this.set('move')
+  }
+
+  // 设置为某个方向的可移动状态
+  setResize(dir) {
+    let type = ''
+    switch (dir) {
+      case DRAG_ELEMENT_PARTS.BODY:
+        type = 'move'
+        break
+      case DRAG_ELEMENT_PARTS.ROTATE:
+        type = 'grab'
+        break
+      case DRAG_ELEMENT_PARTS.TOP_LEFT_BTN:
+        type = 'nw-resize'
+        break
+      case DRAG_ELEMENT_PARTS.TOP_RIGHT_BTN:
+        type = 'ne-resize'
+        break
+      case DRAG_ELEMENT_PARTS.BOTTOM_RIGHT_BTN:
+        type = 'se-resize'
+        break
+      case DRAG_ELEMENT_PARTS.BOTTOM_LEFT_BTN:
+        type = 'sw-resize'
+        break
+      default:
+        break
+    }
+    this.set(type)
+  }
+
+  // 设置为橡皮擦样式
+  setEraser() {
+    this.set('eraser')
+  }
+}

+ 477 - 0
tiny-whiteboard/src/Elements.js

@@ -0,0 +1,477 @@
+import Rectangle from './elements/Rectangle'
+import Circle from './elements/Circle'
+import Diamond from './elements/Diamond'
+import Triangle from './elements/Triangle'
+import Freedraw from './elements/Freedraw'
+import Arrow from './elements/Arrow'
+import Image from './elements/Image'
+import Line from './elements/Line'
+import Text from './elements/Text'
+import {
+  getTowPointDistance,
+  throttle,
+  computedLineWidthBySpeed,
+  createImageObj
+} from './utils'
+import { DRAG_ELEMENT_PARTS } from './constants'
+
+// 元素管理类
+export default class Elements {
+  constructor(app) {
+    this.app = app
+    // 所有元素
+    this.elementList = []
+    // 当前激活元素
+    this.activeElement = null
+    // 当前正在创建新元素
+    this.isCreatingElement = false
+    // 当前正在调整元素
+    this.isResizing = false
+    // 当前正在调整的元素
+    this.resizingElement = null
+    // 稍微缓解一下卡顿
+    this.handleResize = throttle(this.handleResize, this, 16)
+  }
+
+  // 序列化当前画布上的所有元素
+  serialize(stringify = false) {
+    let data = this.elementList.map(element => {
+      return element.serialize()
+    })
+    return stringify ? JSON.stringify(data) : data
+  }
+
+  // 获取当前画布上的元素数量
+  getElementsNum() {
+    return this.elementList.length
+  }
+
+  // 当前画布上是否有元素
+  hasElements() {
+    return this.elementList.length > 0
+  }
+
+  // 添加元素
+  addElement(element) {
+    this.elementList.push(element)
+    return this
+  }
+
+  // 向前添加元素
+  unshiftElement(element) {
+    this.elementList.unshift(element)
+    return this
+  }
+
+  // 添加元素到指定位置
+  insertElement(element, index) {
+    this.elementList.splice(index, 0, element)
+  }
+
+  // 删除元素
+  deleteElement(element) {
+    let index = this.getElementIndex(element)
+    if (index !== -1) {
+      this.elementList.splice(index, 1)
+      if (element.isActive) {
+        this.cancelActiveElement(element)
+      }
+    }
+    return this
+  }
+
+  // 删除全部元素
+  deleteAllElements() {
+    this.activeElement = null
+    this.elementList = []
+    this.isCreatingElement = false
+    this.isResizing = false
+    this.resizingElement = null
+    return this
+  }
+
+  // 获取元素在元素列表里的索引
+  getElementIndex(element) {
+    return this.elementList.findIndex(item => {
+      return item === element
+    })
+  }
+
+  // 根据元素数据创建元素
+  createElementsFromData(data) {
+    data.forEach(item => {
+      let element = this.pureCreateElement(item)
+      element.isActive = false
+      element.isCreating = false
+      this.addElement(element)
+    })
+    this.app.group.initIdToElementList(this.elementList)
+    return this
+  }
+
+  // 是否存在激活元素
+  hasActiveElement() {
+    return !!this.activeElement
+  }
+
+  // 设置激活元素
+  setActiveElement(element) {
+    this.cancelActiveElement()
+    this.activeElement = element
+    if (element) {
+      element.isActive = true
+    }
+    this.app.emit('activeElementChange', this.activeElement)
+    return this
+  }
+
+  // 取消当前激活元素
+  cancelActiveElement() {
+    if (!this.hasActiveElement()) {
+      return this
+    }
+    this.activeElement.isActive = false
+    this.activeElement = null
+    this.app.emit('activeElementChange', this.activeElement)
+    return this
+  }
+
+  // 检测是否点击选中元素
+  checkIsHitElement(e) {
+    // 判断是否选中元素
+    let x = e.unGridClientX
+    let y = e.unGridClientY
+    // 从后往前遍历元素,默认认为新创建的元素在上一层
+    for (let i = this.elementList.length - 1; i >= 0; i--) {
+      let element = this.elementList[i]
+      if (element.isHit(x, y)) {
+        return element
+      }
+    }
+    return null
+  }
+
+  // 纯创建元素
+  pureCreateElement(opts = {}) {
+    switch (opts.type) {
+      case 'rectangle':
+        return new Rectangle(opts, this.app)
+      case 'diamond':
+        return new Diamond(opts, this.app)
+      case 'triangle':
+        return new Triangle(opts, this.app)
+      case 'circle':
+        return new Circle(opts, this.app)
+      case 'freedraw':
+        return new Freedraw(opts, this.app)
+      case 'image':
+        return new Image(opts, this.app)
+      case 'arrow':
+        return new Arrow(opts, this.app)
+      case 'line':
+        return new Line(opts, this.app)
+      case 'text':
+        return new Text(opts, this.app)
+      default:
+        return null
+    }
+  }
+
+  // 创建元素
+  createElement(opts = {}, callback = () => {}, ctx = null, notActive) {
+    if (this.hasActiveElement() || this.isCreatingElement) {
+      return this
+    }
+    let element = this.pureCreateElement(opts)
+    if (!element) {
+      return this
+    }
+    this.addElement(element)
+    if (!notActive) {
+      this.setActiveElement(element)
+    }
+    this.isCreatingElement = true
+    callback.call(ctx, element)
+    return this
+  }
+
+  // 复制元素
+  copyElement(element, notActive = false, pos) {
+    return new Promise(async resolve => {
+      if (!element) {
+        return resolve()
+      }
+      let data = this.app.group.handleCopyElementData(element.serialize())
+      // 图片元素需要先加载图片
+      if (data.type === 'image') {
+        data.imageObj = await createImageObj(data.url)
+      }
+      this.createElement(
+        data,
+        element => {
+          this.app.group.handleCopyElement(element)
+          element.startResize(DRAG_ELEMENT_PARTS.BODY)
+          // 默认偏移原图形20像素
+          let ox = 20
+          let oy = 20
+          // 指定了具体坐标则使用具体坐标
+          if (pos) {
+            ox = pos.x - element.x - element.width / 2
+            oy = pos.y - element.y - element.height / 2
+          }
+          // 如果开启了网格,那么要坐标要吸附到网格
+          let gridAdsorbentPos = this.app.coordinate.gridAdsorbent(ox, oy)
+          element.resize(
+            null,
+            null,
+            null,
+            gridAdsorbentPos.x,
+            gridAdsorbentPos.y
+          )
+          element.isCreating = false
+          if (notActive) {
+            element.isActive = false
+          }
+          this.isCreatingElement = false
+          resolve(element)
+        },
+        this,
+        notActive
+      )
+    })
+  }
+
+  // 正在创建类矩形元素
+  creatingRectangleLikeElement(type, x, y, offsetX, offsetY) {
+    this.createElement({
+      type,
+      x: x,
+      y: y,
+      width: offsetX,
+      height: offsetY
+    })
+    this.activeElement.updateSize(offsetX, offsetY)
+  }
+
+  // 正在创建圆形元素
+  creatingCircle(x, y, e) {
+    this.createElement({
+      type: 'circle',
+      x: x,
+      y: y
+    })
+    let radius = getTowPointDistance(e.clientX, e.clientY, x, y)
+    this.activeElement.updateSize(radius, radius)
+  }
+
+  // 正在创建自由画笔元素
+  creatingFreedraw(e, event) {
+    this.createElement({
+      type: 'freedraw'
+    })
+    let element = this.activeElement
+    // 计算画笔粗细
+    let lineWidth = computedLineWidthBySpeed(
+      event.mouseSpeed,
+      element.lastLineWidth
+    )
+    element.lastLineWidth = lineWidth
+    element.addPoint(e.clientX, e.clientY, lineWidth)
+    // 绘制自由线不重绘,采用增量绘制,否则会卡顿
+    let { coordinate, ctx, state } = this.app
+    // 事件对象的坐标默认是加上了画布偏移量的,临时绘制的时候不需要,所以需要减去
+    let tfp = coordinate.transformToCanvasCoordinate(
+      coordinate.subScrollX(event.lastMousePos.x),
+      coordinate.subScrollY(event.lastMousePos.y)
+    )
+    let ttp = coordinate.transformToCanvasCoordinate(
+      coordinate.subScrollX(e.clientX),
+      coordinate.subScrollY(e.clientY)
+    )
+    ctx.save()
+    ctx.scale(state.scale, state.scale)
+    element.singleRender(tfp.x, tfp.y, ttp.x, ttp.y, lineWidth)
+    ctx.restore()
+  }
+
+  // 正在创建图片元素
+  creatingImage(e, { width, height, imageObj, url, ratio }) {
+    // 吸附到网格,如果网格开启的话
+    let gp = this.app.coordinate.gridAdsorbent(
+      e.unGridClientX - width / 2,
+      e.unGridClientY - height / 2
+    )
+    this.createElement({
+      type: 'image',
+      x: gp.x,
+      y: gp.y,
+      url: url,
+      imageObj: imageObj,
+      width: width,
+      height: height,
+      ratio: ratio
+    })
+  }
+
+  // 正在编辑文本元素
+  editingText(element) {
+    if (element.type !== 'text') {
+      return
+    }
+    element.noRender = true
+    this.setActiveElement(element)
+  }
+
+  // 完成文本元素的编辑
+  completeEditingText() {
+    let element = this.activeElement
+    if (!element || element.type !== 'text') {
+      return
+    }
+    if (!element.text.trim()) {
+      // 没有输入则删除该文字元素
+      this.deleteElement(element)
+      this.setActiveElement(null)
+      return
+    }
+    element.noRender = false
+  }
+
+  // 完成箭头元素的创建
+  completeCreateArrow(e) {
+    this.activeElement.addPoint(e.clientX, e.clientY)
+  }
+
+  // 正在创建箭头元素
+  creatingArrow(x, y, e) {
+    this.createElement(
+      {
+        type: 'arrow',
+        x,
+        y
+      },
+      element => {
+        element.addPoint(x, y)
+      }
+    )
+    this.activeElement.updateFictitiousPoint(e.clientX, e.clientY)
+  }
+
+  // 正在创建线段/折线元素
+  creatingLine(x, y, e, isSingle = false, notCreate = false) {
+    if (!notCreate) {
+      this.createElement(
+        {
+          type: 'line',
+          x,
+          y,
+          isSingle
+        },
+        element => {
+          element.addPoint(x, y)
+        }
+      )
+    }
+    let element = this.activeElement
+    if (element) {
+      element.updateFictitiousPoint(e.clientX, e.clientY)
+    }
+  }
+
+  // 完成线段/折线元素的创建
+  completeCreateLine(e, completeCallback = () => {}) {
+    let element = this.activeElement
+    let x = e.clientX
+    let y = e.clientY
+    if (element && element.isSingle) {
+      // 单根线段模式,鼠标松开则代表绘制完成
+      element.addPoint(x, y)
+      completeCallback()
+    } else {
+      // 绘制折线模式,鼠标松开代表固定一个端点
+      this.createElement({
+        type: 'line',
+        isSingle: false
+      })
+      element = this.activeElement
+      element.addPoint(x, y)
+      element.updateFictitiousPoint(x, y)
+    }
+  }
+
+  // 创建元素完成
+  completeCreateElement() {
+    this.isCreatingElement = false
+    let element = this.activeElement
+    if (!element) {
+      return this
+    }
+    // 由多个端点构成的元素需要根据端点计算外包围框
+    if (['freedraw', 'arrow', 'line'].includes(element.type)) {
+      element.updateMultiPointBoundingRect()
+    }
+    element.isCreating = false
+    this.app.emitChange()
+    return this
+  }
+
+  // 为激活元素设置样式
+  setActiveElementStyle(style = {}) {
+    if (!this.hasActiveElement()) {
+      return this
+    }
+    Object.keys(style).forEach(key => {
+      this.activeElement.style[key] = style[key]
+      if (key === 'fontSize' && this.activeElement.type === 'text') {
+        this.activeElement.updateTextSize()
+      }
+    })
+    return this
+  }
+
+  // 检测指定位置是否在元素调整手柄上
+  checkInResizeHand(x, y) {
+    // 按住了拖拽元素的某个部分
+    let element = this.activeElement
+    let hand = element.dragElement.checkPointInDragElementWhere(x, y)
+    if (hand) {
+      return {
+        element,
+        hand
+      }
+    }
+    return null
+  }
+
+  // 检查是否需要进行元素调整操作
+  checkIsResize(x, y, e) {
+    if (!this.hasActiveElement()) {
+      return false
+    }
+    let res = this.checkInResizeHand(x, y)
+    if (res) {
+      this.isResizing = true
+      this.resizingElement = res.element
+      this.resizingElement.startResize(res.hand, e)
+      this.app.cursor.setResize(res.hand)
+      return true
+    }
+    return false
+  }
+
+  // 进行元素调整操作
+  handleResize(...args) {
+    if (!this.isResizing) {
+      return
+    }
+    this.resizingElement.resize(...args)
+    this.app.render.render()
+  }
+
+  // 结束元素调整操作
+  endResize() {
+    this.isResizing = false
+    this.resizingElement.endResize()
+    this.resizingElement = null
+  }
+}

+ 192 - 0
tiny-whiteboard/src/Event.js

@@ -0,0 +1,192 @@
+import { getTowPointDistance } from './utils'
+import EventEmitter from 'eventemitter3'
+
+// 事件类
+export default class Event extends EventEmitter {
+  constructor(app) {
+    super()
+    this.app = app
+    this.coordinate = app.coordinate
+
+    // 鼠标是否按下
+    this.isMousedown = false
+    // 按下时的鼠标位置
+    this.mousedownPos = {
+      x: 0,
+      y: 0,
+      unGridClientX: 0,
+      unGridClientY: 0,
+      originClientX: 0,
+      originClientY: 0
+    }
+    // 鼠标当前位置和按下时位置的差值
+    this.mouseOffset = {
+      x: 0,
+      y: 0,
+      originX: 0,
+      originY: 0
+    }
+    // 记录上一时刻的鼠标位置
+    this.lastMousePos = {
+      x: 0,
+      y: 0
+    }
+    // 前一瞬间的鼠标移动距离
+    this.mouseDistance = 0
+    // 记录上一时刻的时间
+    this.lastMouseTime = Date.now()
+    // 前一瞬间的时间
+    this.mouseDuration = 0
+    // 前一瞬间的鼠标移动速度
+    this.mouseSpeed = 0
+    // 绑定事件
+    this.onMousedown = this.onMousedown.bind(this)
+    this.onMousemove = this.onMousemove.bind(this)
+    this.onMouseup = this.onMouseup.bind(this)
+    this.onDblclick = this.onDblclick.bind(this)
+    this.onMousewheel = this.onMousewheel.bind(this)
+    this.onKeydown = this.onKeydown.bind(this)
+    this.onKeyup = this.onKeyup.bind(this)
+    this.onContextmenu = this.onContextmenu.bind(this)
+    this.bindEvent()
+  }
+
+  // 绑定canvas事件
+  bindEvent() {
+    this.app.container.addEventListener('mousedown', this.onMousedown)
+    this.app.container.addEventListener('mousemove', this.onMousemove)
+    this.app.container.addEventListener('mouseup', this.onMouseup)
+    this.app.container.addEventListener('dblclick', this.onDblclick)
+    this.app.container.addEventListener('mousewheel', this.onMousewheel)
+    this.app.container.addEventListener('contextmenu', this.onContextmenu)
+    window.addEventListener('keydown', this.onKeydown)
+    window.addEventListener('keyup', this.onKeyup)
+  }
+
+  // 解绑事件
+  unbindEvent() {
+    this.app.container.removeEventListener('mousedown', this.onMousedown)
+    this.app.container.removeEventListener('mousemove', this.onMousemove)
+    this.app.container.removeEventListener('mouseup', this.onMouseup)
+    this.app.container.removeEventListener('dblclick', this.onDblclick)
+    this.app.container.removeEventListener('mousewheel', this.onMousewheel)
+    this.app.container.removeEventListener('contextmenu', this.onContextmenu)
+    window.removeEventListener('keydown', this.onKeydown)
+    window.removeEventListener('keyup', this.onKeyup)
+  }
+
+  // 转换事件对象e
+  // 1.将相当于浏览器窗口左上角的坐标转换成相对容器左上角
+  // 2.如果画布进行了缩放,那么鼠标坐标要反向进行缩放
+  // 3.x、y坐标加上了画布水平和垂直的滚动距离scrollX和scrollY
+  // 4.如果开启了网格,那么坐标要吸附到网格上
+  transformEvent(e) {
+    let { coordinate } = this.app
+    // 容器和窗口左上角存在距离时转换
+    let wp = coordinate.windowToContainer(e.clientX, e.clientY)
+    // 元素缩放是*scale,所以视觉上我们点击到了元素,但是实际上元素的位置还是原来的x,y,所以鼠标的坐标需要/scale
+    let { x, y } = coordinate.reverseScale(wp.x, wp.y)
+    // 加上滚动偏移
+    x = coordinate.addScrollX(x)
+    y = coordinate.addScrollY(y)
+    // 保存未吸附到网格的坐标,用于位置检测等不需要吸附的场景
+    let unGridClientX = x
+    let unGridClientY = y
+    // 如果开启了网格,那么要坐标要吸附到网格
+    let gp = coordinate.gridAdsorbent(x, y)
+    let newEvent = {
+      originEvent: e,
+      unGridClientX,
+      unGridClientY,
+      clientX: gp.x,
+      clientY: gp.y // 向下滚动scroll值为正,而canvas坐标系向下为正,所以要造成元素向上滚动的效果显示的时候元素的y坐标需要减去scroll值,但是元素真实的y值并未改变,所以对于鼠标坐标来说需要加上scroll值,这样才能匹配元素真实的y坐标,水平方向也是一样的。
+    }
+    return newEvent
+  }
+
+  // 鼠标按下事件
+  onMousedown(e) {
+    e = this.transformEvent(e)
+    this.isMousedown = true
+    this.mousedownPos.x = e.clientX
+    this.mousedownPos.y = e.clientY
+    this.mousedownPos.unGridClientX = e.unGridClientX
+    this.mousedownPos.unGridClientY = e.unGridClientY
+    this.mousedownPos.originClientX = e.originEvent.clientX
+    this.mousedownPos.originClientY = e.originEvent.clientY
+    this.emit('mousedown', e, this)
+  }
+
+  // 鼠标移动事件
+  onMousemove(e) {
+    e = this.transformEvent(e)
+    let x = e.clientX
+    let y = e.clientY
+    // 鼠标按下状态
+    if (this.isMousedown) {
+      this.mouseOffset.x = x - this.mousedownPos.x
+      this.mouseOffset.y = y - this.mousedownPos.y
+      this.mouseOffset.originX =
+        e.originEvent.clientX - this.mousedownPos.originClientX
+      this.mouseOffset.originY =
+        e.originEvent.clientY - this.mousedownPos.originClientY
+    }
+    let curTime = Date.now()
+    // 距离上一次的时间
+    this.mouseDuration = curTime - this.lastMouseTime
+    // 距离上一次的距离
+    this.mouseDistance = getTowPointDistance(
+      x,
+      y,
+      this.lastMousePos.x,
+      this.lastMousePos.y
+    )
+    // 鼠标移动速度
+    this.mouseSpeed = this.mouseDistance / this.mouseDuration
+    this.emit('mousemove', e, this)
+    // 更新变量
+    this.lastMouseTime = curTime
+    this.lastMousePos.x = x
+    this.lastMousePos.y = y
+  }
+
+  // 鼠标松开事件
+  onMouseup(e) {
+    e = this.transformEvent(e)
+    // 复位
+    this.isMousedown = false
+    this.mousedownPos.x = 0
+    this.mousedownPos.y = 0
+    this.emit('mouseup', e, this)
+  }
+
+  // 双击事件
+  onDblclick(e) {
+    e = this.transformEvent(e)
+    this.emit('dblclick', e, this)
+  }
+
+  // 鼠标滚动事件
+  onMousewheel(e) {
+    e = this.transformEvent(e)
+    this.emit('mousewheel', e.originEvent.wheelDelta < 0 ? 'down' : 'up')
+  }
+
+  // 右键菜单事件
+  onContextmenu(e) {
+    e.stopPropagation()
+    e.preventDefault()
+    e = this.transformEvent(e)
+    this.emit('contextmenu', e, this)
+  }
+
+  // 按键按下事件
+  onKeydown(e) {
+    this.emit('keydown', e, this)
+  }
+
+  // 按键松开事件
+  onKeyup(e) {
+    this.emit('keyup', e, this)
+  }
+}

+ 159 - 0
tiny-whiteboard/src/Export.js

@@ -0,0 +1,159 @@
+import { createCanvas, getMultiElementRectInfo } from './utils'
+
+// 导入导出
+export default class Export {
+  constructor(app) {
+    this.app = app
+    // 会把导出canvas绘制到页面上,方便测试
+    this.openTest = false
+    // 数据保存
+    this.saveState = {
+      scale: 0,
+      scrollX: 0,
+      scrollY: 0,
+      width: 0,
+      height: 0
+    }
+  }
+
+  // 显示
+  show(canvas) {
+    if (this.openTest) {
+      canvas.style.cssText = `
+        position: absolute;
+        left: 0;
+        top: 0;
+        background-color: #fff;
+      `
+      document.body.appendChild(canvas)
+    }
+  }
+
+  // 获取要导出的元素
+  getElementList(onlySelected = true) {
+    // 导出所有元素
+    if (!onlySelected) {
+      return this.app.elements.elementList
+    } else {
+      // 仅导出激活或选中的元素
+      let selectedElements = []
+      if (this.app.elements.activeElement) {
+        selectedElements.push(this.app.elements.activeElement)
+      } else if (this.app.selection.hasSelectionElements()) {
+        selectedElements = this.app.selection.getSelectionElements()
+      }
+      let res = this.app.elements.elementList.filter(element => {
+        return selectedElements.includes(element)
+      })
+      return res
+    }
+  }
+
+  // 导出为图片
+  exportImage({
+    type = 'image/png',
+    renderBg = true,
+    useBlob = false,
+    paddingX = 10,
+    paddingY = 10,
+    onlySelected
+  } = {}) {
+    // 计算所有元素的外包围框
+    let { minx, maxx, miny, maxy } = getMultiElementRectInfo(
+      this.getElementList(onlySelected)
+    )
+    let width = maxx - minx + paddingX * 2
+    let height = maxy - miny + paddingY * 2
+    // 创建导出canvas
+    let { canvas, ctx } = createCanvas(width, height, {
+      noStyle: true,
+      noTranslate: true
+    })
+    this.show(canvas)
+    this.saveAppState()
+    this.changeAppState(minx - paddingX, miny - paddingY, ctx)
+    // 绘制背景颜色
+    if (renderBg && this.app.state.backgroundColor) {
+      this.app.background.canvasAddBackgroundColor(
+        ctx,
+        width,
+        height,
+        this.app.state.backgroundColor
+      )
+    }
+    // 绘制元素到导出canvas
+    this.render(ctx, onlySelected)
+    this.recoveryAppState()
+    // 导出
+    if (useBlob) {
+      return new Promise((resolve, reject) => {
+        canvas.toBlob(blob => {
+          if (blob) {
+            resolve(blob)
+          } else {
+            reject()
+          }
+        }, type)
+      })
+    } else {
+      return canvas.toDataURL(type)
+    }
+  }
+
+  // 保存app类当前状态数据
+  saveAppState() {
+    let { width, height, state, ctx } = this.app
+    this.saveState.width = width
+    this.saveState.height = height
+    this.saveState.scale = state.scale
+    this.saveState.scrollX = state.scrollX
+    this.saveState.scrollY = state.scrollY
+    this.saveState.ctx = ctx
+  }
+
+  // 临时修改app类状态数据
+  changeAppState(minx, miny, ctx) {
+    this.app.ctx = ctx
+    this.app.state.scale = 1
+    this.app.state.scrollX = 0
+    this.app.state.scrollY = 0
+    // 这里为什么要这么修改呢,原因是要把元素的坐标转换成当前导出画布的坐标,当前导出画布的坐标在左上角,比如一个元素的左上角原始坐标为(100,100),假设刚好minx和miny也是100,那么相当于元素的这个坐标要绘制到导出画布时的坐标应为(0,0),所以元素绘制到导出画布的坐标均需要减去minx,miny,而元素在绘制时都会调用this.app.coordinate.transform方法进行转换,这个方法里使用的是this.app.width和this.app.height,所以方便起见直接修改这两个属性。
+    this.app.width = minx * 2
+    this.app.height = miny * 2
+  }
+
+  // 恢复app类状态数据
+  recoveryAppState() {
+    let { width, height, scale, scrollX, scrollY, ctx } = this.saveState
+    this.app.state.scale = scale
+    this.app.state.scrollX = scrollX
+    this.app.state.scrollY = scrollY
+    this.app.width = width
+    this.app.height = height
+    this.app.ctx = ctx
+  }
+
+  // 绘制所有元素
+  render(ctx, onlySelected) {
+    ctx.save()
+    this.getElementList(onlySelected).forEach(element => {
+      if (element.noRender) {
+        return
+      }
+      let cacheActive = element.isActive
+      let cacheSelected = element.isSelected
+      // 临时修改元素的激活状态为非激活、非选中
+      element.isActive = false
+      element.isSelected = false
+      element.render()
+      element.isActive = cacheActive
+      element.isSelected = cacheSelected
+    })
+    ctx.restore()
+  }
+
+  // 导出为json数据
+  exportJson() {
+    return this.app.getData()
+  }
+}

+ 155 - 0
tiny-whiteboard/src/Grid.js

@@ -0,0 +1,155 @@
+import Canvas from './Canvas'
+
+// 网格
+export default class Grid {
+  constructor(app) {
+    this.app = app
+    this.canvas = null
+    this.ctx = null
+
+    this.init()
+    this.app.on('zoomChange', this.renderGrid, this)
+    this.app.on('scrollChange', this.renderGrid, this)
+  }
+
+  // 初始化
+  init() {
+    if (this.canvas) {
+      this.app.container.removeChild(this.canvas.el)
+    }
+    let { width, height } = this.app
+    this.canvas = new Canvas(width, height, {
+      className: 'grid'
+    })
+    this.ctx = this.canvas.ctx
+    this.app.container.insertBefore(
+      this.canvas.el,
+      this.app.container.children[0]
+    )
+  }
+
+  // 绘制水平线
+  drawHorizontalLine(i) {
+    let { coordinate, width, state } = this.app
+    let _i = coordinate.subScrollY(i)
+    this.ctx.beginPath()
+    this.ctx.moveTo(-width / state.scale / 2, _i)
+    this.ctx.lineTo(width / state.scale / 2, _i)
+    this.ctx.stroke()
+  }
+
+  // 渲染水平线
+  renderHorizontalLines() {
+    let { coordinate, height, state } = this.app
+    let { gridConfig, scale } = state
+    let maxBottom = 0
+    for (let i = -height / 2; i < height / 2; i += gridConfig.size) {
+      this.drawHorizontalLine(i)
+      maxBottom = i
+    }
+    // 向下滚时绘制上方超出的线
+    for (
+      let i = -height / 2 - gridConfig.size;
+      i > -coordinate.subScrollY(height / scale / 2);
+      i -= gridConfig.size
+    ) {
+      this.drawHorizontalLine(i)
+    }
+    // 向上滚时绘制下方超出的线
+    for (
+      let i = maxBottom + gridConfig.size;
+      i < coordinate.addScrollY(height / scale / 2);
+      i += gridConfig.size
+    ) {
+      this.drawHorizontalLine(i)
+    }
+  }
+
+  // 绘制重置线
+  drawVerticalLine(i) {
+    let { coordinate, height, state } = this.app
+    let _i = coordinate.subScrollX(i)
+    this.ctx.beginPath()
+    this.ctx.moveTo(_i, -height / state.scale / 2)
+    this.ctx.lineTo(_i, height / state.scale / 2)
+    this.ctx.stroke()
+  }
+
+  // 渲染垂直线
+  renderVerticalLines() {
+    let { coordinate, width, state } = this.app
+    let { gridConfig, scale } = state
+    let maxRight = 0
+    for (let i = -width / 2; i < width / 2; i += gridConfig.size) {
+      this.drawVerticalLine(i)
+      maxRight = i
+    }
+    // 向右滚时绘制左方超出的线
+    for (
+      let i = -width / 2 - gridConfig.size;
+      i > -coordinate.subScrollX(width / scale / 2);
+      i -= gridConfig.size
+    ) {
+      this.drawVerticalLine(i)
+    }
+    // 向左滚时绘制右方超出的线
+    for (
+      let i = maxRight + gridConfig.size;
+      i < coordinate.addScrollX(width / scale / 2);
+      i += gridConfig.size
+    ) {
+      this.drawVerticalLine(i)
+    }
+  }
+
+  // 渲染网格
+  renderGrid() {
+    this.canvas.clearCanvas()
+    let { gridConfig, scale, showGrid } = this.app.state
+    if (!showGrid) {
+      return
+    }
+    this.ctx.save()
+    this.ctx.scale(scale, scale)
+    this.ctx.strokeStyle = gridConfig.strokeStyle
+    this.ctx.lineWidth = gridConfig.lineWidth
+
+    // 水平
+    this.renderHorizontalLines()
+
+    // 垂直
+    this.renderVerticalLines()
+
+    this.ctx.restore()
+  }
+
+  // 显示网格
+  showGrid() {
+    this.app.updateState({
+      showGrid: true
+    })
+    this.renderGrid()
+  }
+
+  // 隐藏网格
+  hideGrid() {
+    this.app.updateState({
+      showGrid: false
+    })
+    this.canvas.clearCanvas()
+  }
+
+  // 更新网格配置
+  updateGrid(config = {}) {
+    this.app.updateState({
+      gridConfig: {
+        ...this.app.state.gridConfig,
+        ...config
+      }
+    })
+    if (this.app.state.showGrid) {
+      this.hideGrid()
+      this.showGrid()
+    }
+  }
+}

+ 123 - 0
tiny-whiteboard/src/Group.js

@@ -0,0 +1,123 @@
+import { v4 as uuidv4 } from 'uuid'
+import MultiSelectElement from './elements/MultiSelectElement'
+
+// 编组/取消编组类
+export default class Group {
+  constructor(app) {
+    this.app = app
+    this.groupIdToElementList = {}
+    this.newGroupIdMap = {}
+  }
+
+  // 多选时渲染编组元素的多选框
+  render() {
+    Object.keys(this.groupIdToElementList).forEach(groupId => {
+      let group = this.groupIdToElementList[groupId]
+      let selected = group[0].isSelected
+      if (selected) {
+        let mElement = new MultiSelectElement(
+          {
+            type: 'multiSelectElement'
+          },
+          this.app
+        )
+        mElement.setSelectedElementList(group)
+        mElement.updateRect()
+        mElement.dragElement.onlyShowBody()
+        mElement.render()
+      }
+    })
+  }
+
+  // 存储到映射列表
+  setToMap(element) {
+    let groupId = element.getGroupId()
+    if (groupId) {
+      if (!this.groupIdToElementList[groupId]) {
+        this.groupIdToElementList[groupId] = []
+      }
+      this.groupIdToElementList[groupId].push(element)
+    }
+  }
+
+  // 初始化映射列表
+  initIdToElementList(elementList) {
+    this.groupIdToElementList = {}
+    elementList.forEach(element => {
+      this.setToMap(element)
+    })
+  }
+
+  // 处理元素数据的复制
+  handleCopyElementData(data) {
+    if (data.groupId) {
+      if (this.newGroupIdMap[data.groupId]) {
+        data.groupId = this.newGroupIdMap[data.groupId]
+      } else {
+        data.groupId = this.newGroupIdMap[data.groupId] = uuidv4()
+      }
+    }
+    return data
+  }
+
+  // 复位用于元素数据复制的存储对象
+  clearCopyMap() {
+    this.newGroupIdMap = {}
+  }
+
+  // 处理元素对象的复制
+  handleCopyElement(element) {
+    this.setToMap(element)
+  }
+
+  // 编组
+  dogroup() {
+    if (
+      !this.app.selection.hasSelection ||
+      this.app.selection.multiSelectElement.selectedElementList.length <= 1
+    ) {
+      return
+    }
+    let groupElement = this.app.selection.multiSelectElement.selectedElementList
+    let groupId = uuidv4()
+    this.groupIdToElementList[groupId] = groupElement
+    groupElement.forEach(element => {
+      element.setGroupId(groupId)
+    })
+    this.app.render.render()
+    this.app.emitChange()
+  }
+
+  // 取消编组
+  ungroup() {
+    if (
+      !this.app.selection.hasSelection ||
+      this.app.selection.multiSelectElement.selectedElementList.length <= 1
+    ) {
+      return
+    }
+    let groupElement = this.app.selection.multiSelectElement.selectedElementList
+    let groupId = groupElement[0].getGroupId()
+    this.groupIdToElementList[groupId] = []
+    delete this.groupIdToElementList[groupId]
+    groupElement.forEach(element => {
+      element.removeGroupId(groupId)
+    })
+    this.app.render.render()
+    this.app.emitChange()
+  }
+
+  // 根据元素激活元素所在的组
+  setSelection(element) {
+    let groupId = element.getGroupId()
+    if (this.groupIdToElementList[groupId]) {
+      this.app.selection.selectElements(this.groupIdToElementList[groupId])
+    }
+  }
+
+  // 获取和指定元素同一个组的所有元素
+  getGroupElements(element) {
+    let groupId = element.getGroupId()
+    return this.groupIdToElementList[groupId] || []
+  }
+}

+ 63 - 0
tiny-whiteboard/src/History.js

@@ -0,0 +1,63 @@
+import { deepCopy } from './utils'
+
+// 历史记录管理
+export default class History {
+  constructor(app) {
+    this.app = app
+    this.historyStack = []
+    this.length = 0
+    this.index = -1
+  }
+
+  // 添加
+  add(data) {
+    let prev = this.length > 0 ? this.historyStack[this.length - 1] : null
+    let copyData = deepCopy(data)
+    if (copyData === prev) {
+      return
+    }
+    this.historyStack.push(copyData)
+    this.length++
+    this.index = this.length - 1
+    this.emitChange()
+  }
+
+  // 后退
+  undo() {
+    if (this.index <= 0) {
+      return
+    }
+    this.index--
+    this.shuttle()
+  }
+
+  // 前进
+  redo() {
+    if (this.index >= this.length - 1) {
+      return
+    }
+    this.index++
+    this.shuttle()
+  }
+
+  // 前进后退
+  async shuttle() {
+    let data = this.historyStack[this.index]
+    await this.app.setData(data, true)
+    this.emitChange()
+    this.app.emit('change', data)
+  }
+
+  // 清空数据
+  clear() {
+    this.index = -1
+    this.length = 0
+    this.historyStack = []
+    this.emitChange()
+  }
+
+  // 触发事件
+  emitChange() {
+    this.app.emit('shuttle', this.index, this.length)
+  }
+}

+ 121 - 0
tiny-whiteboard/src/ImageEdit.js

@@ -0,0 +1,121 @@
+import EventEmitter from 'eventemitter3'
+
+export default class ImageEdit extends EventEmitter {
+  constructor(app) {
+    super()
+    this.app = app
+    this.el = null
+    this.isReady = false
+    this.previewEl = null
+    this.imageData = null
+    this.maxWidth = 750
+    this.maxHeight = 450
+    this.maxRatio = this.maxWidth / this.maxHeight
+    this.onImageSelectChange = this.onImageSelectChange.bind(this)
+  }
+
+  // 复位
+  reset() {
+    this.el.value = ''
+    this.isReady = false
+    document.body.removeChild(this.previewEl)
+    this.previewEl = null
+    this.imageData = null
+  }
+
+  // 选择图片
+  selectImage() {
+    if (!this.el) {
+      this.el = document.createElement('input')
+      this.el.type = 'file'
+      this.el.accept = 'image/*'
+      this.el.style.position = 'fixed'
+      this.el.style.left = '-999999px'
+      this.el.addEventListener('change', this.onImageSelectChange)
+      document.body.appendChild(this.el)
+    }
+    this.el.click()
+  }
+
+  // 更新
+  updatePreviewElPos(x, y) {
+    let width = 100
+    let height = width / this.imageData.ratio
+    if (!this.previewEl) {
+      this.previewEl = document.createElement('div')
+      this.previewEl.style.position = 'fixed'
+      this.previewEl.style.width = width + 'px'
+      this.previewEl.style.height = height + 'px'
+      this.previewEl.style.backgroundImage = `url('${this.imageData.url}')`
+      this.previewEl.style.backgroundSize = 'cover'
+      this.previewEl.style.pointerEvents = 'none'
+      document.body.appendChild(this.previewEl)
+    }
+    let tp = this.app.coordinate.containerToWindow(x, y)
+    this.previewEl.style.left = tp.x - width / 2 + 'px'
+    this.previewEl.style.top = tp.y - height / 2 + 'px'
+  }
+
+  // 获取图片宽高
+  async getImageSize(url) {
+    return new Promise((resolve, reject) => {
+      let img = new Image()
+      img.setAttribute('crossOrigin', 'anonymous')
+      img.onload = () => {
+        let width = img.width
+        let height = img.height
+        // 图片过大,缩小宽高
+        let ratio = img.width / img.height
+        if (img.width > this.maxWidth || img.height > this.maxHeight) {
+          if (ratio > this.maxRatio) {
+            width = this.maxWidth
+            height = this.maxWidth / ratio
+          } else {
+            height = this.maxHeight
+            width = this.maxHeight * ratio
+          }
+        }
+        resolve({
+          imageObj: img,
+          size: {
+            width: width,
+            height: height
+          },
+          ratio
+        })
+      }
+      img.onerror = () => {
+        reject()
+      }
+      img.src = url
+    })
+  }
+
+  // 图片选择事件
+  async onImageSelectChange(e) {
+    let url = await this.getImageUrl(e.target.files[0])
+    let { imageObj, size, ratio } = await this.getImageSize(url)
+    this.isReady = true
+    this.imageData = {
+      url,
+      ...size,
+      ratio,
+      imageObj
+    }
+    this.emit('imageSelectChange', this.imageData)
+  }
+
+  // 获取图片url
+  async getImageUrl(file) {
+    return new Promise((resolve, reject) => {
+      let reader = new FileReader()
+      reader.onloadend = () => {
+        resolve(reader.result)
+      }
+      reader.onerror = () => {
+        reject()
+      }
+      reader.readAsDataURL(file)
+    })
+  }
+}

+ 131 - 0
tiny-whiteboard/src/KeyCommand.js

@@ -0,0 +1,131 @@
+import { keyMap } from './utils/keyMap'
+
+// 快捷按键、命令处理类
+export default class KeyCommand {
+  constructor(app) {
+    this.app = app
+    this.keyMap = keyMap
+    this.shortcutMap = {
+      //Enter: [fn]
+    }
+    this.bindEvent()
+  }
+
+  // 绑定事件
+  bindEvent() {
+    this.app.event.on('keydown', this.onKeydown, this)
+  }
+
+  // 解绑事件
+  unBindEvent() {
+    this.app.event.off('keydown', this.onKeydown)
+  }
+
+  // 按键事件
+  onKeydown(e) {
+    Object.keys(this.shortcutMap).forEach(key => {
+      if (this.checkKey(e, key)) {
+        e.stopPropagation()
+        e.preventDefault()
+        this.shortcutMap[key].forEach(f => {
+          f.fn.call(f.ctx)
+        })
+      }
+    })
+  }
+
+  // 检查键值是否符合
+  checkKey(e, key) {
+    let o = this.getOriginEventCodeArr(e)
+    let k = this.getKeyCodeArr(key)
+    if (o.length !== k.length) {
+      return false
+    }
+    for (let i = 0; i < o.length; i++) {
+      let index = k.findIndex(item => {
+        return item === o[i]
+      })
+      if (index === -1) {
+        return false
+      } else {
+        k.splice(index, 1)
+      }
+    }
+    return true
+  }
+
+  // 获取事件对象里的键值数组
+  getOriginEventCodeArr(e) {
+    let arr = []
+    if (e.ctrlKey || e.metaKey) {
+      arr.push(keyMap['Control'])
+    }
+    if (e.altKey) {
+      arr.push(keyMap['Alt'])
+    }
+    if (e.shiftKey) {
+      arr.push(keyMap['Shift'])
+    }
+    if (!arr.includes(e.keyCode)) {
+      arr.push(e.keyCode)
+    }
+    return arr
+  }
+
+  // 获取快捷键对应的键值数组
+  getKeyCodeArr(key) {
+    // 对xxx++情况特殊处理
+    key = key.replace(/\+\+/, '+add')
+    let keyArr = key.split(/\s*\+\s*/).map(item => {
+      return item === 'add' ? '+' : item
+    })
+    let arr = []
+    keyArr.forEach(item => {
+      arr.push(keyMap[item])
+    })
+    return arr
+  }
+
+  /**
+   * 添加快捷键命令
+   * Enter
+   * Tab | Insert
+   * Shift + a
+   */
+  addShortcut(key, fn, ctx) {
+    key.split(/\s*\|\s*/).forEach(item => {
+      if (this.shortcutMap[item]) {
+        this.shortcutMap[item].push({
+          fn,
+          ctx
+        })
+      } else {
+        this.shortcutMap[item] = [
+          {
+            fn,
+            ctx
+          }
+        ]
+      }
+    })
+  }
+
+  // 移除快捷键命令
+  removeShortcut(key, fn) {
+    key.split(/\s*\|\s*/).forEach(item => {
+      if (this.shortcutMap[item]) {
+        if (fn) {
+          let index = this.shortcutMap[item].findIndex(f => {
+            return f.fn === fn
+          })
+          if (index !== -1) {
+            this.shortcutMap[item].splice(index, 1)
+          }
+        } else {
+          this.shortcutMap[item] = []
+          delete this.shortcutMap[item]
+        }
+      }
+    })
+  }
+}

+ 69 - 0
tiny-whiteboard/src/Mode.js

@@ -0,0 +1,69 @@
+import { throttle } from './utils'
+import { keyMap } from './utils/keyMap'
+
+// 模式
+export default class Mode {
+  constructor(app) {
+    this.app = app
+    // 保存拖动即将开始时的滚动偏移量
+    this.startScrollX = 0
+    this.startScrollY = 0
+    // 画布拖拽模式
+    this.isDragMode = false
+    // 稍微缓解一下卡顿
+    this.onMove = throttle(this.onMove, this, 16)
+    this.bindEvent()
+  }
+
+  // 绑定事件
+  bindEvent() {
+    this.app.event.on('keydown', e => {
+      if (e.keyCode === keyMap.Space) {
+        this.isDragMode = true
+        this.app.cursor.set('grab')
+      }
+    })
+    this.app.event.on('keyup', e => {
+      if (this.isDragMode) {
+        this.isDragMode = false
+        this.app.cursor.set('default')
+      }
+    })
+  }
+
+  // 设置为编辑模式
+  setEditMode() {
+    this.app.cursor.set('default')
+    this.app.updateState({
+      readonly: false
+    })
+  }
+
+  // 设置为只读模式
+  setReadonlyMode() {
+    this.app.cursor.set('grab')
+    this.app.updateState({
+      readonly: true
+    })
+  }
+
+  // 保存当前的滚动偏移量
+  onStart() {
+    this.startScrollX = this.app.state.scrollX
+    this.startScrollY = this.app.state.scrollY
+  }
+
+  // 更新滚动偏移量并重新渲染
+  onMove(e, event) {
+    this.app.scrollTo(
+      this.startScrollX - event.mouseOffset.originX / this.app.state.scale,
+      this.startScrollY - event.mouseOffset.originY / this.app.state.scale
+    )
+  }
+
+  // 结束拖拽
+  onEnd() {
+    this.startScrollX = 0
+    this.startScrollY = 0
+  }
+}

+ 385 - 0
tiny-whiteboard/src/Render.js

@@ -0,0 +1,385 @@
+import { getMultiElementRectInfo } from './utils'
+
+// 渲染类
+export default class Render {
+  constructor(app) {
+    this.app = app
+    // 将被复制的激活的元素
+    this.beingCopyActiveElement = null
+    // 将被复制的选中的元素
+    this.beingCopySelectedElements = []
+    this.registerShortcutKeys()
+  }
+
+  // 清除画布
+  clearCanvas() {
+    let { width, height } = this.app
+    this.app.ctx.clearRect(-width / 2, -height / 2, width, height)
+    return this
+  }
+
+  // 绘制所有元素
+  render() {
+    let { state } = this.app
+    // 清空画布
+    this.clearCanvas()
+    this.app.ctx.save()
+    // 整体缩放
+    this.app.ctx.scale(state.scale, state.scale)
+    // 渲染所有元素
+    this.app.elements.elementList.forEach(element => {
+      // 不需要渲染
+      if (element.noRender) {
+        return
+      }
+      element.render()
+    })
+    this.app.group.render()
+    this.app.ctx.restore()
+    return this
+  }
+
+  // 注册快捷键
+  registerShortcutKeys() {
+    // 删除当前激活元素
+    this.app.keyCommand.addShortcut('Del|Backspace', () => {
+      this.deleteCurrentElements()
+    })
+    // 复制元素
+    this.app.keyCommand.addShortcut('Control+c', () => {
+      this.copyCurrentElement()
+    })
+    // 剪切元素
+    this.app.keyCommand.addShortcut('Control+x', () => {
+      this.cutCurrentElement()
+    })
+    // 撤销
+    this.app.keyCommand.addShortcut('Control+z', () => {
+      this.app.history.undo()
+    })
+    // 重做
+    this.app.keyCommand.addShortcut('Control+y', () => {
+      this.app.history.redo()
+    })
+    // 粘贴元素
+    this.app.keyCommand.addShortcut('Control+v', () => {
+      this.pasteCurrentElement(true)
+    })
+    // 放大
+    this.app.keyCommand.addShortcut('Control++', () => {
+      this.zoomIn()
+    })
+    // 缩小
+    this.app.keyCommand.addShortcut('Control+-', () => {
+      this.zoomOut()
+    })
+    // 缩放以适应所有元素
+    this.app.keyCommand.addShortcut('Shift+1', () => {
+      this.fit()
+    })
+    // 全部选中
+    this.app.keyCommand.addShortcut('Control+a', () => {
+      this.selectAll()
+    })
+    // 重置缩放
+    this.app.keyCommand.addShortcut('Control+0', () => {
+      this.setZoom(1)
+    })
+    // 显示隐藏网格
+    this.app.keyCommand.addShortcut("Control+'", () => {
+      if (this.app.state.showGrid) {
+        this.app.grid.hideGrid()
+      } else {
+        this.app.grid.showGrid()
+      }
+    })
+  }
+
+  // 复制当前激活或选中的元素
+  copyCurrentElement() {
+    // 当前存在激活元素
+    if (this.app.elements.activeElement) {
+      this.beingCopySelectedElements = []
+      this.beingCopyElement = this.app.elements.activeElement
+    } else if (this.app.selection.hasSelectionElements()) {
+      // 当前存在选中元素
+      this.beingCopyElement = null
+      this.beingCopySelectedElements = this.app.selection.getSelectionElements()
+    }
+  }
+
+  // 剪切当前激活或选中的元素
+  cutCurrentElement() {
+    // 当前存在激活元素
+    if (this.app.elements.activeElement) {
+      this.copyCurrentElement()
+      this.deleteCurrentElements()
+    } else if (this.app.selection.hasSelectionElements()) {
+      // 当前存在选中元素
+      this.copyCurrentElement()
+      this.deleteCurrentElements()
+      this.app.selection.setMultiSelectElements(this.beingCopySelectedElements)
+      this.app.selection.emitChange()
+    }
+  }
+
+  // 粘贴被复制或剪切的元素
+  pasteCurrentElement(useCurrentEventPos = false) {
+    let pos = null
+    // 使用当前鼠标所在的位置
+    if (useCurrentEventPos) {
+      let x = this.app.event.lastMousePos.x
+      let y = this.app.event.lastMousePos.y
+      pos = {
+        x,
+        y
+      }
+    }
+    if (this.beingCopyElement) {
+      this.copyElement(this.beingCopyElement, false, pos)
+    } else if (this.beingCopySelectedElements.length > 0) {
+      this.app.selection.selectElements(this.beingCopySelectedElements)
+      this.app.selection.copySelectionElements(useCurrentEventPos ? pos : null)
+    }
+  }
+
+  // 删除元素
+  deleteElement(element) {
+    this.app.elements.deleteElement(element)
+    this.render()
+    this.app.emitChange()
+  }
+
+  // 复制粘贴元素
+  async copyElement(element, notActive = false, pos) {
+    this.app.elements.cancelActiveElement()
+    await this.app.elements.copyElement(element, notActive, pos)
+    this.app.group.clearCopyMap()
+    this.render()
+    this.app.emitChange()
+  }
+
+  // 删除当前激活元素
+  deleteActiveElement() {
+    if (!this.app.elements.hasActiveElement()) {
+      return
+    }
+    this.deleteElement(this.app.elements.activeElement)
+  }
+
+  // 删除当前激活或选中的元素
+  deleteCurrentElements() {
+    // 当前激活元素
+    this.deleteActiveElement()
+    // 当前选中元素
+    this.app.selection.deleteSelectedElements()
+  }
+
+  // 将当前元素上移一层
+  moveUpCurrentElement() {
+    this.moveLevelCurrentElement('up')
+  }
+
+  // 将当前元素下移一层
+  moveDownCurrentElement() {
+    this.moveLevelCurrentElement('down')
+  }
+
+  // 将当前元素置于顶层
+  moveTopCurrentElement() {
+    this.moveLevelCurrentElement('top')
+  }
+
+  // 将当前元素置于底层
+  moveBottomCurrentElement() {
+    this.moveLevelCurrentElement('bottom')
+  }
+
+  // 移动当前元素的层级
+  moveLevelCurrentElement(level) {
+    let element = null
+    if (this.app.elements.hasActiveElement()) {
+      element = this.app.elements.activeElement
+    } else if (this.app.selection.getSelectionElements().length === 1) {
+      element = this.app.selection.getSelectionElements()[0]
+    }
+    if (!element) {
+      return
+    }
+    let index = this.app.elements.getElementIndex(element)
+    this.app.elements.elementList.splice(index, 1)
+    if (level === 'up') {
+      this.app.elements.insertElement(element, index + 1)
+    } else if (level === 'down') {
+      this.app.elements.insertElement(element, index - 1)
+    } else if (level === 'top') {
+      this.app.elements.addElement(element)
+    } else if (level === 'bottom') {
+      this.app.elements.unshiftElement(element)
+    }
+  }
+
+  // 为激活元素设置样式
+  setActiveElementStyle(style = {}) {
+    if (!this.app.elements.hasActiveElement()) {
+      return this
+    }
+    this.app.elements.setActiveElementStyle(style)
+    this.render()
+    if (!this.app.elements.isCreatingElement) {
+      this.app.emitChange()
+    }
+    return this
+  }
+
+  // 为当前激活或选中的元素设置样式
+  setCurrentElementsStyle(style = {}) {
+    this.setActiveElementStyle(style)
+    this.app.selection.setSelectedElementStyle(style)
+  }
+
+  // 取消当前激活元素
+  cancelActiveElement() {
+    if (!this.app.elements.hasActiveElement()) {
+      return this
+    }
+    this.app.elements.cancelActiveElement()
+    this.render()
+    return this
+  }
+
+  // 更新当前激活元素的位置
+  updateActiveElementPosition(x, y) {
+    if (!this.app.elements.hasActiveElement()) {
+      return this
+    }
+    this.app.elements.activeElement.updatePos(x, y)
+    this.render()
+    return this
+  }
+
+  // 更新当前激活元素的尺寸
+  updateActiveElementSize(width, height) {
+    if (!this.app.elements.hasActiveElement()) {
+      return this
+    }
+    this.app.elements.activeElement.updateSize(width, height)
+    this.render()
+    return this
+  }
+
+  // 更新当前激活元素的旋转角度
+  updateActiveElementRotate(rotate) {
+    if (!this.app.elements.hasActiveElement()) {
+      return this
+    }
+    this.app.elements.activeElement.updateRotate(rotate)
+    this.render()
+    return this
+  }
+
+  // 清空元素
+  empty() {
+    this.app.elements.deleteAllElements()
+    this.render()
+    this.app.history.clear()
+    this.app.emitChange()
+  }
+
+  // 放大
+  zoomIn(num = 0.1) {
+    this.app.updateState({
+      scale: this.app.state.scale + num
+    })
+    this.render()
+    this.app.emit('zoomChange', this.app.state.scale)
+  }
+
+  // 缩小
+  zoomOut(num = 0.1) {
+    this.app.updateState({
+      scale: this.app.state.scale - num > 0 ? this.app.state.scale - num : 0
+    })
+    this.render()
+    this.app.emit('zoomChange', this.app.state.scale)
+  }
+
+  // 设置指定缩放值
+  setZoom(zoom) {
+    if (zoom < 0) {
+      return
+    }
+    this.app.updateState({
+      scale: zoom
+    })
+    this.render()
+    this.app.emit('zoomChange', this.app.state.scale)
+  }
+
+  // 缩放以适应所有元素
+  fit() {
+    if (!this.app.elements.hasElements()) {
+      return
+    }
+    this.scrollToCenter()
+    // 计算所有元素的外包围框
+    let { minx, maxx, miny, maxy } = getMultiElementRectInfo(
+      this.app.elements.elementList
+    )
+    let width = maxx - minx
+    let height = maxy - miny
+    let maxScale = Math.min(this.app.width / width, this.app.height / height)
+    console.log(maxScale);
+    this.setZoom(maxScale)
+  }
+
+  // 滚动至指定位置
+  scrollTo(scrollX, scrollY) {
+    this.app.updateState({
+      scrollX,
+      scrollY
+    })
+    this.render()
+    this.app.emit(
+      'scrollChange',
+      this.app.state.scrollX,
+      this.app.state.scrollY
+    )
+  }
+
+  // 滚动至中心,即回到所有元素的中心位置
+  scrollToCenter() {
+    if (!this.app.elements.hasElements()) {
+      this.scrollTo(0, 0)
+      return
+    }
+    let { minx, maxx, miny, maxy } = getMultiElementRectInfo(
+      this.app.elements.elementList
+    )
+    let width = maxx - minx
+    let height = maxy - miny
+    this.scrollTo(
+      minx - (this.app.width - width) / 2,
+      miny - (this.app.height - height) / 2
+    )
+  }
+
+  // 复制粘贴当前元素
+  copyPasteCurrentElements() {
+    this.copyCurrentElement()
+    this.pasteCurrentElement()
+  }
+
+  // 设置背景颜色
+  setBackgroundColor(color) {
+    this.app.updateState({
+      backgroundColor: color
+    })
+    this.app.background.set()
+  }
+
+  // 选中所有元素
+  selectAll() {
+    this.app.selection.selectElements(this.app.elements.elementList)
+  }
+}

+ 317 - 0
tiny-whiteboard/src/Selection.js

@@ -0,0 +1,317 @@
+import { throttle, getElementCorners, getBoundingRect } from './utils'
+import Rectangle from './elements/Rectangle'
+import Canvas from './Canvas'
+import Coordinate from './Coordinate'
+import MultiSelectElement from './elements/MultiSelectElement'
+import { DRAG_ELEMENT_PARTS } from './constants'
+
+// 多选类
+export default class Selection {
+  constructor(app) {
+    this.app = app
+    this.canvas = null
+    this.ctx = null
+    // 当前是否正在创建选区中
+    this.creatingSelection = false
+    // 当前是否存在多选元素
+    this.hasSelection = false
+    // 当前是否正在调整被选中的元素
+    this.isResizing = false
+    this.state = this.app.state
+    this.width = this.app.width
+    this.height = this.app.height
+    this.coordinate = new Coordinate(this)
+    // 选区矩形
+    this.rectangle = new Rectangle(
+      {
+        type: 'rectangle',
+        style: {
+          strokeStyle: 'rgba(9,132,227,0.3)',
+          fillStyle: 'rgba(9,132,227,0.3)'
+        }
+      },
+      this
+    )
+    // 被选中的元素的虚拟元素,用于显示拖拽框
+    this.multiSelectElement = new MultiSelectElement(
+      {
+        type: 'multiSelectElement'
+      },
+      this
+    )
+    this.checkInNodes = throttle(this.checkInNodes, this, 500)
+    // 稍微缓解一下卡顿
+    this.handleResize = throttle(this.handleResize, this, 16)
+    this.init()
+    this.bindEvent()
+  }
+
+  // 初始化
+  init() {
+    if (this.canvas) {
+      this.app.container.removeChild(this.canvas.el)
+    }
+    this.width = this.app.width
+    this.height = this.app.height
+    // 创建canvas元素
+    this.canvas = new Canvas(this.width, this.height, {
+      className: 'selection'
+    })
+    this.ctx = this.canvas.ctx
+    this.app.container.appendChild(this.canvas.el)
+  }
+
+  // 监听事件
+  bindEvent() {
+    this.app.on('change', () => {
+      this.state = this.app.state
+      this.multiSelectElement.updateElements(this.app.elements.elementList)
+      this.renderSelection()
+    })
+    this.app.on('scrollChange', () => {
+      this.renderSelection()
+    })
+    this.app.on('zoomChange', () => {
+      this.renderSelection()
+    })
+  }
+
+  // 鼠标按下
+  onMousedown(e, event) {
+    if (e.originEvent.which !== 1) {
+      return
+    }
+    this.creatingSelection = true
+    this.rectangle.updatePos(event.mousedownPos.x, event.mousedownPos.y)
+  }
+
+  // 鼠标移动
+  onMousemove(e, event) {
+    if (
+      Math.abs(event.mouseOffset.x) <= 10 &&
+      Math.abs(event.mouseOffset.y) <= 10
+    ) {
+      return
+    }
+    this.onMove(e, event)
+  }
+
+  // 鼠标松开
+  onMouseup() {
+    this.creatingSelection = false
+    this.rectangle.updateRect(0, 0, 0, 0)
+    // 判断是否有元素被选中
+    this.hasSelection = this.hasSelectionElements()
+    this.multiSelectElement.updateRect()
+    this.renderSelection()
+    this.emitChange()
+  }
+
+  // 复位
+  reset() {
+    this.setMultiSelectElements([])
+    this.hasSelection = false
+    this.renderSelection()
+    this.emitChange()
+  }
+
+  // 渲染
+  renderSelection() {
+    this.canvas.clearCanvas()
+    this.ctx.save()
+    this.ctx.scale(this.app.state.scale, this.app.state.scale)
+    this.rectangle.render()
+    this.multiSelectElement.render()
+    this.ctx.restore()
+  }
+
+  // 鼠标移动事件
+  onMove(e, event) {
+    this.rectangle.updateSize(event.mouseOffset.x, event.mouseOffset.y)
+    this.renderSelection()
+    this.checkInElements(e, event)
+  }
+
+  // 检测在选区里的节点
+  checkInElements(e, event) {
+    let minx = Math.min(event.mousedownPos.x, e.clientX)
+    let miny = Math.min(event.mousedownPos.y, e.clientY)
+    let maxx = Math.max(event.mousedownPos.x, e.clientX)
+    let maxy = Math.max(event.mousedownPos.y, e.clientY)
+    let selectedElementList = []
+    this.app.elements.elementList.forEach(element => {
+      let _minx = Infinity
+      let _maxx = -Infinity
+      let _miny = Infinity
+      let _maxy = -Infinity
+      let endPointList = element.getEndpointList()
+      let rect = getBoundingRect(
+        endPointList.map(point => {
+          return [point.x, point.y]
+        }),
+        true
+      )
+      rect.forEach(({ x, y }) => {
+        if (x < _minx) {
+          _minx = x
+        }
+        if (x > _maxx) {
+          _maxx = x
+        }
+        if (y < _miny) {
+          _miny = y
+        }
+        if (y > _maxy) {
+          _maxy = y
+        }
+      })
+      if (_minx >= minx && _maxx <= maxx && _miny >= miny && _maxy <= maxy) {
+        selectedElementList.push(element)
+      }
+    })
+    let finalList = [...selectedElementList]
+    selectedElementList.forEach(item => {
+      if (item.hasGroup()) {
+        finalList.push(...this.app.group.getGroupElements(item))
+      }
+    })
+    finalList = new Set(finalList)
+    finalList = Array.from(finalList)
+    this.setMultiSelectElements(finalList, true)
+    this.app.render.render()
+  }
+
+  // 检测指定位置是否在元素调整手柄上
+  checkInResizeHand(x, y) {
+    return this.multiSelectElement.dragElement.checkPointInDragElementWhere(
+      x,
+      y
+    )
+  }
+
+  // 检查是否需要进行元素调整操作
+  checkIsResize(x, y, e) {
+    if (!this.hasSelection) {
+      return false
+    }
+    let hand = this.multiSelectElement.dragElement.checkPointInDragElementWhere(
+      x,
+      y
+    )
+    if (hand) {
+      this.isResizing = true
+      this.multiSelectElement.startResize(hand, e)
+      this.app.cursor.setResize(hand)
+      return true
+    }
+    return false
+  }
+
+  // 进行元素调整操作
+  handleResize(...args) {
+    if (!this.isResizing) {
+      return
+    }
+    this.multiSelectElement.resize(...args)
+    this.app.render.render()
+    this.multiSelectElement.updateRect()
+    this.renderSelection()
+  }
+
+  // 结束元素调整操作
+  endResize() {
+    this.isResizing = false
+    this.multiSelectElement.endResize()
+  }
+
+  // 为多选元素设置样式
+  setSelectedElementStyle(style = {}) {
+    if (!this.hasSelectionElements()) {
+      return
+    }
+    Object.keys(style).forEach(key => {
+      this.getSelectionElements().forEach(element => {
+        element.style[key] = style[key]
+        if (key === 'fontSize' && element.type === 'text') {
+          element.updateTextSize()
+          this.multiSelectElement.updateRect()
+        }
+      })
+    })
+    this.app.render.render()
+    this.app.emitChange()
+  }
+
+  // 删除当前选中的元素
+  deleteSelectedElements() {
+    this.getSelectionElements().forEach(element => {
+      this.app.elements.deleteElement(element)
+    })
+    this.selectElements([])
+    this.app.emitChange()
+  }
+
+  // 当前是否存在被选中元素
+  hasSelectionElements() {
+    return this.getSelectionElements().length > 0
+  }
+
+  // 获取当前被选中的元素
+  getSelectionElements() {
+    return this.multiSelectElement.selectedElementList
+  }
+
+  // 复制当前选中的元素
+  async copySelectionElements(pos) {
+    let task = this.getSelectionElements().map(element => {
+      return this.app.elements.copyElement(element, true)
+    })
+    this.app.group.clearCopyMap()
+    let elements = await Promise.all(task)
+    this.setMultiSelectElements(elements)
+    // 粘贴到指定位置
+    if (pos) {
+      this.multiSelectElement.startResize(DRAG_ELEMENT_PARTS.BODY)
+      let ox =
+        pos.x - this.multiSelectElement.x - this.multiSelectElement.width / 2
+      let oy =
+        pos.y - this.multiSelectElement.y - this.multiSelectElement.height / 2
+      // 如果开启了网格,那么要坐标要吸附到网格
+      let gridAdsorbentPos = this.app.coordinate.gridAdsorbent(ox, oy)
+      this.multiSelectElement.resize(
+        null,
+        null,
+        null,
+        gridAdsorbentPos.x,
+        gridAdsorbentPos.y
+      )
+      this.multiSelectElement.endResize()
+      this.multiSelectElement.updateRect()
+    }
+    this.app.render.render()
+    this.renderSelection()
+    this.app.emitChange()
+  }
+
+  // 选中指定元素
+  selectElements(elements = []) {
+    this.hasSelection = elements.length > 0
+    this.setMultiSelectElements(elements)
+    this.app.render.render()
+    this.renderSelection()
+    this.emitChange()
+  }
+
+  // 设置选中的元素
+  setMultiSelectElements(elements = [], notUpdateRect) {
+    this.multiSelectElement.setSelectedElementList(elements)
+    if (!notUpdateRect) {
+      this.multiSelectElement.updateRect()
+    }
+  }
+
+  // 触发多选元素变化事件
+  emitChange() {
+    this.app.emit('multiSelectChange', this.getSelectionElements())
+  }
+}

+ 103 - 0
tiny-whiteboard/src/TextEdit.js

@@ -0,0 +1,103 @@
+import { getFontString, getTextElementSize } from './utils'
+import EventEmitter from 'eventemitter3'
+
+// 文字编辑类
+export default class TextEdit extends EventEmitter {
+  constructor(app) {
+    super()
+    this.app = app
+    this.editable = null
+    this.isEditing = false
+    this.onTextInput = this.onTextInput.bind(this)
+    this.onTextBlur = this.onTextBlur.bind(this)
+  }
+
+  // 创建文本输入框元素
+  crateTextInputEl() {
+    this.editable = document.createElement('textarea')
+    this.editable.dir = 'auto'
+    this.editable.tabIndex = 0
+    this.editable.wrap = 'off'
+    this.editable.className = 'textInput'
+    Object.assign(this.editable.style, {
+      position: 'fixed',
+      display: 'block',
+      minHeight: '1em',
+      backfaceVisibility: 'hidden',
+      margin: 0,
+      padding: 0,
+      border: 0,
+      outline: 0,
+      resize: 'none',
+      background: 'transparent',
+      overflow: 'hidden',
+      whiteSpace: 'pre'
+    })
+    this.editable.addEventListener('input', this.onTextInput)
+    this.editable.addEventListener('blur', this.onTextBlur)
+    document.body.appendChild(this.editable)
+  }
+
+  // 根据当前文字元素的样式更新文本输入框的样式
+  updateTextInputStyle() {
+    let activeElement = this.app.elements.activeElement
+    if (!activeElement) {
+      return
+    }
+    let { x, y, width, height, style, text, rotate } = activeElement
+    let { coordinate, state } = this.app
+    this.editable.value = text
+    x = coordinate.subScrollX(x)
+    y = coordinate.subScrollY(y)
+    // 屏幕坐标转画布坐标
+    let sp = coordinate.scale(x, y)
+    let tp = coordinate.containerToWindow(sp.x, sp.y)
+    let fontSize = style.fontSize * state.scale
+    let styles = {
+      font: getFontString(fontSize, style.fontFamily),
+      lineHeight: `${fontSize * style.lineHeightRatio}px`,
+      left: `${tp.x}px`,
+      top: `${tp.y}px`,
+      color: style.fillStyle,
+      width: Math.max(width, 100) * state.scale + 'px',
+      height: height * state.scale + 'px',
+      transform: `rotate(${rotate}deg)`,
+      opacity: style.globalAlpha
+    }
+    Object.assign(this.editable.style, styles)
+  }
+
+  // 文本输入事件
+  onTextInput() {
+    let activeElement = this.app.elements.activeElement
+    if (!activeElement) {
+      return
+    }
+    activeElement.text = this.editable.value
+    let { width, height } = getTextElementSize(activeElement)
+    activeElement.width = width
+    activeElement.height = height
+    this.updateTextInputStyle()
+  }
+
+  // 文本框失焦事件
+  onTextBlur() {
+    this.editable.style.display = 'none'
+    this.editable.value = ''
+    this.emit('blur')
+    this.isEditing = false
+  }
+
+  // 显示文本编辑框
+  showTextEdit() {
+    if (!this.editable) {
+      this.crateTextInputEl()
+    } else {
+      this.editable.style.display = 'block'
+    }
+    this.updateTextInputStyle()
+    this.editable.focus()
+    this.editable.select()
+    this.isEditing = true
+  }
+}

+ 20 - 0
tiny-whiteboard/src/constants.js

@@ -0,0 +1,20 @@
+// 元素的四个角
+export const CORNERS = {
+  TOP_LEFT: 'topLeft', // 左上角
+  TOP_RIGHT: 'topRight', // 右上角
+  BOTTOM_RIGHT: 'bottomRight', // 右下角
+  BOTTOM_LEFT: 'bottomLeft' // 左下角
+}
+
+// 拖拽元素的部位
+export const DRAG_ELEMENT_PARTS = {
+  BODY: 'body',
+  ROTATE: 'rotate',
+  TOP_LEFT_BTN: 'topLeftBtn',
+  TOP_RIGHT_BTN: 'topRightBtn',
+  BOTTOM_RIGHT_BTN: 'bottomRightBtn',
+  BOTTOM_LEFT_BTN: 'bottomLeftBtn'
+}
+
+// 距离10像素内都认为点击到了目标
+export const HIT_DISTANCE = 10

+ 48 - 0
tiny-whiteboard/src/elements/Arrow.js

@@ -0,0 +1,48 @@
+import BaseMultiPointElement from './BaseMultiPointElement'
+import { drawArrow } from '../utils/draw'
+import DragElement from './DragElement'
+import { transformPointOnElement } from '../utils'
+import { checkIsAtArrowEdge } from '../utils/checkHit'
+
+// 箭头元素类
+export default class Arrow extends BaseMultiPointElement {
+  constructor(...args) {
+    super(...args)
+    // 拖拽元素实例
+    this.dragElement = new DragElement(this, this.app)
+  }
+
+  // 渲染到画布
+  render() {
+    let { pointArr, fictitiousPoint } = this
+    this.warpRender(({ cx, cy }) => {
+      // 加上鼠标当前实时位置
+      let realtimePoint = []
+      if (pointArr.length > 0 && this.isCreating) {
+        let { x: fx, y: fy } = this.app.coordinate.transform(
+          fictitiousPoint.x - cx,
+          fictitiousPoint.y - cy
+        )
+        realtimePoint = [[fx, fy]]
+      }
+      drawArrow(
+        this.app.ctx,
+        pointArr
+          .map(point => {
+            // 屏幕坐标在左上角,画布坐标在中心,所以屏幕坐标要先转成画布坐标
+            let { x, y } = this.app.coordinate.transform(point[0], point[1])
+            return [x - cx, y - cy]
+          })
+          .concat(realtimePoint)
+      )
+    })
+    // 激活时显示拖拽框
+    this.renderDragElement()
+  }
+
+  // 检测是否被击中
+  isHit(x, y) {
+    let rp = transformPointOnElement(x, y, this)
+    return checkIsAtArrowEdge(this, rp)
+  }
+}

+ 270 - 0
tiny-whiteboard/src/elements/BaseElement.js

@@ -0,0 +1,270 @@
+import {
+  degToRad,
+  getRotatedPoint,
+  getElementCorners,
+  createNodeKey
+} from '../utils'
+import EventEmitter from 'eventemitter3'
+
+// 基础元素类
+export default class BaseElement extends EventEmitter {
+  constructor(opts = {}, app) {
+    super()
+    this.app = app
+    // 编组id
+    this.groupId = opts.groupId || ''
+    // 类型
+    this.type = opts.type || ''
+    // key
+    this.key = createNodeKey()
+    // 是否正在创建中
+    this.isCreating = true
+    // 是否被激活
+    this.isActive = true
+    // 是否被多选选中
+    this.isSelected = false
+    // 记录初始位置,用于拖动时
+    this.startX = 0
+    this.startY = 0
+    // 实时位置,该位置为元素的左上角坐标
+    this.x = opts.x || 0
+    this.y = opts.y || 0
+    // 宽高
+    this.width = opts.width || 0
+    this.height = opts.height || 0
+    // 记录初始角度,用于旋转时
+    this.startRotate = 0
+    // 角度
+    this.rotate = opts.rotate || 0
+    // 是否不需要渲染
+    this.noRender = false
+    // 样式
+    this.style = {
+      strokeStyle: '', // 线条颜色
+      fillStyle: '', // 填充颜色
+      lineWidth: 'small', // 线条宽度
+      lineDash: 0, // 线条虚线大小
+      globalAlpha: 1, // 透明度
+      ...(opts.style || {})
+    }
+    // 拖拽元素实例
+    this.dragElement = null
+  }
+
+  // 序列化
+  serialize() {
+    return {
+      groupId: this.groupId,
+      type: this.type,
+      width: this.width,
+      height: this.height,
+      x: this.x,
+      y: this.y,
+      rotate: this.rotate,
+      style: {
+        ...this.style
+      }
+    }
+  }
+
+  // 渲染方法
+  render() {
+    throw new Error('子类需要实现该方法!')
+  }
+
+  // 设置所属编组id
+  setGroupId(groupId) {
+    this.groupId = groupId
+  }
+
+  // 获取所属组id
+  getGroupId() {
+    return this.groupId
+  }
+
+  // 移除所属组id
+  removeGroupId() {
+    this.groupId = ''
+  }
+
+  // 是否存在编组
+  hasGroup() {
+    return !!this.groupId
+  }
+
+  // 渲染拖拽元素
+  renderDragElement() {
+    if (this.isActive && !this.isCreating) {
+      this.dragElement.showAll()
+      this.dragElement.render()
+    } else if (this.isSelected) {
+      // 被多选选中
+      this.dragElement.onlyShowBody()
+      this.dragElement.render()
+    }
+  }
+
+  // 处理样式数据
+  handleStyle(style) {
+    Object.keys(style).forEach(key => {
+      // 处理线条宽度
+      if (key === 'lineWidth') {
+        if (style[key] === 'small') {
+          style[key] = 2
+        } else if (style[key] === 'middle') {
+          style[key] = 4
+        } else if (style[key] === 'large') {
+          style[key] = 6
+        }
+      }
+      if (style[key] === '') {
+        if (
+          this.app.state[key] !== undefined &&
+          this.app.state[key] !== null &&
+          this.app.state[key] !== ''
+        ) {
+          style[key] = this.app.state[key]
+        }
+      }
+    })
+    return style
+  }
+
+  // 设置绘图样式
+  setStyle(style = {}) {
+    let _style = this.handleStyle(style)
+    Object.keys(_style).forEach(key => {
+      // 处理虚线
+      if (key === 'lineDash') {
+        if (_style.lineDash > 0) {
+          this.app.ctx.setLineDash([_style.lineDash])
+        }
+      } else if (
+        _style[key] !== undefined &&
+        _style[key] !== '' &&
+        _style[key] !== null
+      ) {
+        this.app.ctx[key] = _style[key]
+      }
+    })
+    return this
+  }
+
+  // 公共渲染操作
+  warpRender(renderFn) {
+    let { x, y, width, height, rotate, style } = this
+    // 坐标转换
+    let { x: tx, y: ty } = this.app.coordinate.transform(x, y)
+    // 移动画布中点到元素中心,否则旋转时中心点不对
+    let halfWidth = width / 2
+    let halfHeight = height / 2
+    let cx = tx + halfWidth
+    let cy = ty + halfHeight
+    this.app.ctx.save()
+    this.app.ctx.translate(cx, cy)
+    this.app.ctx.rotate(degToRad(rotate))
+    this.setStyle(style)
+    renderFn({
+      halfWidth,
+      halfHeight,
+      tx,
+      ty,
+      cx,
+      cy
+    })
+    this.app.ctx.restore()
+    return this
+  }
+
+  // 保存元素初始状态
+  saveState() {
+    let { rotate, x, y } = this
+    this.startRotate = rotate
+    this.startX = x
+    this.startY = y
+    return this
+  }
+
+  // 移动元素
+  move(ox, oy) {
+    let { startX, startY } = this
+    this.x = startX + ox
+    this.y = startY + oy
+    this.emit('elementPositionChange', this.x, this.y)
+    return this
+  }
+
+  // 更新元素包围框
+  updateRect(x, y, width, height) {
+    this.updatePos(x, y)
+    this.updateSize(width, height)
+    return this
+  }
+
+  // 更新激活元素尺寸
+  updateSize(width, height) {
+    this.width = width
+    this.height = height
+    this.emit('elementSizeChange', this.width, this.height)
+    return this
+  }
+
+  // 更新激活元素坐标
+  updatePos(x, y) {
+    this.x = x
+    this.y = y
+    this.emit('elementPositionChange', this.x, this.y)
+    return this
+  }
+
+  // 偏移元素角度
+  offsetRotate(or) {
+    this.updateRotate(this.startRotate + or)
+    return this
+  }
+
+  // 更新元素角度
+  updateRotate(rotate) {
+    rotate = rotate % 360
+    if (rotate < 0) {
+      rotate = 360 + rotate
+    }
+    this.rotate = parseInt(rotate)
+    this.emit('elementRotateChange', this.rotate)
+  }
+
+  // 根据指定中心点旋转元素的各个点
+  rotateByCenter(rotate, cx, cy) {
+    this.offsetRotate(rotate)
+    let np = getRotatedPoint(this.startX, this.startY, cx, cy, rotate)
+    this.updatePos(np.x, np.y)
+  }
+
+  // 检测元素是否被击中
+  isHit(x, y) {
+    throw new Error('子类需要实现该方法!')
+  }
+
+  // 开始调整元素
+  startResize(resizeType, e) {
+    this.dragElement.startResize(resizeType, e)
+    return this
+  }
+
+  // 结束调整元素操作
+  endResize() {
+    this.dragElement.endResize()
+    return this
+  }
+
+  // 调整元素中
+  resize(...args) {
+    this.dragElement.handleResizeElement(...args)
+    return this
+  }
+
+  // 获取图形应用了旋转之后的端点列表
+  getEndpointList() {
+    return getElementCorners(this)
+  }
+}

+ 134 - 0
tiny-whiteboard/src/elements/BaseMultiPointElement.js

@@ -0,0 +1,134 @@
+import {
+  getBoundingRect,
+  deepCopy,
+  getRotatedPoint,
+  getElementCenterPoint
+} from '../utils'
+import BaseElement from './BaseElement'
+
+// 基础多个点的组件的元素类
+export default class BaseMultiPointElement extends BaseElement {
+  constructor(opts = {}, app) {
+    super(opts, app)
+    // 记录初始点位,在拖动时
+    this.startPointArr = []
+    // 点位
+    this.pointArr = opts.pointArr || []
+    // 记录初始大小,用于缩放时
+    this.startWidth = 0
+    this.startHeight = 0
+    // 鼠标当前实时位置,用于在绘制时显示线段最后一个点到当前鼠标的虚拟连接线
+    this.fictitiousPoint = {
+      x: 0,
+      y: 0
+    }
+  }
+
+  // 序列化
+  serialize() {
+    let base = super.serialize()
+    return {
+      ...base,
+      pointArr: [...this.pointArr]
+    }
+  }
+
+  // 添加坐标,具有多个坐标数据的图形,如线段、自由线
+  addPoint(x, y, ...args) {
+    if (!Array.isArray(this.pointArr)) {
+      return
+    }
+    this.pointArr.push([x, y, ...args])
+    return this
+  }
+
+  // 更新元素包围框,用于具有多个坐标数据的图形
+  updateMultiPointBoundingRect() {
+    let rect = getBoundingRect(this.pointArr)
+    this.x = rect.x
+    this.y = rect.y
+    this.width = rect.width
+    this.height = rect.height
+    return this
+  }
+
+  // 更新虚拟坐标点
+  updateFictitiousPoint(x, y) {
+    this.fictitiousPoint.x = x
+    this.fictitiousPoint.y = y
+  }
+
+  // 保存元素初始状态
+  saveState() {
+    let { rotate, x, y, width, height, pointArr } = this
+    this.startRotate = rotate
+    this.startX = x
+    this.startY = y
+    this.startPointArr = deepCopy(pointArr)
+    this.startWidth = width
+    this.startHeight = height
+    return this
+  }
+
+  // 移动元素
+  move(ox, oy) {
+    this.pointArr = this.startPointArr.map(point => {
+      return [point[0] + ox, point[1] + oy, ...point.slice(2)]
+    })
+    let { startX, startY } = this
+    this.x = startX + ox
+    this.y = startY + oy
+    return this
+  }
+
+  // 更新元素包围框
+  updateRect(x, y, width, height) {
+    let { startWidth, startHeight, startPointArr } = this
+    // 获取收缩比例
+    let scaleX = width / startWidth
+    let scaleY = height / startHeight
+    // 所有点位都进行同步缩放
+    this.pointArr = startPointArr.map(point => {
+      let nx = point[0] * scaleX
+      let ny = point[1] * scaleY
+      return [nx, ny, ...point.slice(2)]
+    })
+    // 放大后会偏移拖拽元素,所以计算一下元素的新包围框和拖拽元素包围框的差距,然后绘制时整体往回偏移
+    let rect = getBoundingRect(this.pointArr)
+    let offsetX = rect.x - x
+    let offsetY = rect.y - y
+    this.pointArr = this.pointArr.map(point => {
+      return [point[0] - offsetX, point[1] - offsetY, ...point.slice(2)]
+    })
+    this.updatePos(x, y)
+    this.updateSize(width, height)
+    return this
+  }
+
+  // 根据指定中心点旋转元素的各个点
+  rotateByCenter(rotate, cx, cy) {
+    this.pointArr = this.startPointArr.map(point => {
+      let np = getRotatedPoint(point[0], point[1], cx, cy, rotate)
+      return [np.x, np.y, ...point.slice(2)]
+    })
+    this.updateMultiPointBoundingRect()
+  }
+
+  // 获取图形应用了旋转之后的端点列表
+  getEndpointList() {
+    return this.pointArr.map(point => {
+      let center = getElementCenterPoint(this)
+      let np = getRotatedPoint(
+        point[0],
+        point[1],
+        center.x,
+        center.y,
+        this.rotate
+      )
+      return {
+        x: np.x,
+        y: np.y
+      }
+    })
+  }
+}

+ 33 - 0
tiny-whiteboard/src/elements/Circle.js

@@ -0,0 +1,33 @@
+import BaseElement from './BaseElement'
+import { drawCircle } from '../utils/draw'
+import DragElement from './DragElement'
+import { transformPointOnElement } from '../utils'
+import { getCircleRadius, checkIsAtCircleEdge } from '../utils/checkHit'
+
+// 正圆元素类
+export default class Circle extends BaseElement {
+  constructor(...args) {
+    super(...args)
+    // 拖拽元素实例
+    this.dragElement = new DragElement(this, this.app, {
+      lockRatio: true
+    })
+  }
+
+  // 渲染到画布
+  render() {
+    let { width, height } = this
+    this.warpRender(({ halfWidth, halfHeight }) => {
+      // 画布中心点修改了,所以元素的坐标也要相应修改
+      drawCircle(this.app.ctx, 0, 0, getCircleRadius(width, height), true)
+    })
+    // 激活时显示拖拽框
+    this.renderDragElement()
+  }
+
+  // 检测是否被击中
+  isHit(x, y) {
+    let rp = transformPointOnElement(x, y, this)
+    return checkIsAtCircleEdge(this, rp)
+  }
+}

+ 50 - 0
tiny-whiteboard/src/elements/Diamond.js

@@ -0,0 +1,50 @@
+import BaseElement from './BaseElement'
+import { drawDiamond } from '../utils/draw'
+import DragElement from './DragElement'
+import {
+  transformPointOnElement,
+  getRotatedPoint,
+  getElementCenterPoint
+} from '../utils'
+import { checkIsAtDiamondEdge } from '../utils/checkHit'
+
+// 菱形元素类
+export default class Diamond extends BaseElement {
+  constructor(...args) {
+    super(...args)
+    // 拖拽元素实例
+    this.dragElement = new DragElement(this, this.app)
+  }
+
+  // 渲染到画布
+  render() {
+    let { width, height } = this
+    this.warpRender(({ halfWidth, halfHeight }) => {
+      // 画布中心点修改了,所以元素的坐标也要相应修改
+      drawDiamond(this.app.ctx, -halfWidth, -halfHeight, width, height, true)
+    })
+    // 激活时显示拖拽框
+    this.renderDragElement()
+  }
+
+  // 检测是否被击中
+  isHit(x, y) {
+    let rp = transformPointOnElement(x, y, this)
+    return checkIsAtDiamondEdge(this, rp)
+  }
+
+  // 获取图形应用了旋转之后的端点列表
+  getEndpointList() {
+    let { x, y, width, height, rotate } = this
+    let points = [
+      [x + width / 2, y],
+      [x + width, y + height / 2],
+      [x + width / 2, y + height],
+      [x, y + height / 2]
+    ]
+    let center = getElementCenterPoint(this)
+    return points.map(point => {
+      return getRotatedPoint(point[0], point[1], center.x, center.y, rotate)
+    })
+  }
+}

+ 529 - 0
tiny-whiteboard/src/elements/DragElement.js

@@ -0,0 +1,529 @@
+import {
+  getTowPointDistance,
+  transformPointOnElement,
+  checkPointIsInRectangle,
+  getTowPointRotate,
+  getElementCenterPoint,
+  transformPointReverseRotate,
+  getElementRotatedCornerPoint,
+  getRotatedPoint
+} from '../utils'
+import { CORNERS, DRAG_ELEMENT_PARTS } from '../constants'
+import BaseElement from './BaseElement'
+import { drawRect, drawCircle } from '../utils/draw'
+
+// 拖拽元素
+export default class DragElement extends BaseElement {
+  constructor(element, app, opts = {}) {
+    super(
+      {
+        type: 'dragElement',
+        notNeedDragElement: true
+      },
+      app
+    )
+
+    this.opts = {
+      // 是否锁定长宽比
+      lockRatio: false,
+      ...opts
+    }
+
+    // 样式
+    this.style = {
+      strokeStyle: this.app.state.dragStrokeStyle, // 线条颜色
+      fillStyle: 'transparent', // 填充颜色
+      lineWidth: 'small', // 线条宽度
+      lineDash: 0, // 线条虚线大小
+      globalAlpha: 1 // 透明度
+    }
+
+    // 归属节点
+    this.element = element
+
+    // 和元素的距离
+    this.offset = 5
+    // 拖拽手柄尺寸
+    this.size = 10
+
+    // 当前正在进行何种调整操作
+    this.resizeType = ''
+    // 当前鼠标按住拖拽元素的点的对角点
+    this.diagonalPoint = {
+      x: 0,
+      y: 0
+    }
+    // 当前鼠标按下时的坐标和拖拽元素的点的坐标差值
+    this.mousedownPosAndElementPosOffset = {
+      x: 0,
+      y: 0
+    }
+    // 元素的长宽比
+    this.elementRatio = 0
+    // 隐藏的部分
+    this.hideParts = []
+  }
+
+  // 设置隐藏的部分
+  setHideParts(parts = []) {
+    this.hideParts = parts
+  }
+
+  // 显示所有部分
+  showAll() {
+    this.setHideParts([])
+  }
+
+  // 只显示主体部分
+  onlyShowBody() {
+    this.setHideParts([
+      DRAG_ELEMENT_PARTS.ROTATE,
+      DRAG_ELEMENT_PARTS.TOP_LEFT_BTN,
+      DRAG_ELEMENT_PARTS.TOP_RIGHT_BTN,
+      DRAG_ELEMENT_PARTS.BOTTOM_RIGHT_BTN,
+      DRAG_ELEMENT_PARTS.BOTTOM_LEFT_BTN
+    ])
+  }
+
+  // 更新数据
+  update() {
+    this.x = this.element.x - this.offset
+    this.y = this.element.y - this.offset
+    this.width = this.element.width + this.offset * 2
+    this.height = this.element.height + this.offset * 2
+    this.rotate = this.element.rotate
+  }
+
+  // 渲染
+  render() {
+    // 如果被编组了那么不显示组元素自身的拖拽框
+    if (this.element.hasGroup()) return
+    this.update()
+    let { width, height } = this
+    this.warpRender(({ halfWidth, halfHeight }) => {
+      // 主体
+      this.app.ctx.save()
+      if (!this.hideParts.includes(DRAG_ELEMENT_PARTS.BODY)) {
+        this.app.ctx.setLineDash([5])
+        drawRect(this.app.ctx, -halfWidth, -halfHeight, width, height)
+        this.app.ctx.restore()
+      }
+      // 左上角
+      if (!this.hideParts.includes(DRAG_ELEMENT_PARTS.TOP_LEFT_BTN)) {
+        drawRect(
+          this.app.ctx,
+          -halfWidth - this.size,
+          -halfHeight - this.size,
+          this.size,
+          this.size
+        )
+      }
+      // 右上角
+      if (!this.hideParts.includes(DRAG_ELEMENT_PARTS.TOP_RIGHT_BTN)) {
+        drawRect(
+          this.app.ctx,
+          -halfWidth + this.element.width + this.size,
+          -halfHeight - this.size,
+          this.size,
+          this.size
+        )
+      }
+      // 右下角
+      if (!this.hideParts.includes(DRAG_ELEMENT_PARTS.BOTTOM_RIGHT_BTN)) {
+        drawRect(
+          this.app.ctx,
+          -halfWidth + this.element.width + this.size,
+          -halfHeight + this.element.height + this.size,
+          this.size,
+          this.size
+        )
+      }
+      // 左下角
+      if (!this.hideParts.includes(DRAG_ELEMENT_PARTS.BOTTOM_LEFT_BTN)) {
+        drawRect(
+          this.app.ctx,
+          -halfWidth - this.size,
+          -halfHeight + this.element.height + this.size,
+          this.size,
+          this.size
+        )
+      }
+      // 旋转按钮
+      if (!this.hideParts.includes(DRAG_ELEMENT_PARTS.ROTATE)) {
+        drawCircle(
+          this.app.ctx,
+          -halfWidth + this.element.width / 2 + this.size / 2,
+          -halfHeight - this.size * 2,
+          this.size
+        )
+      }
+    })
+  }
+
+  // 检测一个坐标在拖拽元素的哪个部分上
+  checkPointInDragElementWhere(x, y) {
+    let part = ''
+    // 坐标反向旋转元素的角度
+    let rp = transformPointOnElement(x, y, this.element)
+    // 在内部
+    if (checkPointIsInRectangle(rp.x, rp.y, this)) {
+      part = DRAG_ELEMENT_PARTS.BODY
+    } else if (
+      getTowPointDistance(
+        rp.x,
+        rp.y,
+        this.x + this.width / 2,
+        this.y - this.size * 2
+      ) <= this.size
+    ) {
+      // 在旋转按钮
+      part = DRAG_ELEMENT_PARTS.ROTATE
+    } else if (this._checkPointIsInBtn(rp.x, rp.y, CORNERS.TOP_LEFT)) {
+      // 在左上角伸缩手柄
+      part = DRAG_ELEMENT_PARTS.TOP_LEFT_BTN
+    } else if (this._checkPointIsInBtn(rp.x, rp.y, CORNERS.TOP_RIGHT)) {
+      // 在右上角伸缩手柄
+      part = DRAG_ELEMENT_PARTS.TOP_RIGHT_BTN
+    } else if (this._checkPointIsInBtn(rp.x, rp.y, CORNERS.BOTTOM_RIGHT)) {
+      // 在右下角伸缩手柄
+      part = DRAG_ELEMENT_PARTS.BOTTOM_RIGHT_BTN
+    } else if (this._checkPointIsInBtn(rp.x, rp.y, CORNERS.BOTTOM_LEFT)) {
+      // 在左下角伸缩手柄
+      part = DRAG_ELEMENT_PARTS.BOTTOM_LEFT_BTN
+    }
+    if (this.hideParts.includes(part)) {
+      part = ''
+    }
+    return part
+  }
+
+  // 检测坐标是否在某个拖拽按钮内
+  _checkPointIsInBtn(x, y, dir) {
+    let _x = 0
+    let _y = 0
+    switch (dir) {
+      case CORNERS.TOP_LEFT:
+        _x = this.x - this.size
+        _y = this.y - this.size
+        break
+      case CORNERS.TOP_RIGHT:
+        _x = this.x + this.width
+        _y = this.y - this.size
+        break
+      case CORNERS.BOTTOM_RIGHT:
+        _x = this.x + this.width
+        _y = this.y + this.height
+        break
+      case CORNERS.BOTTOM_LEFT:
+        _x = this.x - this.size
+        _y = this.y + this.height
+        break
+      default:
+        break
+    }
+    return checkPointIsInRectangle(x, y, _x, _y, this.size, this.size)
+  }
+
+  // 开始调整元素
+  startResize(resizeType, e) {
+    this.resizeType = resizeType
+    if (this.opts.lockRatio) {
+      this.elementRatio = this.element.width / this.element.height
+    }
+    if (resizeType === DRAG_ELEMENT_PARTS.BODY) {
+      // 按住了拖拽元素内部
+      this.element.saveState()
+    } else if (resizeType === DRAG_ELEMENT_PARTS.ROTATE) {
+      // 按住了拖拽元素的旋转按钮
+      this.element.saveState()
+    } else if (resizeType === DRAG_ELEMENT_PARTS.TOP_LEFT_BTN) {
+      // 按住了拖拽元素左上角拖拽手柄
+      this.handleDragMousedown(e, CORNERS.TOP_LEFT)
+    } else if (resizeType === DRAG_ELEMENT_PARTS.TOP_RIGHT_BTN) {
+      // 按住了拖拽元素右上角拖拽手柄
+      this.handleDragMousedown(e, CORNERS.TOP_RIGHT)
+    } else if (resizeType === DRAG_ELEMENT_PARTS.BOTTOM_RIGHT_BTN) {
+      // 按住了拖拽元素右下角拖拽手柄
+      this.handleDragMousedown(e, CORNERS.BOTTOM_RIGHT)
+    } else if (resizeType === DRAG_ELEMENT_PARTS.BOTTOM_LEFT_BTN) {
+      // 按住了拖拽元素左下角拖拽手柄
+      this.handleDragMousedown(e, CORNERS.BOTTOM_LEFT)
+    }
+  }
+
+  // 结束调整元素操作
+  endResize() {
+    this.resizeType = ''
+    this.diagonalPoint = {
+      x: 0,
+      y: 0
+    }
+    this.mousedownPosAndElementPosOffset = {
+      x: 0,
+      y: 0
+    }
+    this.elementRatio = 0
+  }
+
+  // 处理按下拖拽元素四个伸缩手柄事件
+  handleDragMousedown(e, corner) {
+    let centerPos = getElementCenterPoint(this.element)
+    let pos = getElementRotatedCornerPoint(this.element, corner)
+    // 对角点的坐标
+    this.diagonalPoint.x = 2 * centerPos.x - pos.x
+    this.diagonalPoint.y = 2 * centerPos.y - pos.y
+    // 鼠标按下位置和元素的左上角坐标差值
+    this.mousedownPosAndElementPosOffset.x = e.clientX - pos.x
+    this.mousedownPosAndElementPosOffset.y = e.clientY - pos.y
+    this.element.saveState()
+  }
+
+  // 调整元素
+  handleResizeElement(e, mx, my, offsetX, offsetY) {
+    let resizeType = this.resizeType
+    // 按住了拖拽元素内部
+    if (resizeType === DRAG_ELEMENT_PARTS.BODY) {
+      this.handleMoveElement(offsetX, offsetY)
+    } else if (resizeType === DRAG_ELEMENT_PARTS.ROTATE) {
+      // 按住了拖拽元素的旋转按钮
+      this.handleRotateElement(e, mx, my)
+    } else if (resizeType === DRAG_ELEMENT_PARTS.TOP_LEFT_BTN) {
+      // 按住左上角伸缩元素
+      this.handleStretchElement(
+        e,
+        (newCenter, rp) => {
+          return {
+            width: (newCenter.x - rp.x) * 2,
+            height: (newCenter.y - rp.y) * 2
+          }
+        },
+        rp => {
+          return {
+            x: rp.x,
+            y: rp.y
+          }
+        },
+        (newRatio, newRect) => {
+          let x = newRect.x
+          let y = newRect.y
+          if (newRatio > this.elementRatio) {
+            x = newRect.x + newRect.width - this.elementRatio * newRect.height
+          } else if (newRatio < this.elementRatio) {
+            y = newRect.y + (newRect.height - newRect.width / this.elementRatio)
+          }
+          return {
+            x,
+            y
+          }
+        }
+      )
+    } else if (resizeType === DRAG_ELEMENT_PARTS.TOP_RIGHT_BTN) {
+      // 按住右上角伸缩元素
+      this.handleStretchElement(
+        e,
+        (newCenter, rp) => {
+          return {
+            width: (rp.x - newCenter.x) * 2,
+            height: (newCenter.y - rp.y) * 2
+          }
+        },
+        (rp, newSize) => {
+          return {
+            x: rp.x - newSize.width,
+            y: rp.y
+          }
+        },
+        (newRatio, newRect) => {
+          let x = newRect.x
+          let y = newRect.y
+          if (newRatio > this.elementRatio) {
+            x = newRect.x + this.elementRatio * newRect.height
+          } else if (newRatio < this.elementRatio) {
+            x = newRect.x + newRect.width
+            y = newRect.y + (newRect.height - newRect.width / this.elementRatio)
+          }
+          return {
+            x,
+            y
+          }
+        }
+      )
+    } else if (resizeType === DRAG_ELEMENT_PARTS.BOTTOM_RIGHT_BTN) {
+      // 按住右下角伸缩元素
+      this.handleStretchElement(
+        e,
+        (newCenter, rp) => {
+          return {
+            width: (rp.x - newCenter.x) * 2,
+            height: (rp.y - newCenter.y) * 2
+          }
+        },
+        (rp, newSize) => {
+          return {
+            x: rp.x - newSize.width,
+            y: rp.y - newSize.height
+          }
+        },
+        (newRatio, newRect) => {
+          let x = newRect.x
+          let y = newRect.y
+          if (newRatio > this.elementRatio) {
+            x = newRect.x + this.elementRatio * newRect.height
+            y = newRect.y + newRect.height
+          } else if (newRatio < this.elementRatio) {
+            x = newRect.x + newRect.width
+            y = newRect.y + newRect.width / this.elementRatio
+          }
+          return {
+            x,
+            y
+          }
+        }
+      )
+    } else if (resizeType === DRAG_ELEMENT_PARTS.BOTTOM_LEFT_BTN) {
+      // 按住左下角伸缩元素
+      this.handleStretchElement(
+        e,
+        (newCenter, rp) => {
+          return {
+            width: (newCenter.x - rp.x) * 2,
+            height: (rp.y - newCenter.y) * 2
+          }
+        },
+        (rp, newSize) => {
+          return {
+            x: rp.x,
+            y: rp.y - newSize.height
+          }
+        },
+        (newRatio, newRect) => {
+          let x = newRect.x
+          let y = newRect.y
+          if (newRatio > this.elementRatio) {
+            x = newRect.x + newRect.width - this.elementRatio * newRect.height
+            y = newRect.y + newRect.height
+          } else if (newRatio < this.elementRatio) {
+            y = newRect.y + newRect.width / this.elementRatio
+          }
+          return {
+            x,
+            y
+          }
+        }
+      )
+    }
+  }
+
+  // 移动元素整体
+  handleMoveElement(offsetX, offsetY) {
+    this.element.move(offsetX, offsetY)
+  }
+
+  // 旋转元素
+  handleRotateElement(e, mx, my) {
+    // 获取元素中心点
+    let centerPos = getElementCenterPoint(this.element)
+    // 获取鼠标移动的角度
+    let rotate = getTowPointRotate(
+      centerPos.x,
+      centerPos.y,
+      e.clientX,
+      e.clientY,
+      mx,
+      my
+    )
+    this.element.offsetRotate(rotate)
+  }
+
+  // 伸缩计算
+  stretchCalc(x, y, calcSize, calcPos) {
+    // 新的中心点
+    let newCenter = {
+      x: (x + this.diagonalPoint.x) / 2,
+      y: (y + this.diagonalPoint.y) / 2
+    }
+    // 获取当前鼠标位置经新的中心点反向旋转元素的角度后的坐标
+    let rp = transformPointReverseRotate(
+      x,
+      y,
+      newCenter.x,
+      newCenter.y,
+      this.element.rotate
+    )
+    // 计算新尺寸
+    let newSize = calcSize(newCenter, rp)
+    // 判断是否翻转了,不允许翻转
+    let isWidthReverse = false
+    if (newSize.width < 0) {
+      newSize.width = 0
+      isWidthReverse = true
+    }
+    let isHeightReverse = false
+    if (newSize.height < 0) {
+      newSize.height = 0
+      isHeightReverse = true
+    }
+    // 计算新位置
+    let newPos = calcPos(rp, newSize)
+    let newRect = {
+      x: newPos.x,
+      y: newPos.y,
+      width: newSize.width,
+      height: newSize.height
+    }
+    // 如果翻转了,那么位置保持为上一次的位置
+    if (isWidthReverse || isHeightReverse) {
+      newRect.x = this.element.x
+      newRect.y = this.element.y
+    }
+    return {
+      newRect,
+      newCenter
+    }
+  }
+
+  // 伸缩元素
+  handleStretchElement(e, calcSize, calcPos, fixPos) {
+    let actClientX = e.clientX - this.mousedownPosAndElementPosOffset.x
+    let actClientY = e.clientY - this.mousedownPosAndElementPosOffset.y
+    let { newRect, newCenter } = this.stretchCalc(
+      actClientX,
+      actClientY,
+      calcSize,
+      calcPos
+    )
+    // 修正新图形
+    if (this.opts.lockRatio) {
+      this.fixStretch(newRect, newCenter, calcSize, calcPos, fixPos)
+      return
+    }
+    // 更新尺寸位置信息
+    this.element.updateRect(newRect.x, newRect.y, newRect.width, newRect.height)
+  }
+
+  // 锁定长宽比时修正新图形
+  fixStretch(newRect, newCenter, calcSize, calcPos, fixPos) {
+    let newRatio = newRect.width / newRect.height
+    let fp = fixPos(newRatio, newRect)
+    // 修正的点旋转图形的角度
+    let rp = getRotatedPoint(
+      fp.x,
+      fp.y,
+      newCenter.x,
+      newCenter.y,
+      this.element.rotate
+    )
+    let fixNewRect = this.stretchCalc(rp.x, rp.y, calcSize, calcPos).newRect
+    // 不知道为什么刚拖动时会有宽高计算为0的情况
+    if (fixNewRect.width === 0 && fixNewRect.height === 0) {
+      return
+    }
+    // 更新尺寸位置信息
+    this.element.updateRect(
+      fixNewRect.x,
+      fixNewRect.y,
+      fixNewRect.width,
+      fixNewRect.height
+    )
+  }
+}

+ 45 - 0
tiny-whiteboard/src/elements/Freedraw.js

@@ -0,0 +1,45 @@
+import BaseMultiPointElement from './BaseMultiPointElement'
+import { drawLineSegment, drawFreeLine } from '../utils/draw'
+import DragElement from './DragElement'
+import { transformPointOnElement, deepCopy, getBoundingRect } from '../utils'
+import { checkIsAtFreedrawLineEdge } from '../utils/checkHit'
+
+// 自由画笔元素类
+export default class Freedraw extends BaseMultiPointElement {
+  constructor(...args) {
+    super(...args)
+    // 拖拽元素实例
+    this.dragElement = new DragElement(this, this.app)
+    // 点位[x,y,speed]第三个数字为线宽
+    // 上一次的线宽
+    this.lastLineWidth = -1
+  }
+
+  // 渲染到画布
+  render() {
+    let { pointArr } = this
+    this.warpRender(({ cx, cy }) => {
+      drawFreeLine(this.app.ctx, pointArr, {
+        app: this.app,
+        cx,
+        cy
+      })
+    })
+    // 激活时显示拖拽框
+    this.renderDragElement()
+  }
+
+  // 检测是否被击中
+  isHit(x, y) {
+    let rp = transformPointOnElement(x, y, this)
+    return checkIsAtFreedrawLineEdge(this, rp)
+  }
+
+  // 绘制单条线段
+  singleRender(mx, my, tx, ty, lineWidth) {
+    this.app.ctx.save()
+    this.setStyle(this.style)
+    drawLineSegment(this.app.ctx, mx, my, tx, ty, lineWidth)
+    this.app.ctx.restore()
+  }
+}

+ 45 - 0
tiny-whiteboard/src/elements/Image.js

@@ -0,0 +1,45 @@
+import BaseElement from './BaseElement'
+import { drawImage } from '../utils/draw'
+import DragElement from './DragElement'
+import { transformPointOnElement } from '../utils'
+import { checkIsAtRectangleInner } from '../utils/checkHit'
+
+// 图片元素类
+export default class Image extends BaseElement {
+  constructor(opts = {}, app) {
+    super(opts, app)
+    // 拖拽元素实例
+    this.dragElement = new DragElement(this, this.app, {
+      lockRatio: true
+    })
+    this.url = opts.url || ''
+    this.imageObj = opts.imageObj || null
+    this.ratio = opts.ratio || 1
+  }
+
+  // 序列化
+  serialize() {
+    let base = super.serialize()
+    return {
+      ...base,
+      url: this.url,
+      ratio: this.ratio
+    }
+  }
+
+  // 渲染到画布
+  render() {
+    let { width, height } = this
+    this.warpRender(({ halfWidth, halfHeight }) => {
+      drawImage(this.app.ctx, this, -halfWidth, -halfHeight, width, height)
+    })
+    // 激活时显示拖拽框
+    this.renderDragElement()
+  }
+
+  // 检测是否被击中
+  isHit(x, y) {
+    let rp = transformPointOnElement(x, y, this)
+    return checkIsAtRectangleInner(this, rp)
+  }
+}

+ 50 - 0
tiny-whiteboard/src/elements/Line.js

@@ -0,0 +1,50 @@
+import BaseMultiPointElement from './BaseMultiPointElement'
+import { drawLine } from '../utils/draw'
+import DragElement from './DragElement'
+import { transformPointOnElement } from '../utils'
+import { checkIsAtLineEdge } from '../utils/checkHit'
+
+// 线段/折线元素类
+export default class Line extends BaseMultiPointElement {
+  constructor(opts = {}, app) {
+    super(opts, app)
+    // 拖拽元素实例
+    this.dragElement = new DragElement(this, this.app)
+    // 是否是单线段,否则为多根线段组成的折线
+    this.isSingle = opts.isSingle
+  }
+
+  // 渲染到画布
+  render() {
+    let { pointArr, fictitiousPoint } = this
+    this.warpRender(({ cx, cy }) => {
+      // 加上鼠标当前实时位置
+      let realtimePoint = []
+      if (pointArr.length > 0 && this.isCreating) {
+        let { x: fx, y: fy } = this.app.coordinate.transform(
+          fictitiousPoint.x - cx,
+          fictitiousPoint.y - cy
+        )
+        realtimePoint = [[fx, fy]]
+      }
+      drawLine(
+        this.app.ctx,
+        pointArr
+          .map(point => {
+            // 屏幕坐标在左上角,画布坐标在中心,所以屏幕坐标要先转成画布坐标
+            let { x, y } = this.app.coordinate.transform(point[0], point[1])
+            return [x - cx, y - cy]
+          })
+          .concat(realtimePoint)
+      )
+    })
+    // 激活时显示拖拽框
+    this.renderDragElement()
+  }
+
+  // 检测是否被击中
+  isHit(x, y) {
+    let rp = transformPointOnElement(x, y, this)
+    return checkIsAtLineEdge(this, rp)
+  }
+}

+ 109 - 0
tiny-whiteboard/src/elements/MultiSelectElement.js

@@ -0,0 +1,109 @@
+import BaseElement from './BaseElement'
+import DragElement from './DragElement'
+import {
+  getMultiElementRectInfo,
+  getElementCenterPoint,
+  getTowPointRotate
+} from '../utils'
+
+// 用于多选情况下的虚拟元素类
+export default class MultiSelectElement extends BaseElement {
+  constructor(opts = {}, app) {
+    super(opts, app)
+    // 拖拽元素实例
+    this.dragElement = new DragElement(this, this.app)
+    // 被选中的元素集合
+    this.selectedElementList = []
+    // 被选中元素整体的中心点
+    this.wholeCenterPos = { x: 0, y: 0 }
+  }
+
+  // 设置选中元素
+  setSelectedElementList(list) {
+    this.selectedElementList.forEach(element => {
+      element.isSelected = false
+    })
+    this.selectedElementList = list
+    this.selectedElementList.forEach(element => {
+      element.isSelected = true
+    })
+  }
+
+  // 过滤掉被删除的元素
+  updateElements(elements) {
+    let exists = []
+    this.selectedElementList.forEach(element => {
+      if (elements.includes(element)) {
+        exists.push(element)
+      }
+    })
+    this.setSelectedElementList(exists)
+  }
+
+  // 计算大小和位置
+  updateRect() {
+    if (this.selectedElementList.length <= 0) {
+      super.updateRect(0, 0, 0, 0)
+      return
+    }
+    let { minx, maxx, miny, maxy } = getMultiElementRectInfo(
+      this.selectedElementList
+    )
+    super.updateRect(minx, miny, maxx - minx, maxy - miny)
+  }
+
+  // 开始调整
+  startResize(...args) {
+    this.selectedElementList.forEach(element => {
+      if (args[0] === 'rotate') {
+        // 计算多选元素整体中心点
+        this.wholeCenterPos = getElementCenterPoint(this)
+      }
+      element.startResize(...args)
+    })
+  }
+
+  // 调整中
+  resize(...args) {
+    this.selectedElementList.forEach(element => {
+      if (element.dragElement.resizeType === 'rotate') {
+        // 旋转操作特殊处理
+        this.handleRotate(element, ...args)
+      } else {
+        element.resize(...args)
+      }
+    })
+  }
+
+  // 旋转元素
+  handleRotate(element, e, mx, my, offsetX, offsetY) {
+    // 获取鼠标移动的角度
+    let rotate = getTowPointRotate(
+      this.wholeCenterPos.x,
+      this.wholeCenterPos.y,
+      e.clientX,
+      e.clientY,
+      mx,
+      my
+    )
+    element.rotateByCenter(rotate, this.wholeCenterPos.x, this.wholeCenterPos.y)
+  }
+
+  // 结束调整
+  endResize() {
+    this.selectedElementList.forEach(element => {
+      element.endResize()
+    })
+  }
+
+  // 渲染到画布
+  render() {
+    // 显示拖拽框
+    if (this.selectedElementList.length > 0) {
+      if (this.width <= 0 || this.height <= 0) {
+        return
+      }
+      this.dragElement.render()
+    }
+  }
+}

+ 31 - 0
tiny-whiteboard/src/elements/Rectangle.js

@@ -0,0 +1,31 @@
+import BaseElement from './BaseElement'
+import { drawRect } from '../utils/draw'
+import DragElement from './DragElement'
+import { transformPointOnElement } from '../utils'
+import { checkIsAtRectangleEdge } from '../utils/checkHit'
+
+// 矩形元素类
+export default class Rectangle extends BaseElement {
+  constructor(...args) {
+    super(...args)
+    // 拖拽元素实例
+    this.dragElement = new DragElement(this, this.app)
+  }
+
+  // 渲染到画布
+  render() {
+    let { width, height } = this
+    this.warpRender(({ halfWidth, halfHeight }) => {
+      // 画布中心点修改了,所以元素的坐标也要相应修改
+      drawRect(this.app.ctx, -halfWidth, -halfHeight, width, height, true)
+    })
+    // 激活时显示拖拽框
+    this.renderDragElement()
+  }
+
+  // 检测是否被击中
+  isHit(x, y) {
+    let rp = transformPointOnElement(x, y, this)
+    return checkIsAtRectangleEdge(this, rp)
+  }
+}

+ 73 - 0
tiny-whiteboard/src/elements/Text.js

@@ -0,0 +1,73 @@
+import BaseElement from './BaseElement'
+import { drawText } from '../utils/draw'
+import DragElement from './DragElement'
+import {
+  transformPointOnElement,
+  splitTextLines,
+  getTextElementSize
+} from '../utils'
+import { checkIsAtRectangleInner } from '../utils/checkHit'
+
+// 文本元素类
+export default class Text extends BaseElement {
+  constructor(opts = {}, app) {
+    super(opts, app)
+    // 拖拽元素实例
+    this.dragElement = new DragElement(this, this.app, {
+      lockRatio: true
+    })
+    this.text = opts.text || ''
+    this.style.fillStyle =
+      opts.style?.fillStyle || this.app.state.strokeStyle || '#000'
+    this.style.fontSize = opts.style?.fontSize || this.app.state.fontSize || 18
+    this.style.lineHeightRatio = opts.style?.lineHeightRatio || 1.5
+    this.style.fontFamily =
+      opts.style?.fontFamily ||
+      this.app.state.fontFamily ||
+      '微软雅黑, Microsoft YaHei'
+  }
+
+  // 序列化
+  serialize() {
+    let base = super.serialize()
+    return {
+      ...base,
+      text: this.text
+    }
+  }
+
+  // 渲染到画布
+  render() {
+    let { width, height } = this
+    this.warpRender(({ halfWidth, halfHeight }) => {
+      // 画布中心点修改了,所以元素的坐标也要相应修改
+      drawText(this.app.ctx, this, -halfWidth, -halfHeight, width, height)
+    })
+    // 激活时显示拖拽框
+    this.renderDragElement()
+  }
+
+  // 检测是否被击中
+  isHit(x, y) {
+    let rp = transformPointOnElement(x, y, this)
+    return checkIsAtRectangleInner(this, rp)
+  }
+
+  // 更新包围框
+  updateRect(x, y, width, height) {
+    let { text, style } = this
+    // 新字号 = 新高度 / 行数
+    let fontSize = Math.floor(
+      height / splitTextLines(text).length / style.lineHeightRatio
+    )
+    this.style.fontSize = fontSize
+    super.updateRect(x, y, width, height)
+  }
+
+  // 字号改不了更新尺寸
+  updateTextSize() {
+    let { width, height } = getTextElementSize(this)
+    this.width = width
+    this.height = height
+  }
+}

+ 49 - 0
tiny-whiteboard/src/elements/Triangle.js

@@ -0,0 +1,49 @@
+import BaseElement from './BaseElement'
+import { drawTriangle } from '../utils/draw'
+import DragElement from './DragElement'
+import {
+  transformPointOnElement,
+  getElementCenterPoint,
+  getRotatedPoint
+} from '../utils'
+import { checkIsAtTriangleEdge } from '../utils/checkHit'
+
+// 三角形元素类
+export default class Triangle extends BaseElement {
+  constructor(...args) {
+    super(...args)
+    // 拖拽元素实例
+    this.dragElement = new DragElement(this, this.app)
+  }
+
+  // 渲染到画布
+  render() {
+    let { width, height } = this
+    this.warpRender(({ halfWidth, halfHeight }) => {
+      // 画布中心点修改了,所以元素的坐标也要相应修改
+      drawTriangle(this.app.ctx, -halfWidth, -halfHeight, width, height, true)
+    })
+    // 激活时显示拖拽框
+    this.renderDragElement()
+  }
+
+  // 检测是否被击中
+  isHit(x, y) {
+    let rp = transformPointOnElement(x, y, this)
+    return checkIsAtTriangleEdge(this, rp)
+  }
+
+  // 获取图形应用了旋转之后的端点列表
+  getEndpointList() {
+    let { x, y, width, height, rotate } = this
+    let points = [
+      [x + width / 2, y],
+      [x + width, y + height],
+      [x, y + height]
+    ]
+    let center = getElementCenterPoint(this)
+    return points.map(point => {
+      return getRotatedPoint(point[0], point[1], center.x, center.y, rotate)
+    })
+  }
+}

+ 29 - 0
tiny-whiteboard/src/elements/index.js

@@ -0,0 +1,29 @@
+import Arrow from './Arrow'
+import BaseElement from './BaseElement'
+import BaseMultiPointElement from './BaseMultiPointElement'
+import Circle from './Circle'
+import Diamond from './Diamond'
+import DragElement from './DragElement'
+import Freedraw from './Freedraw'
+import Image from './Image'
+import Line from './Line'
+import MultiSelectElement from './MultiSelectElement'
+import Rectangle from './Rectangle'
+import Text from './Text'
+import Triangle from './Triangle'
+
+export default {
+  Arrow,
+  BaseElement,
+  BaseMultiPointElement,
+  Circle,
+  Diamond,
+  DragElement,
+  Freedraw,
+  Image,
+  Line,
+  MultiSelectElement,
+  Rectangle,
+  Text,
+  Triangle
+}

+ 631 - 0
tiny-whiteboard/src/index.js

@@ -0,0 +1,631 @@
+import EventEmitter from 'eventemitter3'
+import {
+  createCanvas,
+  getTowPointDistance,
+  throttle,
+  createImageObj
+} from './utils'
+import * as utils from './utils'
+import * as checkHit from './utils/checkHit'
+import * as draw from './utils/draw'
+import Coordinate from './Coordinate'
+import Event from './Event'
+import Elements from './Elements'
+import ImageEdit from './ImageEdit'
+import Cursor from './Cursor'
+import TextEdit from './TextEdit'
+import History from './History'
+import Export from './Export'
+import Background from './Background'
+import Selection from './Selection'
+import Grid from './Grid'
+import Mode from './Mode'
+import KeyCommand from './KeyCommand'
+import Render from './Render'
+import elements from './elements'
+import Group from './Group'
+
+// 主类
+class TinyWhiteboard extends EventEmitter {
+  constructor(opts = {}) {
+    super()
+    // 参数
+    this.opts = opts
+    // 容器元素
+    this.container = opts.container
+    // 当前绘制类型
+    this.drawType = opts.drawType || 'selection'
+    // 对容器做一些必要检查
+    if (!this.container) {
+      throw new Error('缺少 container 参数!')
+    }
+    if (
+      !['absolute', 'fixed', 'relative'].includes(
+        window.getComputedStyle(this.container).position
+      )
+    ) {
+      throw new Error('container元素需要设置定位!')
+    }
+    // 容器宽高位置信息
+    this.width = 0
+    this.height = 0
+    this.left = 0
+    this.top = 0
+    // 主要的渲染canvas元素
+    this.canvas = null
+    // canvas绘制上下文
+    this.ctx = null
+    // 画布状态
+    this.state = {
+      scale: 1, // 缩放
+      scrollX: 0, // 水平方向的滚动偏移量
+      scrollY: 0, // 垂直方向的滚动偏移量
+      scrollStep: 50, // 滚动步长
+      backgroundColor: '', // 背景颜色
+      strokeStyle: '#000000', // 默认线条颜色
+      fillStyle: 'transparent', // 默认填充颜色
+      fontFamily: '微软雅黑, Microsoft YaHei', // 默认文字字体
+      fontSize: 18, // 默认文字字号
+      dragStrokeStyle: '#666', // 选中元素的拖拽元素的默认线条颜色
+      showGrid: false, // 是否显示网格
+      readonly: false, // 是否是只读模式
+      gridConfig: {
+        size: 20, // 网格大小
+        strokeStyle: '#dfe0e1', // 网格线条颜色
+        lineWidth: 1 // 网格线条宽度
+      },
+      ...(opts.state || {})
+    }
+
+    // 初始化画布
+    this.initCanvas()
+    // 坐标转换类
+    this.coordinate = new Coordinate(this)
+    // 事件类
+    this.event = new Event(this)
+    this.event.on('mousedown', this.onMousedown, this)
+    this.event.on('mousemove', this.onMousemove, this)
+    this.event.on('mouseup', this.onMouseup, this)
+    this.event.on('dblclick', this.onDblclick, this)
+    this.event.on('mousewheel', this.onMousewheel, this)
+    this.event.on('contextmenu', this.onContextmenu, this)
+    // 快捷键类
+    this.keyCommand = new KeyCommand(this)
+    // 图片选择类
+    this.imageEdit = new ImageEdit(this)
+    this.imageEdit.on('imageSelectChange', this.onImageSelectChange, this)
+    // 文字编辑类
+    this.textEdit = new TextEdit(this)
+    this.textEdit.on('blur', this.onTextInputBlur, this)
+    // 鼠标样式类
+    this.cursor = new Cursor(this)
+    // 历史记录管理类
+    this.history = new History(this)
+    // 导入导出类
+    this.export = new Export(this)
+    // 背景设置类
+    this.background = new Background(this)
+    // 多选类
+    this.selection = new Selection(this)
+    // 编组类
+    this.group = new Group(this)
+    // 网格类
+    this.grid = new Grid(this)
+    // 模式类
+    this.mode = new Mode(this)
+    // 元素管理类
+    this.elements = new Elements(this)
+    // 渲染类
+    this.render = new Render(this)
+
+    // 代理
+    this.proxy()
+    this.checkIsOnElement = throttle(this.checkIsOnElement, this)
+
+    this.emitChange()
+    this.helpUpdate()
+  }
+
+  // 代理各个类的方法到实例上
+  proxy() {
+    // history类
+    ;['undo', 'redo'].forEach(method => {
+      this[method] = this.history[method].bind(this.history)
+    })
+    // elements类
+    ;[].forEach(method => {
+      this[method] = this.elements[method].bind(this.elements)
+    })
+    // 渲染类
+    ;[
+      'deleteElement',
+      'setActiveElementStyle',
+      'setCurrentElementsStyle',
+      'cancelActiveElement',
+      'deleteActiveElement',
+      'deleteCurrentElements',
+      'empty',
+      'zoomIn',
+      'zoomOut',
+      'setZoom',
+      'scrollTo',
+      'scrollToCenter',
+      'copyPasteCurrentElements',
+      'setBackgroundColor',
+      'copyElement',
+      'copyCurrentElement',
+      'cutCurrentElement',
+      'pasteCurrentElement',
+      'updateActiveElementRotate',
+      'updateActiveElementSize',
+      'updateActiveElementPosition',
+      'moveBottomCurrentElement',
+      'moveTopCurrentElement',
+      'moveUpCurrentElement',
+      'moveDownCurrentElement',
+      'selectAll',
+      'fit'
+    ].forEach(method => {
+      this[method] = this.render[method].bind(this.render)
+    })
+    // 导入导出类
+    ;['exportImage', 'exportJson'].forEach(method => {
+      this[method] = this.export[method].bind(this.export)
+    })
+    // 多选类
+    ;['setSelectedElementStyle'].forEach(method => {
+      this[method] = this.selection[method].bind(this.selection)
+    })
+    // 编组类
+    ;['dogroup', 'ungroup'].forEach(method => {
+      this[method] = this.group[method].bind(this.group)
+    })
+    // 网格类
+    ;['showGrid', 'hideGrid', 'updateGrid'].forEach(method => {
+      this[method] = this.grid[method].bind(this.grid)
+    })
+    // 模式类
+    ;['setEditMode', 'setReadonlyMode'].forEach(method => {
+      this[method] = this.mode[method].bind(this.mode)
+    })
+  }
+
+  // 获取容器尺寸位置信息
+  getContainerRectInfo() {
+    let { width, height, left, top } = this.container.getBoundingClientRect()
+    this.width = width
+    this.height = height
+    this.left = left
+    this.top = top
+  }
+
+  // 必要的重新渲染
+  helpUpdate() {
+    // 设置背景
+    this.background.set()
+    // 设置网格
+    if (this.state.showGrid) {
+      this.grid.showGrid()
+    }
+    // 设置模式
+    if (this.state.readonly) {
+      this.setReadonlyMode()
+    }
+  }
+
+  // 设置数据,包括状态数据及元素数据
+  async setData({ state = {}, elements = [] }, noEmitChange) {
+    this.state = state
+    // 图片需要预加载
+    for (let i = 0; i < elements.length; i++) {
+      if (elements[i].type === 'image') {
+        elements[i].imageObj = await createImageObj(elements[i].url)
+      }
+    }
+    this.helpUpdate()
+    this.elements.deleteAllElements().createElementsFromData(elements)
+    this.render.render()
+    if (!noEmitChange) {
+      this.emitChange()
+    }
+  }
+
+  // 初始化画布
+  initCanvas() {
+    this.getContainerRectInfo()
+    // 删除旧的canvas元素
+    if (this.canvas) {
+      this.container.removeChild(this.canvas)
+    }
+    // 创建canvas元素
+    let { canvas, ctx } = createCanvas(this.width, this.height, {
+      className: 'main'
+    })
+    this.canvas = canvas
+    this.ctx = ctx
+    this.container.appendChild(this.canvas)
+  }
+
+  // 容器尺寸调整
+  resize() {
+    // 初始化canvas元素
+    this.initCanvas()
+    // 在新的画布上绘制元素
+    this.render.render()
+    // 多选画布重新初始化
+    this.selection.init()
+    // 网格画布重新初始化
+    this.grid.init()
+    // 重新判断是否渲染网格
+    this.grid.renderGrid()
+  }
+
+  // 更新状态数据,只是更新状态数据,不会触发重新渲染,如有需要重新渲染或其他操作需要自行调用相关方法
+  updateState(data = {}) {
+    this.state = {
+      ...this.state,
+      ...data
+    }
+    this.emitChange()
+  }
+
+  // 更新当前绘制类型
+  updateCurrentType(drawType) {
+    this.drawType = drawType
+    // 图形绘制类型
+    if (this.drawType === 'image') {
+      this.imageEdit.selectImage()
+    }
+    // 设置鼠标指针样式
+    // 开启橡皮擦模式
+    if (this.drawType === 'eraser') {
+      this.cursor.setEraser()
+      this.cancelActiveElement()
+    } else if (this.drawType !== 'selection') {
+      this.cursor.setCrosshair()
+    } else {
+      this.cursor.reset()
+    }
+    this.emit('currentTypeChange', this.drawType)
+  }
+
+  // 获取数据,包括状态数据及元素数据
+  getData() {
+    return {
+      state: {
+        ...this.state
+      },
+      elements: this.elements.serialize()
+    }
+  }
+
+  // 图片选择事件
+  onImageSelectChange() {
+    this.cursor.hide()
+  }
+
+  // 鼠标按下事件
+  onMousedown(e, event) {
+    if (this.state.readonly || this.mode.isDragMode) {
+      // 只读模式下即将进行整体拖动
+      this.mode.onStart()
+      return
+    }
+    if (!this.elements.isCreatingElement && !this.textEdit.isEditing) {
+      // 是否击中了某个元素
+      let hitElement = this.elements.checkIsHitElement(e)
+      if (this.drawType === 'selection') {
+        // 当前是选择模式
+        // 当前存在激活元素
+        if (this.elements.hasActiveElement()) {
+          // 判断按下的位置是否是拖拽部位
+          let isResizing = this.elements.checkIsResize(
+            event.mousedownPos.unGridClientX,
+            event.mousedownPos.unGridClientY,
+            e
+          )
+          // 不在拖拽部位则将当前的激活元素替换成hitElement
+          if (!isResizing) {
+            this.elements.setActiveElement(hitElement)
+            this.render.render()
+          }
+        } else {
+          // 当前没有激活元素
+          if (this.selection.hasSelection) {
+            // 当前存在多选元素,则判断按下的位置是否是多选元素的拖拽部位
+            let isResizing = this.selection.checkIsResize(
+              event.mousedownPos.unGridClientX,
+              event.mousedownPos.unGridClientY,
+              e
+            )
+            // 不在拖拽部位则复位多选,并将当前的激活元素替换成hitElement
+            if (!isResizing) {
+              this.selection.reset()
+              this.elements.setActiveElement(hitElement)
+              this.render.render()
+            }
+          } else if (hitElement) {
+            // 激活击中的元素
+            if (hitElement.hasGroup()) {
+              this.group.setSelection(hitElement)
+              this.onMousedown(e, event)
+            } else {
+              this.elements.setActiveElement(hitElement)
+              this.render.render()
+              this.onMousedown(e, event)
+            }
+          } else {
+            // 上述条件都不符合则进行多选创建选区操作
+            this.selection.onMousedown(e, event)
+          }
+        }
+      } else if (this.drawType === 'eraser') {
+        // 当前有击中元素
+        // 橡皮擦模式则删除该元素
+        this.deleteElement(hitElement)
+      }
+    }
+  }
+
+  // 鼠标移动事件
+  onMousemove(e, event) {
+    if (this.state.readonly || this.mode.isDragMode) {
+      if (event.isMousedown) {
+        // 只读模式下进行整体拖动
+        this.mode.onMove(e, event)
+      }
+      return
+    }
+    // 鼠标按下状态
+    if (event.isMousedown) {
+      let mx = event.mousedownPos.x
+      let my = event.mousedownPos.y
+      let offsetX = Math.max(event.mouseOffset.x, 0)
+      let offsetY = Math.max(event.mouseOffset.y, 0)
+      // 选中模式
+      if (this.drawType === 'selection') {
+        if (this.selection.isResizing) {
+          // 多选调整元素中
+          this.selection.handleResize(
+            e,
+            mx,
+            my,
+            event.mouseOffset.x,
+            event.mouseOffset.y
+          )
+        } else if (this.selection.creatingSelection) {
+          // 多选创建选区中
+          this.selection.onMousemove(e, event)
+        } else {
+          // 检测是否是正常的激活元素的调整操作
+          this.elements.handleResize(
+            e,
+            mx,
+            my,
+            event.mouseOffset.x,
+            event.mouseOffset.y
+          )
+        }
+      } else if (['rectangle', 'diamond', 'triangle'].includes(this.drawType)) {
+        // 类矩形元素绘制模式
+        this.elements.creatingRectangleLikeElement(
+          this.drawType,
+          mx,
+          my,
+          offsetX,
+          offsetY
+        )
+        this.render.render()
+      } else if (this.drawType === 'circle') {
+        // 绘制圆形模式
+        this.elements.creatingCircle(mx, my, e)
+        this.render.render()
+      } else if (this.drawType === 'freedraw') {
+        // 自由画笔模式
+        this.elements.creatingFreedraw(e, event)
+      } else if (this.drawType === 'arrow') {
+        this.elements.creatingArrow(mx, my, e)
+        this.render.render()
+      } else if (this.drawType === 'line') {
+        if (getTowPointDistance(mx, my, e.clientX, e.clientY) > 3) {
+          this.elements.creatingLine(mx, my, e, true)
+          this.render.render()
+        }
+      }
+    } else {
+      // 鼠标没有按下状态
+      // 图片放置中
+      if (this.imageEdit.isReady) {
+        this.cursor.hide()
+        this.imageEdit.updatePreviewElPos(
+          e.originEvent.clientX,
+          e.originEvent.clientY
+        )
+      } else if (this.drawType === 'selection') {
+        if (this.elements.hasActiveElement()) {
+          // 当前存在激活元素
+          // 检测是否划过激活元素的各个收缩手柄
+          let handData = ''
+          if (
+            (handData = this.elements.checkInResizeHand(
+              e.unGridClientX,
+              e.unGridClientY
+            ))
+          ) {
+            this.cursor.setResize(handData.hand)
+          } else {
+            this.checkIsOnElement(e)
+          }
+        } else if (this.selection.hasSelection) {
+          // 多选中检测是否可进行调整元素
+          let hand = this.selection.checkInResizeHand(
+            e.unGridClientX,
+            e.unGridClientY
+          )
+          if (hand) {
+            this.cursor.setResize(hand)
+          } else {
+            this.checkIsOnElement(e)
+          }
+        } else {
+          // 检测是否划过元素
+          this.checkIsOnElement(e)
+        }
+      } else if (this.drawType === 'line') {
+        // 线段绘制中
+        this.elements.creatingLine(null, null, e, false, true)
+        this.render.render()
+      }
+    }
+  }
+
+  // 检测是否滑过元素
+  checkIsOnElement(e) {
+    let hitElement = this.elements.checkIsHitElement(e)
+    if (hitElement) {
+      this.cursor.setMove()
+    } else {
+      this.cursor.reset()
+    }
+  }
+
+  // 复位当前类型到选择模式
+  resetCurrentType() {
+    if (this.drawType !== 'selection') {
+      this.drawType = 'selection'
+      this.emit('currentTypeChange', 'selection')
+    }
+  }
+
+  // 创建新元素完成
+  completeCreateNewElement() {
+    this.resetCurrentType()
+    this.elements.completeCreateElement()
+    this.render.render()
+  }
+
+  // 鼠标松开事件
+  onMouseup(e) {
+    if (this.state.readonly || this.mode.isDragMode) {
+      return
+    }
+    if (this.drawType === 'text') {
+      // 文字编辑模式
+      if (!this.textEdit.isEditing) {
+        this.createTextElement(e)
+        this.resetCurrentType()
+      }
+    } else if (this.imageEdit.isReady) {
+      // 图片放置模式
+      this.elements.creatingImage(e, this.imageEdit.imageData)
+      this.completeCreateNewElement()
+      this.cursor.reset()
+      this.imageEdit.reset()
+    } else if (this.drawType === 'arrow') {
+      // 箭头绘制模式
+      this.elements.completeCreateArrow(e)
+      this.completeCreateNewElement()
+    } else if (this.drawType === 'line') {
+      this.elements.completeCreateLine(e, () => {
+        this.completeCreateNewElement()
+      })
+      this.render.render()
+    } else if (this.elements.isCreatingElement) {
+      // 正在创建元素中
+      if (this.drawType === 'freedraw') {
+        // 自由绘画模式可以连续绘制
+        this.elements.completeCreateElement()
+        this.elements.setActiveElement()
+      } else {
+        // 创建新元素完成
+        this.completeCreateNewElement()
+      }
+    } else if (this.elements.isResizing) {
+      // 调整元素操作结束
+      this.elements.endResize()
+      this.emitChange()
+    } else if (this.selection.creatingSelection) {
+      // 多选选区操作结束
+      this.selection.onMouseup(e)
+    } else if (this.selection.isResizing) {
+      // 多选元素调整结束
+      this.selection.endResize()
+      this.emitChange()
+    }
+  }
+
+  // 双击事件
+  onDblclick(e) {
+    if (this.drawType === 'line') {
+      // 结束折线绘制
+      this.completeCreateNewElement()
+    } else {
+      // 是否击中了某个元素
+      let hitElement = this.elements.checkIsHitElement(e)
+      if (hitElement) {
+        // 编辑文字
+        if (hitElement.type === 'text') {
+          this.elements.editingText(hitElement)
+          this.render.render()
+          this.keyCommand.unBindEvent()
+          this.textEdit.showTextEdit()
+        }
+      } else {
+        // 双击空白处新增文字
+        if (!this.textEdit.isEditing) {
+          this.createTextElement(e)
+        }
+      }
+    }
+  }
+
+  // 文本框失焦事件
+  onTextInputBlur() {
+    this.keyCommand.bindEvent()
+    this.elements.completeEditingText()
+    this.render.render()
+    this.emitChange()
+  }
+
+  // 创建文本元素
+  createTextElement(e) {
+    this.elements.createElement({
+      type: 'text',
+      x: e.clientX,
+      y: e.clientY
+    })
+    this.keyCommand.unBindEvent()
+    this.textEdit.showTextEdit()
+  }
+
+  // 鼠标滚动事件
+  onMousewheel(dir) {
+    let stepNum = this.state.scrollStep / this.state.scale
+    let step = dir === 'down' ? stepNum : -stepNum
+    this.scrollTo(this.state.scrollX, this.state.scrollY + step)
+  }
+
+  // 右键菜单事件
+  onContextmenu(e) {
+    let elements = []
+    if (this.elements.hasActiveElement()) {
+      elements = [this.elements.activeElement]
+    } else if (this.selection.hasSelectionElements()) {
+      elements = this.selection.getSelectionElements()
+    }
+    this.emit('contextmenu', e.originEvent, elements)
+  }
+
+  // 触发更新事件
+  emitChange() {
+    let data = this.getData()
+    this.history.add(data)
+    this.emit('change', data)
+  }
+}
+TinyWhiteboard.utils = utils
+TinyWhiteboard.checkHit = checkHit
+TinyWhiteboard.draw = draw
+TinyWhiteboard.elements = elements
+
+export default TinyWhiteboard

+ 107 - 0
tiny-whiteboard/src/utils/checkHit.js

@@ -0,0 +1,107 @@
+import {
+  checkIsAtSegment,
+  getTowPointDistance,
+  checkPointIsInRectangle
+} from './'
+import { HIT_DISTANCE } from '../constants'
+
+// 检测是否点击到折线上
+export const checkIsAtMultiSegment = (segments, rp) => {
+  let res = false
+  segments.forEach(seg => {
+    if (res) return
+    if (checkIsAtSegment(rp.x, rp.y, ...seg, HIT_DISTANCE)) {
+      res = true
+    }
+  })
+  return res
+}
+
+// 检测是否点击到矩形边缘
+export const checkIsAtRectangleEdge = (element, rp) => {
+  let { x, y, width, height } = element
+  let segments = [
+    [x, y, x + width, y],
+    [x + width, y, x + width, y + height],
+    [x + width, y + height, x, y + height],
+    [x, y + height, x, y]
+  ]
+  return checkIsAtMultiSegment(segments, rp) ? element : null
+}
+
+// 检测是否点击到矩形内部
+export const checkIsAtRectangleInner = (element, rp) => {
+  return checkPointIsInRectangle(rp.x, rp.y, element) ? element : null
+}
+
+// 根据宽高计算圆的半径
+export const getCircleRadius = (width, height) => {
+  return Math.min(Math.abs(width), Math.abs(height)) / 2
+}
+
+// 检测是否点击到圆的边缘
+export const checkIsAtCircleEdge = (element, rp) => {
+  let { width, height, x, y } = element
+  let radius = getCircleRadius(width, height)
+  let dis = getTowPointDistance(rp.x, rp.y, x + radius, y + radius)
+  let onCircle = dis >= radius - HIT_DISTANCE && dis <= radius + HIT_DISTANCE
+  return onCircle ? element : null
+}
+
+// 检测是否点击到线段边缘
+export const checkIsAtLineEdge = (element, rp) => {
+  let segments = []
+  let len = element.pointArr.length
+  let arr = element.pointArr
+  for (let i = 0; i < len - 1; i++) {
+    segments.push([...arr[i], ...arr[i + 1]])
+  }
+  return checkIsAtMultiSegment(segments, rp) ? element : null
+}
+
+// 检测是否点击到自由线段边缘
+export const checkIsAtFreedrawLineEdge = (element, rp) => {
+  let res = null
+  element.pointArr.forEach(point => {
+    if (res) return
+    let dis = getTowPointDistance(rp.x, rp.y, point[0], point[1])
+    if (dis <= HIT_DISTANCE) {
+      res = element
+    }
+  })
+  return res
+}
+
+// 检测是否点击到菱形边缘
+export const checkIsAtDiamondEdge = (element, rp) => {
+  let { x, y, width, height } = element
+  let segments = [
+    [x + width / 2, y, x + width, y + height / 2],
+    [x + width, y + height / 2, x + width / 2, y + height],
+    [x + width / 2, y + height, x, y + height / 2],
+    [x, y + height / 2, x + width / 2, y]
+  ]
+  return checkIsAtMultiSegment(segments, rp) ? element : null
+}
+
+// 检测是否点击到三角形边缘
+export const checkIsAtTriangleEdge = (element, rp) => {
+  let { x, y, width, height } = element
+  let segments = [
+    [x + width / 2, y, x + width, y + height],
+    [x + width, y + height, x, y + height],
+    [x, y + height, x + width / 2, y]
+  ]
+  return checkIsAtMultiSegment(segments, rp) ? element : null
+}
+
+// 检测是否点击到箭头边缘
+export const checkIsAtArrowEdge = (element, rp) => {
+  let pointArr = element.pointArr
+  let x = pointArr[0][0]
+  let y = pointArr[0][1]
+  let tx = pointArr[pointArr.length - 1][0]
+  let ty = pointArr[pointArr.length - 1][1]
+  let segments = [[x, y, tx, ty]]
+  return checkIsAtMultiSegment(segments, rp) ? element : null
+}

+ 191 - 0
tiny-whiteboard/src/utils/draw.js

@@ -0,0 +1,191 @@
+import { degToRad, radToDeg, getFontString, splitTextLines } from './'
+
+// 图形绘制工具方法
+
+// 绘制公共操作
+export const drawWrap = (ctx, fn, fill = false) => {
+  ctx.beginPath()
+  fn()
+  ctx.stroke()
+  if (fill) {
+    ctx.fill()
+  }
+}
+
+// 绘制矩形
+export const drawRect = (ctx, x, y, width, height, fill = false) => {
+  drawWrap(ctx, () => {
+    ctx.rect(x, y, width, height)
+    if (fill) {
+      ctx.fillRect(x, y, width, height)
+    }
+  })
+}
+
+// 绘制菱形
+export const drawDiamond = (ctx, x, y, width, height, fill = false) => {
+  drawWrap(
+    ctx,
+    () => {
+      ctx.moveTo(x + width / 2, y)
+      ctx.lineTo(x + width, y + height / 2)
+      ctx.lineTo(x + width / 2, y + height)
+      ctx.lineTo(x, y + height / 2)
+      ctx.closePath()
+    },
+    fill
+  )
+}
+
+// 绘制三角形
+export const drawTriangle = (ctx, x, y, width, height, fill = false) => {
+  drawWrap(
+    ctx,
+    () => {
+      ctx.moveTo(x + width / 2, y)
+      ctx.lineTo(x + width, y + height)
+      ctx.lineTo(x, y + height)
+      ctx.closePath()
+    },
+    fill
+  )
+}
+
+// 绘制圆形
+export const drawCircle = (ctx, x, y, r, fill = false) => {
+  drawWrap(
+    ctx,
+    () => {
+      ctx.arc(x, y, r, 0, 2 * Math.PI)
+    },
+    fill
+  )
+}
+
+// 绘制折线
+export const drawLine = (ctx, points) => {
+  drawWrap(ctx, () => {
+    let first = true
+    points.forEach(point => {
+      if (first) {
+        first = false
+        ctx.moveTo(point[0], point[1])
+      } else {
+        ctx.lineTo(point[0], point[1])
+      }
+    })
+  })
+}
+
+// 绘制箭头
+export const drawArrow = (ctx, pointArr) => {
+  let x = pointArr[0][0]
+  let y = pointArr[0][1]
+  let tx = pointArr[pointArr.length - 1][0]
+  let ty = pointArr[pointArr.length - 1][1]
+  drawWrap(
+    ctx,
+    () => {
+      ctx.moveTo(x, y)
+      ctx.lineTo(tx, ty)
+    },
+    true
+  )
+  let l = 30
+  let deg = 30
+  let lineDeg = radToDeg(Math.atan2(ty - y, tx - x))
+  drawWrap(
+    ctx,
+    () => {
+      let plusDeg = deg - lineDeg
+      let _x = tx - l * Math.cos(degToRad(plusDeg))
+      let _y = ty + l * Math.sin(degToRad(plusDeg))
+      ctx.moveTo(_x, _y)
+      ctx.lineTo(tx, ty)
+    },
+    true
+  )
+  drawWrap(ctx, () => {
+    let plusDeg = 90 - lineDeg - deg
+    let _x = tx - l * Math.sin(degToRad(plusDeg))
+    let _y = ty - l * Math.cos(degToRad(plusDeg))
+    ctx.moveTo(_x, _y)
+    ctx.lineTo(tx, ty)
+  })
+}
+
+// 转换自由线段的点
+const transformFreeLinePoint = (point, opt) => {
+  // 屏幕坐标在左上角,画布坐标在中心,所以屏幕坐标要先转成画布坐标
+  let { x, y } = opt.app.coordinate.transform(point[0], point[1])
+  // 绘制前原点又由屏幕中心移动到了元素中心,所以还需要再转一次
+  return [x - opt.cx, y - opt.cy, ...point.slice(2)]
+}
+
+// 绘制自由线段
+export const drawFreeLine = (ctx, points, opt) => {
+  for (let i = 0; i < points.length - 1; i++) {
+    drawWrap(
+      ctx,
+      () => {
+        // 在这里转换可以减少一次额外的遍历
+        let point = transformFreeLinePoint(points[i], opt)
+        let nextPoint = transformFreeLinePoint(points[i + 1], opt)
+        drawLineSegment(
+          ctx,
+          point[0],
+          point[1],
+          nextPoint[0],
+          nextPoint[1],
+          nextPoint[2],
+          true
+        )
+      },
+      true
+    )
+  }
+}
+
+// 绘制线段
+export const drawLineSegment = (ctx, mx, my, tx, ty, lineWidth = 0) => {
+  drawWrap(ctx, () => {
+    if (lineWidth > 0) {
+      ctx.lineWidth = lineWidth
+    }
+    ctx.moveTo(mx, my)
+    ctx.lineTo(tx, ty)
+    ctx.lineCap = 'round'
+    ctx.lineJoin = 'round'
+  })
+}
+
+// 绘制文字
+export const drawText = (ctx, textObj, x, y, width, height) => {
+  let { text, style } = textObj
+  let lineHeight = style.fontSize * style.lineHeightRatio
+  drawWrap(ctx, () => {
+    ctx.font = getFontString(style.fontSize, style.fontFamily)
+    ctx.textBaseline = 'middle'
+    let textArr = splitTextLines(text)
+    textArr.forEach((textRow, index) => {
+      ctx.fillText(textRow, x, y + (index * lineHeight + lineHeight / 2))
+    })
+  })
+}
+
+// 绘制图片
+export const drawImage = (ctx, element, x, y, width, height) => {
+  drawWrap(ctx, () => {
+    let ratio = width / height
+    let showWidth = 0
+    let showHeight = 0
+    if (ratio > element.ratio) {
+      showHeight = height
+      showWidth = element.ratio * height
+    } else {
+      showWidth = width
+      showHeight = width / element.ratio
+    }
+    ctx.drawImage(element.imageObj, x, y, showWidth, showHeight)
+  })
+}

+ 431 - 0
tiny-whiteboard/src/utils/index.js

@@ -0,0 +1,431 @@
+// 通用工具方法
+
+// 创建canvas元素
+export const createCanvas = (
+  width,
+  height,
+  opt = { noStyle: false, noTranslate: false, className: '' }
+) => {
+  let canvas = document.createElement('canvas')
+  if (!opt.noStyle) {
+    canvas.style.cssText = `
+      position: absolute;
+      left: 0;
+      top: 0;
+    `
+  }
+  if (opt.className) {
+    canvas.className = opt.className
+  }
+  // 获取绘图上下文
+  let ctx = canvas.getContext('2d')
+  canvas.width = width
+  canvas.height = height
+  // 画布原点移动到画布中心
+  if (!opt.noTranslate) {
+    ctx.translate(canvas.width / 2, canvas.height / 2)
+  }
+  return {
+    canvas,
+    ctx
+  }
+}
+
+// 计算两点之间的距离
+export const getTowPointDistance = (x1, y1, x2, y2) => {
+  return Math.sqrt(Math.pow(x1 - x2, 2) + Math.pow(y1 - y2, 2))
+}
+
+// 计算点到直线的距离
+export const getPointToLineDistance = (x, y, x1, y1, x2, y2) => {
+  // 直线垂直于x轴
+  if (x1 === x2) {
+    return Math.abs(x - x1)
+  } else {
+    let B = 1
+    let A, C
+    A = (y1 - y2) / (x2 - x1)
+    C = 0 - B * y1 - A * x1
+    return Math.abs((A * x + B * y + C) / Math.sqrt(A * A + B * B))
+  }
+}
+
+// 检查是否点击到了一条线段
+export const checkIsAtSegment = (x, y, x1, y1, x2, y2, dis = 10) => {
+  if (getPointToLineDistance(x, y, x1, y1, x2, y2) > dis) {
+    return false
+  }
+  let dis1 = getTowPointDistance(x, y, x1, y1)
+  let dis2 = getTowPointDistance(x, y, x2, y2)
+  let dis3 = getTowPointDistance(x1, y1, x2, y2)
+  let max = Math.sqrt(dis * dis + dis3 * dis3)
+  if (dis1 <= max && dis2 <= max) {
+    return true
+  }
+  return false
+}
+
+// 弧度转角度
+export const radToDeg = rad => {
+  return rad * (180 / Math.PI)
+}
+
+// 角度转弧度
+export const degToRad = deg => {
+  return deg * (Math.PI / 180)
+}
+
+// 计算中心点相同的两个坐标相差的角度
+export const getTowPointRotate = (cx, cy, tx, ty, fx, fy) => {
+  return radToDeg(Math.atan2(ty - cy, tx - cx) - Math.atan2(fy - cy, fx - cx))
+}
+
+// 获取坐标经指定中心点旋转指定角度的坐标,顺时针还是逆时针rotate传正负即可
+export const getRotatedPoint = (x, y, cx, cy, rotate) => {
+  let deg = radToDeg(Math.atan2(y - cy, x - cx))
+  let del = deg + rotate
+  let dis = getTowPointDistance(x, y, cx, cy)
+  return {
+    x: Math.cos(degToRad(del)) * dis + cx,
+    y: Math.sin(degToRad(del)) * dis + cy
+  }
+}
+
+// 获取元素的中心点坐标
+export const getElementCenterPoint = element => {
+  let { x, y, width, height } = element
+  return {
+    x: x + width / 2,
+    y: y + height / 2
+  }
+}
+
+// 以指定中心点反向旋转坐标指定角度
+export const transformPointReverseRotate = (x, y, cx, cy, rotate) => {
+  if (rotate !== 0) {
+    let rp = getRotatedPoint(x, y, cx, cy, -rotate)
+    x = rp.x
+    y = rp.y
+  }
+  return {
+    x,
+    y
+  }
+}
+
+// 根据元素是否旋转了处理鼠标坐标,如果元素旋转了,那么鼠标坐标要反向旋转回去
+export const transformPointOnElement = (x, y, element) => {
+  let center = getElementCenterPoint(element)
+  return transformPointReverseRotate(x, y, center.x, center.y, element.rotate)
+}
+
+// 获取元素的四个角坐标
+export const getElementCornerPoint = (element, dir) => {
+  let { x, y, width, height } = element
+  switch (dir) {
+    case 'topLeft':
+      return {
+        x,
+        y
+      }
+    case 'topRight':
+      return {
+        x: x + width,
+        y
+      }
+    case 'bottomRight':
+      return {
+        x: x + width,
+        y: y + height
+      }
+    case 'bottomLeft':
+      return {
+        x,
+        y: y + height
+      }
+    default:
+      break
+  }
+}
+
+// 获取元素旋转后的四个角坐标
+export const getElementRotatedCornerPoint = (element, dir) => {
+  let center = getElementCenterPoint(element)
+  let dirPos = getElementCornerPoint(element, dir)
+  return getRotatedPoint(dirPos.x, dirPos.y, center.x, center.y, element.rotate)
+}
+
+// 判断一个坐标是否在一个矩形内
+// 第三个参数可以直接传一个带有x、y、width、height的元素对象
+export const checkPointIsInRectangle = (x, y, rx, ry, rw, rh) => {
+  if (typeof rx === 'object') {
+    let element = rx
+    rx = element.x
+    ry = element.y
+    rw = element.width
+    rh = element.height
+  }
+  return x >= rx && x <= rx + rw && y >= ry && y <= ry + rh
+}
+
+// 获取多个点的外包围框
+export const getBoundingRect = (pointArr = [], returnCorners = false) => {
+  let minX = Infinity
+  let maxX = -Infinity
+  let minY = Infinity
+  let maxY = -Infinity
+  pointArr.forEach(point => {
+    let [x, y] = point
+    if (x < minX) {
+      minX = x
+    }
+    if (x > maxX) {
+      maxX = x
+    }
+    if (y < minY) {
+      minY = y
+    }
+    if (y > maxY) {
+      maxY = y
+    }
+  })
+  let x = minX
+  let y = minY
+  let width = maxX - minX
+  let height = maxY - minY
+  // 以四个角坐标方式返回
+  if (returnCorners) {
+    return [
+      {
+        x,
+        y
+      },
+      {
+        x: x + width,
+        y
+      },
+      {
+        x: x + width,
+        y: y + height
+      },
+      {
+        x,
+        y: y + height
+      }
+    ]
+  }
+  return {
+    x,
+    y,
+    width,
+    height
+  }
+}
+
+// 简单深拷贝
+export const deepCopy = obj => {
+  return JSON.parse(JSON.stringify(obj))
+}
+
+// 拼接文字字体字号字符串
+export const getFontString = (fontSize, fontFamily) => {
+  return `${fontSize}px ${fontFamily}`
+}
+
+// 文本切割成行
+export const splitTextLines = text => {
+  return text.replace(/\r\n?/g, '\n').split('\n')
+}
+
+// 计算文本的实际渲染宽度
+let textCheckEl = null
+export const getTextActWidth = (text, style) => {
+  if (!textCheckEl) {
+    textCheckEl = document.createElement('div')
+    textCheckEl.style.position = 'fixed'
+    textCheckEl.style.left = '-99999px'
+    document.body.appendChild(textCheckEl)
+  }
+  let { fontSize, fontFamily } = style
+  textCheckEl.innerText = text
+  textCheckEl.style.fontSize = fontSize + 'px'
+  textCheckEl.style.fontFamily = fontFamily
+  let { width } = textCheckEl.getBoundingClientRect()
+  return width
+}
+
+// 计算固定宽度内能放下所有文字的最大字号
+export const getMaxFontSizeInWidth = (text, width, style) => {
+  let fontSize = 12
+  while (
+    getTextActWidth(text, {
+      ...style,
+      fontSize: fontSize + 1
+    }) < width
+  ) {
+    fontSize++
+  }
+  return fontSize
+}
+
+// 计算换行文本的实际宽度
+export const getWrapTextActWidth = element => {
+  let { text } = element
+  let textArr = splitTextLines(text)
+  let maxWidth = -Infinity
+  textArr.forEach(textRow => {
+    let width = getTextActWidth(textRow, element.style)
+    if (width > maxWidth) {
+      maxWidth = width
+    }
+  })
+  return maxWidth
+}
+
+// 计算换行文本的最长一行的文字数量
+export const getWrapTextMaxRowTextNumber = text => {
+  let textArr = splitTextLines(text)
+  let maxNumber = -Infinity
+  textArr.forEach(textRow => {
+    if (textRow.length > maxNumber) {
+      maxNumber = textRow.length
+    }
+  })
+  return maxNumber
+}
+
+// 计算一个文本元素的宽高
+export const getTextElementSize = element => {
+  let { text, style } = element
+  let width = getWrapTextActWidth(element)
+  const lines = Math.max(splitTextLines(text).length, 1)
+  let lineHeight = style.fontSize * style.lineHeightRatio
+  let height = lines * lineHeight
+  return {
+    width,
+    height
+  }
+}
+
+// 节流函数
+export const throttle = (fn, ctx, time = 100) => {
+  let timer = null
+  return (...args) => {
+    if (timer) {
+      return
+    }
+    timer = setTimeout(() => {
+      fn.call(ctx, ...args)
+      timer = null
+    }, time)
+  }
+}
+
+// 根据速度计算画笔粗细
+export const computedLineWidthBySpeed = (
+  speed,
+  lastLineWidth,
+  baseLineWidth = 2
+) => {
+  let lineWidth = 0
+  let maxLineWidth = baseLineWidth
+  let maxSpeed = 10
+  let minSpeed = 0.5
+  // 速度超快,那么直接使用最小的笔画
+  if (speed >= maxSpeed) {
+    lineWidth = baseLineWidth
+  } else if (speed <= minSpeed) {
+    // 速度超慢,那么直接使用最大的笔画
+    lineWidth = maxLineWidth + 1
+  } else {
+    // 中间速度,那么根据速度的比例来计算
+    lineWidth =
+      maxLineWidth - ((speed - minSpeed) / (maxSpeed - minSpeed)) * maxLineWidth
+  }
+  if (lastLineWidth === -1) {
+    lastLineWidth = maxLineWidth
+  }
+  // 最终的粗细为计算出来的一半加上上一次粗细的一半,防止两次粗细相差过大,出现明显突变
+  return lineWidth * (1 / 2) + lastLineWidth * (1 / 2)
+}
+
+// 下载文件
+export const downloadFile = (file, fileName) => {
+  let a = document.createElement('a')
+  a.href = file
+  a.download = fileName
+  a.click()
+}
+
+// 获取元素的四个角的坐标,应用了旋转之后的
+export const getElementCorners = element => {
+  // 左上角
+  let topLeft = getElementRotatedCornerPoint(element, 'topLeft')
+  // 右上角
+  let topRight = getElementRotatedCornerPoint(element, 'topRight')
+  // 左下角
+  let bottomLeft = getElementRotatedCornerPoint(element, 'bottomLeft')
+  // 右下角
+  let bottomRight = getElementRotatedCornerPoint(element, 'bottomRight')
+  return [topLeft, topRight, bottomLeft, bottomRight]
+}
+
+// 获取多个元素的最外层包围框信息
+export const getMultiElementRectInfo = (elementList = []) => {
+  if (elementList.length <= 0) {
+    return {
+      minx: 0,
+      maxx: 0,
+      miny: 0,
+      maxy: 0
+    }
+  }
+  let minx = Infinity
+  let maxx = -Infinity
+  let miny = Infinity
+  let maxy = -Infinity
+  elementList.forEach(element => {
+    let pointList = element.getEndpointList()
+    pointList.forEach(({ x, y }) => {
+      if (x < minx) {
+        minx = x
+      }
+      if (x > maxx) {
+        maxx = x
+      }
+      if (y < miny) {
+        miny = y
+      }
+      if (y > maxy) {
+        maxy = y
+      }
+    })
+  })
+  return {
+    minx,
+    maxx,
+    miny,
+    maxy
+  }
+}
+
+// 创建图片对象
+export const createImageObj = url => {
+  return new Promise(resolve => {
+    let img = new Image()
+    img.setAttribute('crossOrigin', 'anonymous')
+    img.onload = () => {
+      resolve(img)
+    }
+    img.onerror = () => {
+      resolve(null)
+    }
+    img.src = url
+  })
+}
+
+// 元素的唯一key
+let nodeKeyIndex = 0
+export const createNodeKey = () => {
+  return nodeKeyIndex++
+}

+ 71 - 0
tiny-whiteboard/src/utils/keyMap.js

@@ -0,0 +1,71 @@
+const map = {
+  Backspace: 8,
+  Tab: 9,
+  Enter: 13,
+
+  Shift: 16,
+  Control: 17,
+  Alt: 18,
+  CapsLock: 20,
+
+  Esc: 27,
+
+  Space: 32,
+
+  PageUp: 33,
+  PageDown: 34,
+  End: 35,
+  Home: 36,
+
+  Insert: 45,
+
+  Left: 37,
+  Up: 38,
+  Right: 39,
+  Down: 40,
+
+  Del: 46,
+
+  NumLock: 144,
+
+  Cmd: 91,
+  CmdFF: 224,
+  F1: 112,
+  F2: 113,
+  F3: 114,
+  F4: 115,
+  F5: 116,
+  F6: 117,
+  F7: 118,
+  F8: 119,
+  F9: 120,
+  F10: 121,
+  F11: 122,
+  F12: 123,
+
+  '`': 192,
+  '=': 187,
+  '+': 187,
+  '-': 189,
+  "'": 222,
+
+  '/': 191,
+  '.': 190
+}
+
+// 数字
+for (let i = 0; i <= 9; i++) {
+  map[i] = i + 48
+}
+
+// 字母
+'abcdefghijklmnopqrstuvwxyz'.split('').forEach((n, index) => {
+  map[n] = index + 65
+})
+
+export const keyMap = map
+
+export const isKey = (e, key) => {
+  let code = typeof e === 'object' ? e.keyCode : e
+  return map[key] === code
+}

+ 25 - 0
tiny-whiteboard/vite.config.js

@@ -0,0 +1,25 @@
+import { defineConfig } from 'vite'
+const path = require('path')
+
+// https://vitejs.dev/config/
+export default defineConfig({
+  build: {
+    target: 'es2015',
+    lib: {
+      entry: path.resolve(__dirname, './src/index.js'),
+      name: 'tiny-whiteboard',
+      fileName: format => `tiny-whiteboard.${format}.js`
+    }
+  }
+})
+
+/*
+"main": "./dist/tiny-whiteboard.umd.js",
+"module": "./dist/tiny-whiteboard.es.js",
+"exports": {
+  ".": {
+    "import": "./dist/tiny-whiteboard.es.js",
+    "require": "./dist/tiny-whiteboard.umd.js"
+  }
+}
+*/

Một số tệp đã không được hiển thị bởi vì quá nhiều tập tin thay đổi trong này khác