commit a948b80f4d59a204f752e5d55834f0f75452f750 Author: lyc Date: Thu Jun 27 16:48:09 2024 +0800 init diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..ad0d0bb --- /dev/null +++ b/.gitignore @@ -0,0 +1,25 @@ +/node_modules/ +/.idea/.gitignore +/.idea/Alx.iml +/.idea/modules.xml +/.idea/vcs.xml +/dist/ +/.idea/workspace.xml + +yarn.lock + +pnpm-lock.yaml + +node_modules +.vscode/* +!.vscode/extensions.json + +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +/.idea/deployment.xml +/.idea/encodings.xml +/.idea/misc.xml +/dist.zip +/package-lock.json diff --git a/.prettierignore b/.prettierignore new file mode 100644 index 0000000..f70db0a --- /dev/null +++ b/.prettierignore @@ -0,0 +1,4 @@ +dist +node_modules +package-lock.json +package.json \ No newline at end of file diff --git a/.prettierrc b/.prettierrc new file mode 100644 index 0000000..ef93d94 --- /dev/null +++ b/.prettierrc @@ -0,0 +1,5 @@ +semi: false +singleQuote: true +printWidth: 80 +trailingComma: 'none' +arrowParens: 'avoid' diff --git a/README.md b/README.md new file mode 100644 index 0000000..b5cd5e7 --- /dev/null +++ b/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 +
+``` + +```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) + diff --git a/app/.prettierignore b/app/.prettierignore new file mode 100644 index 0000000..c453dd6 --- /dev/null +++ b/app/.prettierignore @@ -0,0 +1,5 @@ +dist +node_modules +package-lock.json +package.json +public \ No newline at end of file diff --git a/app/.prettierrc b/app/.prettierrc new file mode 100644 index 0000000..ef93d94 --- /dev/null +++ b/app/.prettierrc @@ -0,0 +1,5 @@ +semi: false +singleQuote: true +printWidth: 80 +trailingComma: 'none' +arrowParens: 'avoid' diff --git a/app/index.html b/app/index.html new file mode 100644 index 0000000..149a67f --- /dev/null +++ b/app/index.html @@ -0,0 +1,15 @@ + + + + + + + 在线小白板 + + + +
+ + + + diff --git a/app/package-lock.json b/app/package-lock.json new file mode 100644 index 0000000..c89394b --- /dev/null +++ b/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 + } + } +} diff --git a/app/package.json b/app/package.json new file mode 100644 index 0000000..17be59a --- /dev/null +++ b/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" + } +} diff --git a/app/public/favicon.ico b/app/public/favicon.ico new file mode 100644 index 0000000..df36fcf Binary files /dev/null and b/app/public/favicon.ico differ diff --git a/app/public/libs/icons.svg b/app/public/libs/icons.svg new file mode 100644 index 0000000..cc8298a --- /dev/null +++ b/app/public/libs/icons.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/app/public/libs/jsonTree.css b/app/public/libs/jsonTree.css new file mode 100644 index 0000000..3812440 --- /dev/null +++ b/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 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; +} diff --git a/app/public/libs/jsonTree.js b/app/public/libs/jsonTree.js new file mode 100644 index 0000000..51bbf01 --- /dev/null +++ b/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: + *
  • + * + * + * + * "label" + * + * : + * + * <(div|span) class="jsontree_value jsontree_value_(object|array|boolean|null|number|string)"> + * ... + * + *
  • + * + * @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: + *
  • + * + * "age" + * : + * + * 25 + * , + *
  • + * + * @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 = '\ + \ + "' + + label + + '" : \ + \ + \ + ' + + val + + '' + + (!isLast ? ',' : '') + + ''; + + 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: + *
  • + * + * + * + * "label" + * + * : + * + *
    + * { + *
      + * } + * , + *
    + *
  • + * + * @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 = '\ +
    \ +
    \ + ' + sym[0] + '\ + \ +
      \ + ' + sym[1] + '' + + '
      ' + comma + + '
      '; + + if (label !== null) { + str = '\ + \ + ' + + '' + + '"' + label + + '" : \ + ' + 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: + * + * + * @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("
      "); + jsonStr = jsonStr.split(DELIMETER).join("    "); + + 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); + } + }; +})(); diff --git a/app/src/App.vue b/app/src/App.vue new file mode 100644 index 0000000..5a78f03 --- /dev/null +++ b/app/src/App.vue @@ -0,0 +1,1042 @@ + + + + + + diff --git a/app/src/components/ColorPicker.vue b/app/src/components/ColorPicker.vue new file mode 100644 index 0000000..9246cca --- /dev/null +++ b/app/src/components/ColorPicker.vue @@ -0,0 +1,132 @@ + + + + + diff --git a/app/src/components/Contextmenu.vue b/app/src/components/Contextmenu.vue new file mode 100644 index 0000000..4c23a3d --- /dev/null +++ b/app/src/components/Contextmenu.vue @@ -0,0 +1,218 @@ + + + + + diff --git a/app/src/constants.js b/app/src/constants.js new file mode 100644 index 0000000..7f006fa --- /dev/null +++ b/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 + } +}) diff --git a/app/src/main.js b/app/src/main.js new file mode 100644 index 0000000..01433bc --- /dev/null +++ b/app/src/main.js @@ -0,0 +1,4 @@ +import { createApp } from 'vue' +import App from './App.vue' + +createApp(App).mount('#app') diff --git a/app/vite.config.js b/app/vite.config.js new file mode 100644 index 0000000..57da9bd --- /dev/null +++ b/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: ['..'] + } + } +}) diff --git a/components/ColorPicker.vue b/components/ColorPicker.vue new file mode 100644 index 0000000..9246cca --- /dev/null +++ b/components/ColorPicker.vue @@ -0,0 +1,132 @@ + + + + + diff --git a/components/Contextmenu.vue b/components/Contextmenu.vue new file mode 100644 index 0000000..4c23a3d --- /dev/null +++ b/components/Contextmenu.vue @@ -0,0 +1,218 @@ + + + + + diff --git a/constants.js b/constants.js new file mode 100644 index 0000000..7f006fa --- /dev/null +++ b/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 + } +}) diff --git a/index.vue b/index.vue new file mode 100644 index 0000000..465a139 --- /dev/null +++ b/index.vue @@ -0,0 +1,1047 @@ + + + + + + diff --git a/package.json b/package.json new file mode 100644 index 0000000..1d204c9 --- /dev/null +++ b/package.json @@ -0,0 +1,25 @@ +{ + "name": "whiteboard_lyc", + "version": "0.0.3", + "description": "一个简单的在线白板", + "authors": [ + { + "name": "lyc", + "email": "ly_cheng2023@163.com" + } + ], + "license": "MIT", + "scripts": { + "build": "vite build", + "format": "prettier --write ." + }, + "main": "dist/whiteboard_lyc.cjs.js", + "dependencies": { + "eventemitter3": "^4.0.7", + "uuid": "^9.0.0" + }, + "devDependencies": { + "prettier": "^2.7.1", + "vite": "^2.9.5" + } +} diff --git a/src/Background.js b/src/Background.js new file mode 100644 index 0000000..181ba78 --- /dev/null +++ b/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() + } +} diff --git a/src/Canvas.js b/src/Canvas.js new file mode 100644 index 0000000..7a79b82 --- /dev/null +++ b/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) + } +} diff --git a/src/Coordinate.js b/src/Coordinate.js new file mode 100644 index 0000000..9eeafa1 --- /dev/null +++ b/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) + } + } +} diff --git a/src/Cursor.js b/src/Cursor.js new file mode 100644 index 0000000..61c9336 --- /dev/null +++ b/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,iVBORw0KGgoAAAANSUhEUgAAACAAAAAgCAYAAABzenr0AAAAAXNSR0IArs4c6QAAAktJREFUWEfFlj+LFEEQxV+1GCiYmPgnEMVMAwM1VBQ/gIF46SnsdMuKYCRooiYaGJiI0jWB3ImBHuhFIhh4cOldYmJi4FcwX6a05XaZbWd7qnsObJhk6Kn366pX1UP4z4uG6o/H48OTyeQGgDNEtOK9/5wTcxCAc+6UiLwHcLol+piZH2khigHa4kT0BcDdFowaogggOvkHZr4WThy9V0FkA0QiK8wc6j9buRBZAFHwF8x8p6vWORBqgKjmT733D1JG00KoALTBYiDNd70AmiBDMpEEGCo+BUvFWQhgrT0CIPR3GDKqltJmgoiWvferYX8K4HkYLrshHmViU0S+1XV9OQlQVdUrIroFYImZ17SjNbVvNBpdMsZ8FZGNJEDHjB8MMRX/e+pUCWLDAHi4c6piiLZ4XNI5D0Tiz5j5nrU23GzFECnxOQ+0xUXkZV3Xt6f1LIXoE48BnojIfRFZret6OTZTLoRGfA7AWivhBTOnWlNVjh3xTwD29bXxTMxaG/5srv8ZPGvMvLSonfoy4Zy7KCIfARzsE48zEMQDRFhFEFVVnQewTkRHNeL/DCJrbTGEMeZ70zTrAE5qxTsn4QCIadWy7o1Ow2kgQrqJ6B2AEyLywxjzK3jYe885Yzvl+IXlcM5dEZG3AA7lpLsLLPk/0JUJ59xVEXkD4MBQ8eRt2JqCs0yEW4yILgDYsxviXV2wF8D+judYq0XDd1lGS3liVgLn3JaInM0xUOleItr23p+L74LXTdMcLw2a850x5qf3/qbKAzmBS/b+BpHvnTDlnKVDAAAAAElFTkSuQmCC) 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') + } +} diff --git a/src/Elements.js b/src/Elements.js new file mode 100644 index 0000000..dba01a8 --- /dev/null +++ b/src/Elements.js @@ -0,0 +1,482 @@ +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 + if(this.app.isMobile){ + this.elementList = this.elementList.filter(item => item.style.elReadonly) + } + else{ + 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 + } +} diff --git a/src/Event.js b/src/Event.js new file mode 100644 index 0000000..a55f0d1 --- /dev/null +++ b/src/Event.js @@ -0,0 +1,214 @@ +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.onPaste = this.onPaste.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('touchstart', this.onMousedown) + this.app.container.addEventListener('touchmove', this.onMousemove) + this.app.container.addEventListener('touchend', 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) + + window.addEventListener("paste", this.onPaste) + } + + // 解绑事件 + 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('touchstart', this.onMousedown) + this.app.container.removeEventListener('touchmove', this.onMousemove) + this.app.container.removeEventListener('touchend', 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 ex = e.clientX == undefined ? e.changedTouches[0].clientX : e.clientX + let ey = e.clientY == undefined ? e.changedTouches[0].clientY : e.clientY + let wp = coordinate.windowToContainer(ex, ey) + // console.log(wp.x) + // alert(wp.x) + // 元素缩放是*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) + } + + onPaste(e){ + this.emit('paste', e, this) + } +} diff --git a/src/Export.js b/src/Export.js new file mode 100644 index 0000000..0a0c5a5 --- /dev/null +++ b/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() + } +} diff --git a/src/Grid.js b/src/Grid.js new file mode 100644 index 0000000..ac5849b --- /dev/null +++ b/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() + } + } +} diff --git a/src/Group.js b/src/Group.js new file mode 100644 index 0000000..e108c98 --- /dev/null +++ b/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] || [] + } +} diff --git a/src/History.js b/src/History.js new file mode 100644 index 0000000..cb3d7dd --- /dev/null +++ b/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) + } +} diff --git a/src/ImageEdit.js b/src/ImageEdit.js new file mode 100644 index 0000000..82b14e9 --- /dev/null +++ b/src/ImageEdit.js @@ -0,0 +1,169 @@ +import EventEmitter from 'eventemitter3' +export default class ImageEdit extends EventEmitter { + constructor(app) { + super() + this.app = app + this.el = null + this.isReady = false + this.isPaste = false + this.moveEvent = null + 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() { + if(this.el){ + this.el.value = '' + if(this.previewEl){ + document.body.removeChild(this.previewEl) + } + + } + this.isReady = false + this.isPaste = false + 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 file = null; + if(e.target){ + file = e.target.files[0] + this.isReady = true + this.isPaste = false + } + else{ + file = e + this.isReady = false + this.isPaste = true + } + let url = await this.getImageUrl(file) + let { imageObj, size, ratio } = await this.getImageSize(url) + this.imageData = { + url, + ...size, + ratio, + imageObj + } + + if(this.isPaste){ + this.emit('imagePaste') + } + else{ + this.emit('imageSelectChange', this.imageData) + } + + } + + // 获取图片url + async getImageUrl(file) { + return new Promise((resolve, reject) => { + let reader = new FileReader() + reader.onloadend = function (e) { + if(file.size < 1048576){ + // 小于1M + resolve(reader.result) + } + else{ + const img = new Image(); + img.src = e.target.result; + + img.onload = function () { + const canvas = document.createElement('canvas'); + const ctx = canvas.getContext('2d'); + + let width = img.width; + let height = img.height; + canvas.width = width; + canvas.height = height; + ctx.drawImage(img, 0, 0, width, height); + + const compressedDataUrl = canvas.toDataURL('image/jpeg', 0.8); // 压缩图片质量为0.8 + resolve(compressedDataUrl) + } + + }; + }; + reader.onerror = () => { + reject() + } + + reader.readAsDataURL(file); + + }) + } +} diff --git a/src/KeyCommand.js b/src/KeyCommand.js new file mode 100644 index 0000000..adc161d --- /dev/null +++ b/src/KeyCommand.js @@ -0,0 +1,147 @@ +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) + this.app.event.on('paste', this.onPaste, 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) + }) + } + }) + } + + // 用户通过浏览器发起一个粘贴动作时触发 + onPaste(e){ + const clipboardData = e.clipboardData || window.clipboardData; + const items = clipboardData.items; + let file = null; + for (let i = 0; i < items.length; i++) { + const { kind, type } = items[i] + const isFile = kind == 'file' // 文件 + const isImg = type.startsWith('image') // 图片类型 + if (isFile && isImg) { file = items[i].getAsFile(); break; } + } + if(!file) return + this.app.imageEdit.onImageSelectChange(file) + } + + // 检查键值是否符合 + 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] + } + } + }) + } +} diff --git a/src/Mode.js b/src/Mode.js new file mode 100644 index 0000000..1289493 --- /dev/null +++ b/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 + } +} diff --git a/src/Render.js b/src/Render.js new file mode 100644 index 0000000..bc181ed --- /dev/null +++ b/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) + } +} diff --git a/src/Selection.js b/src/Selection.js new file mode 100644 index 0000000..0f14060 --- /dev/null +++ b/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()) + } +} diff --git a/src/TextEdit.js b/src/TextEdit.js new file mode 100644 index 0000000..1bbad24 --- /dev/null +++ b/src/TextEdit.js @@ -0,0 +1,104 @@ +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', + 'z-index': 99999, + 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 + } +} diff --git a/src/constants.js b/src/constants.js new file mode 100644 index 0000000..9bec227 --- /dev/null +++ b/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 diff --git a/src/elements/Arrow.js b/src/elements/Arrow.js new file mode 100644 index 0000000..6abffd4 --- /dev/null +++ b/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) + } +} diff --git a/src/elements/BaseElement.js b/src/elements/BaseElement.js new file mode 100644 index 0000000..004097c --- /dev/null +++ b/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) + } +} diff --git a/src/elements/BaseMultiPointElement.js b/src/elements/BaseMultiPointElement.js new file mode 100644 index 0000000..52bc877 --- /dev/null +++ b/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 + } + }) + } +} diff --git a/src/elements/Circle.js b/src/elements/Circle.js new file mode 100644 index 0000000..e5413f7 --- /dev/null +++ b/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) + } +} diff --git a/src/elements/Diamond.js b/src/elements/Diamond.js new file mode 100644 index 0000000..3e7715b --- /dev/null +++ b/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) + }) + } +} diff --git a/src/elements/DragElement.js b/src/elements/DragElement.js new file mode 100644 index 0000000..136dd8f --- /dev/null +++ b/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 + ) + } +} diff --git a/src/elements/Freedraw.js b/src/elements/Freedraw.js new file mode 100644 index 0000000..a70cb88 --- /dev/null +++ b/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() + } +} diff --git a/src/elements/Image.js b/src/elements/Image.js new file mode 100644 index 0000000..9b4341d --- /dev/null +++ b/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) + } +} diff --git a/src/elements/Line.js b/src/elements/Line.js new file mode 100644 index 0000000..bc8e6fb --- /dev/null +++ b/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) + } +} diff --git a/src/elements/MultiSelectElement.js b/src/elements/MultiSelectElement.js new file mode 100644 index 0000000..3f6eb86 --- /dev/null +++ b/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() + } + } +} diff --git a/src/elements/Rectangle.js b/src/elements/Rectangle.js new file mode 100644 index 0000000..9c9ef89 --- /dev/null +++ b/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) + } +} diff --git a/src/elements/Text.js b/src/elements/Text.js new file mode 100644 index 0000000..ea77787 --- /dev/null +++ b/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 + } +} diff --git a/src/elements/Triangle.js b/src/elements/Triangle.js new file mode 100644 index 0000000..691b988 --- /dev/null +++ b/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) + }) + } +} diff --git a/src/elements/index.js b/src/elements/index.js new file mode 100644 index 0000000..85f2883 --- /dev/null +++ b/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 +} diff --git a/src/index.js b/src/index.js new file mode 100644 index 0000000..0367594 --- /dev/null +++ b/src/index.js @@ -0,0 +1,687 @@ +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/index' +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.isMobile = null + // 容器宽高位置信息 + 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 || {}) + } + // 判断当前设备 pc or mobile + this.initEquipment() + // 初始化画布 + 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('touchstart', this.onMousedown, this) + this.event.on('touchmove', this.onMousemove, this) + this.event.on('touchend', 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.imageEdit.on('imagePaste', this.onImagePaste, 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() + } + } + + // 设备是PC 还是mobile + initEquipment() { + const isMobile = + /(iPhone|iPad|iPod|iOS|Android|Linux armv8l|Linux armv7l|Linux aarch64)/i.test( + navigator.userAgent + ) + if (isMobile) { + this.isMobile = true + } else { + this.isMobile = false + } + } + + // 初始化画布 + 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.drawType !== 'image') { + 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() + let e = { + unGridClientX: this.width / 2, + unGridClientY: this.height / 2 + } + this.elements.creatingImage(e, this.imageEdit.imageData) + this.completeCreateNewElement() + this.cursor.reset() + this.imageEdit.reset() + } + + // 粘贴图片 + onImagePaste() { + // this.imageEdit.moveEvent. + if (this.elements.activeElement) { + this.copyCurrentElement() + this.pasteCurrentElement() + } else { + this.elements.creatingImage( + this.imageEdit.moveEvent, + this.imageEdit.imageData + ) + } + this.completeCreateNewElement() + this.cursor.reset() + this.imageEdit.reset() + } + + // 鼠标按下事件 + 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.isMobile) { + if (hitElement && hitElement.style.elReadonly) return + } + 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) + } else if (this.drawType === 'text') { + this.selection.reset() + } + } + } + + // 鼠标移动事件 + onMousemove(e, event) { + if (this.state.readonly || this.mode.isDragMode) { + if (event.isMousedown) { + // 只读模式下进行整体拖动 + this.mode.onMove(e, event) + } + return + } + this.imageEdit.moveEvent = e + + // 鼠标按下状态 + 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.reset() + // 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 diff --git a/src/utils/checkHit.js b/src/utils/checkHit.js new file mode 100644 index 0000000..e920303 --- /dev/null +++ b/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 +} diff --git a/src/utils/draw.js b/src/utils/draw.js new file mode 100644 index 0000000..b91ebd8 --- /dev/null +++ b/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) + }) +} diff --git a/src/utils/index.js b/src/utils/index.js new file mode 100644 index 0000000..f752cf4 --- /dev/null +++ b/src/utils/index.js @@ -0,0 +1,432 @@ +// 通用工具方法 + +// 创建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++ +} + diff --git a/src/utils/keyMap.js b/src/utils/keyMap.js new file mode 100644 index 0000000..13fa2a3 --- /dev/null +++ b/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 +} diff --git a/vite.config.js b/vite.config.js new file mode 100644 index 0000000..ac0fba1 --- /dev/null +++ b/vite.config.js @@ -0,0 +1,24 @@ +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'), + formats: ['es', 'cjs'] + } + } +}) + +/* +"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" + } +} +*/