Browse Source

Merge remote-tracking branch 'upstream/0.3.x' into perf/dsl-fix

master
qilei0529 4 years ago
parent
commit
0844692576
  1. 6
      README.md
  2. 77
      README.zh-CN.md
  3. 1
      cypress.json
  4. 28
      cypress/integration/examples/actions.spec.js
  5. 45
      cypress/integration/examples/configuration.test.js
  6. 28
      cypress/integration/examples/dataSave.spec.js
  7. 42
      cypress/integration/examples/element.test.js
  8. 100
      cypress/integration/examples/tools.test.js
  9. 41
      example/mock/api.ts
  10. 336
      example/mock/db.json
  11. 13031
      package-lock.json
  12. 3
      package.json
  13. 54
      packages/core/src/mods/header/export/exportModal.tsx
  14. 27
      packages/core/src/mods/header/export/index.module.less
  15. 33
      packages/core/src/mods/header/export/index.tsx
  16. 2
      packages/core/src/mods/header/index.tsx
  17. 20
      packages/core/src/utils/index.ts

6
README.md

@ -28,10 +28,12 @@
下载仓库,安装并启动
```bash
$ git clone https://github.com/imgcook/imove.git
$ git clone git@github.com:imgcook/imove.git
$ cd imove
$ npm install
$ npm run example
```
打开 http://localhost:8000/ ,可以看到线上运行效果。
此时浏览器会自动打开 `http://localhost:8000/` ,可以看到运行效果。
### 步骤 2. 绘制流程图

77
README.zh-CN.md

@ -1,77 +0,0 @@
# iMove
<!-- ALL-CONTRIBUTORS-BADGE:START - Do not remove or modify this section -->
[![All Contributors](https://img.shields.io/badge/all_contributors-0-orange.svg?style=flat-square)](#contributors-)
<!-- ALL-CONTRIBUTORS-BADGE:END -->
<div align="center">
<img src="https://ss1.bdstatic.com/70cFuXSh_Q1YnxGkpoWK1HF6hhy/it/u=3874775950,1064987171&fm=26&gp=0.jpg" width="200px">
</div>
<p align="center">
iMove 是一个逻辑可复用的,面向函数的,流程可视化的 JavaScript 工具库。
</p>
[English](./README.en-US.md) | 简体中文
## 特性
- [x] **流程可视化**: 上手简单,绘图方便,逻辑表达更直观,易于理解
- [x] **逻辑复用**: iMove 节点支持复用,单节点支持参数配置
- [x] **灵活可扩展**: 仅需写一个函数,节点可扩展,支持插件集成
- [ ] **多语言编译**: 无语言编译出码限制(例: 支持 JavaScript, Java 编译出码)
## 快速开始
### 步骤 1. 准备
下载仓库,安装并启动
```bash
$ git clone https://github.com/imgcook/imove.git
$ npm run example
```
打开 http://localhost:8000/ ,就可以看到线上运行效果。
### 步骤 2. 绘制流程图
从左侧拖动节点至中央画布,绘制流程图
![flowchart](https://img.alicdn.com/tfs/TB1aoYe4pP7gK0jSZFjXXc5aXXa-3090-1806.jpg)
### 步骤 3. 配置节点
选择节点,修改节点名,编辑节点代码
![flowchart-usage1](https://img.alicdn.com/tfs/TB1z6DKoZieb18jSZFvXXaI3FXa-1924-1125.png)
![flowchart-usage2](https://img.alicdn.com/tfs/TB1lC26tTM11u4jSZPxXXahcXXa-1924-1125.png)
## 贡献
1. Fork 仓库
2. 创建分支 (`git checkout -b my-new-feature`)
3. 提交修改 (`git commit -am 'Add some feature'`)
4. 推送 (`git push origin my-new-feature`)
5. 创建 PR
## 欢迎fork和反馈
如有建议或意见,欢迎在 github [issues](https://github.com/imgcook/imove/issues) 区提问
## 协议
本仓库遵循 [MIT 协议](http://www.opensource.org/licenses/MIT)
## 贡献者 ✨
感谢所有贡献的人 ([emoji key](https://allcontributors.org/docs/en/emoji-key)):
<!-- ALL-CONTRIBUTORS-LIST:START - Do not remove or modify this section -->
<!-- prettier-ignore-start -->
<!-- markdownlint-disable -->
<!-- markdownlint-restore -->
<!-- prettier-ignore-end -->
<!-- ALL-CONTRIBUTORS-LIST:END -->
本仓库遵循 [all-contributors](https://github.com/all-contributors/all-contributors) 规范,欢迎贡献!

1
cypress.json

@ -0,0 +1 @@
{}

28
cypress/integration/examples/actions.spec.js

@ -1,28 +0,0 @@
/// <reference types="cypress" />
context('Actions', () => {
// 1. 打开http://localhost:8001/
beforeEach(() => {
cy.visit('http://localhost:8001/')
})
// 2. 测试是否能将左侧的组件拖动到画布中
it('the components on the left can be dragged into the canvas', () => {
})
// 3. 测试是否可修改节点的显示名称、逻辑触发名称、代码、投放配置schema、依赖
it('you can modify the display name, logical trigger name, code, delivery configuration schema, and dependencies of the node', () => {
})
// 4. 测试能够run起来一个最小流程(写好代码)
it('A minimal process can be run', () => {
})
// 5. 测试更改节点位置,输出结果不变
it('ensure that the node position is changed and the output result remains unchanged', () => {
})
})

45
cypress/integration/examples/configuration.test.js

@ -0,0 +1,45 @@
/// <reference types="cypress" />
context('Actions', () => {
beforeEach(() => {
cy.visit('http://localhost:8000/')
cy.get('div.ant-modal-confirm-btns > button:nth-child(1)').click({ force: true })
})
function moveNodeAndSelect() {
cy.get('svg > g > g.x6-graph-svg-stage > g:nth-child(1)').eq(0)
.trigger('mousedown', { which: 1, force: true })
.trigger('mousemove', { clientX: 400, clientY: 300, force: true })
.trigger('mouseup', { force: true })
cy.get('svg > g > g.x6-graph-svg-stage > g:nth-child(1)').eq(1).click({ force: true }) // 点击移动到画布中的节点
}
function assertEdit(nth, content) {
moveNodeAndSelect()
const ele = cy.get(`#rc-tabs-0-panel-basic > div > div:nth-child(${nth}) > button`).eq(0)
ele.should('have.text', '编 辑')
ele.click({ force: true })
cy.get('.ant-modal-content').should('exist')
cy.get('.ace_content').type(content)
cy.get('button.ant-btn.ant-btn-primary').eq(0).click({ force: true })
ele.click({ force: true })
cy.get('.ace_content').should('contain', content)
}
it('修改节点名称', () => {
moveNodeAndSelect()
const ele = cy.get('#rc-tabs-0-panel-basic > div > div:nth-child(1) > input').eq(0)
ele.clear({ force: true })
ele.type('新的开始')
ele.should('have.value', '新的开始')
})
it('修改节点代码', () => {
assertEdit(2, 'console.log(1)')
})
it('修改投放配置schema', () => {
assertEdit(3, `"a":1`)
})
it('修改npm依赖', () => {
assertEdit(4, `"@ant-design/icons": "^4.0.6"`)
})
})

28
cypress/integration/examples/dataSave.spec.js

@ -1,28 +0,0 @@
/// <reference types="cypress" />
context('Actions', () => {
// 1. 打开http://localhost:8001/
beforeEach(() => {
cy.visit('http://localhost:8001/')
})
// 2. 测试是否能将左侧的组件拖动到画布中
it('the components on the left can be dragged into the canvas', () => {
})
// 3. 测试是否可修改节点的显示名称、逻辑触发名称、代码、投放配置schema、依赖
it('you can modify the display name, logical trigger name, code, delivery configuration schema, and dependencies of the node', () => {
})
// 4. 测试能够run起来一个最小流程(写好代码)
it('A minimal process can be run', () => {
})
// 5. 测试更改节点位置,输出结果不变
it('ensure that the node position is changed and the output result remains unchanged', () => {
})
})

42
cypress/integration/examples/element.test.js

@ -0,0 +1,42 @@
/// <reference types="cypress" />
context('Actions', () => {
beforeEach(() => {
cy.visit('http://localhost:8000/')
cy.get('div.ant-modal-confirm-btns > button:nth-child(1)').click({ force: true })
})
function moveNode(selector, x, y) {
cy.get(selector).eq(0)
.trigger('mousedown', { which: 1, force: true })
.trigger('mousemove', { clientX: x, clientY: y, force: true })
.trigger('mouseup', { force: true })
}
// 节点/边操作测试
it('添加 开始/分支/处理 节点', () => {
const DEFAULT_COUNT = 1
moveNode('svg > g > g.x6-graph-svg-stage > g:nth-child(1) > circle', 300, 150)
moveNode('svg > g > g.x6-graph-svg-stage > g:nth-child(2) > polygon', 400, 150)
moveNode('svg > g > g.x6-graph-svg-stage > g:nth-child(3) > rect', 500, 150)
cy.get('svg> g > g.x6-graph-svg-stage circle').its('length').should('be.gt', DEFAULT_COUNT)
cy.get('svg> g > g.x6-graph-svg-stage polygon').its('length').should('be.gt', DEFAULT_COUNT)
cy.get('svg> g > g.x6-graph-svg-stage rect').its('length').should('be.gt', DEFAULT_COUNT)
})
it('边连线 & 删除节点/边', () => {
moveNode('svg > g > g.x6-graph-svg-stage > g:nth-child(1) > circle', 300, 150)
moveNode('svg > g > g.x6-graph-svg-stage > g:nth-child(2) > polygon', 400, 150)
moveNode('svg > g > g.x6-graph-svg-stage > g:nth-child(3) > rect', 500, 150)
const node1 = cy.get('svg > g > g.x6-graph-svg-stage > g:nth-child(1)').eq(1)
const node2 = cy.get('svg > g > g.x6-graph-svg-stage > g:nth-child(2)').eq(1)
const node3 = cy.get('svg > g > g.x6-graph-svg-stage > g:nth-child(3)').eq(1)
node1.click('right', { force: true }).trigger('mousemove', { clientX: node2.clientX, clientY: node2.clientY, force: true }).trigger('mouseup', { force: true })
node2.click('right', { force: true }).trigger('mousemove', { clientX: node3.clientX, clientY: node3.clientY, force: true }).trigger('mouseup', { force: true })
cy.get('svg path:nth-child(1)').should('exist')
cy.get('svg path:nth-child(1)').eq(1).click({ force: true })
cy.get('body').type('{del}')
cy.get('svg path:nth-child(1)').eq(1).click({ force: true })
cy.get('body').type('{del}')
})
})

100
cypress/integration/examples/tools.test.js

@ -0,0 +1,100 @@
/// <reference types="cypress" />
// 工具栏测试
context('Actions', () => {
const toolSelector = (i, j, k) => {
if (k) {
return `.index-module_container__1D841 > :nth-child(${i}) > :nth-child(${j})> :nth-child(${k})`
} else {
return `.index-module_container__1D841 > :nth-child(${i}) > :nth-child(${j})`
}
}
beforeEach(() => {
cy.visit('http://localhost:8000/')
cy.get('div.ant-modal-confirm-btns > button:nth-child(1)').click({ force: true })
})
function moveNode(selector, x, y) {
cy.get(selector).eq(0)
.trigger('mousedown', { which: 1, force: true })
.trigger('mousemove', { clientX: x, clientY: y, force: true })
.trigger('mouseup', { force: true })
}
// 第一组
it('保存', () => {
const save = cy.get(toolSelector(1, 1))
})
it('适配窗口', () => {
const fitWindow = cy.get(toolSelector(1, 2))
})
it('撤销', () => {
const undo = cy.get(toolSelector(1, 3))
const node = 'svg > g > g.x6-graph-svg-stage > g:nth-child(1)'
moveNode(node, 350, 300)
moveNode(node, 350, 300)
undo.click({ multiple: true, force: true })
undo.click({ multiple: true, force: true })
cy.get(node).should('have.length', 2)
})
it('重做', () => {
const redo = cy.get(toolSelector(1, 4))
const node = 'svg > g > g.x6-graph-svg-stage > g:nth-child(1)'
moveNode(node, 350, 300)
redo.click()
redo.click()
cy.get(node).should('have.length', 2)
})
// 第二组
it('缩小', () => {
const zoomOut = cy.get(toolSelector(2, 1, 1))
})
it('放大', () => {
const zoomIn = cy.get(toolSelector(2, 1, 2))
})
// 第三组
it('修改字号', () => {
const fontSize = cy.get(toolSelector(3, 1))
})
it('修改字重', () => {
const fontWeight = cy.get(toolSelector(3, 2))
})
it('修改斜体', () => {
const italic = cy.get(toolSelector(3, 3))
})
it('修改下划线', () => {
const underline = cy.get(toolSelector(3, 4))
})
// 第四组
it('修改文字颜色', () => {
const textColor = cy.get(toolSelector(4, 1))
})
it('修改背景颜色', () => {
const bgColor = cy.get(toolSelector(4, 2))
})
it('修改边框颜色', () => {
const borderColor = cy.get(toolSelector(4, 3))
})
it('修改线条样式', () => {
const lineStyle = cy.get(toolSelector(4, 4))
})
// 第五组
it('修改水平方向对齐', () => {
const align = cy.get(toolSelector(5, 1))
})
it('修改垂直方向对齐', () => {
const vertical = cy.get(toolSelector(5, 2))
})
// 第六组
it('修改层级置顶', () => {
const top = cy.get(toolSelector(6, 1))
})
it('修改层级置底', () => {
const bottom = cy.get(toolSelector(6, 2))
})
})

41
example/mock/api.ts

@ -0,0 +1,41 @@
const path = require('path');
const low = require('lowdb');
const FileSync = require('lowdb/adapters/FileSync');
const adapter = new FileSync(path.join(__dirname, './db.json'));
const db = low(adapter);
const queryGraph = (req: any, res: any) => {
const { projectId = 'default' } = req.body;
const data = db.get(projectId).value() || [];
res.send({ status: 200, code: 0, success: true, data: { cells: data } });
};
const modifyGraph = (req: any, res: any) => {
const { projectId = 'default', actions = [] } = req.body;
const projectData = db.get(projectId).value() || [];
actions.forEach((action: any) => {
const { data, actionType } = action;
if (actionType === 'create') {
projectData.push(data);
} else if (actionType === 'update') {
const foundIdx = projectData.findIndex((item: any) => item.id === data.id);
if (foundIdx > -1) {
projectData[foundIdx] = data;
}
} else if (actionType === 'remove') {
const foundIdx = projectData.findIndex((item: any) => item.id === data.id);
if (foundIdx > -1) {
projectData.splice(foundIdx, 1);
}
}
});
db.set(projectId, projectData).write();
res.send({ status: 200, code: 0, success: true, data: [] });
};
export default {
'POST /api/queryGraph': queryGraph,
'POST /api/modifyGraph': modifyGraph,
};

336
example/mock/db.json

@ -0,0 +1,336 @@
{
"default": [
{
"position": {
"x": 420,
"y": 90
},
"size": {
"width": 80,
"height": 40
},
"shape": "imove-behavior",
"data": {
"label": "处理",
"configSchema": "{\n \n}",
"configData": {},
"dependencies": "{\n \n}",
"code": "export default async function(ctx) {\n \n}"
},
"ports": {
"groups": {
"top": {
"position": "top",
"attrs": {
"circle": {
"r": 3,
"magnet": true,
"stroke": "#666",
"strokeWidth": 1,
"fill": "#fff"
}
}
},
"right": {
"position": "right",
"attrs": {
"circle": {
"r": 3,
"magnet": true,
"stroke": "#666",
"strokeWidth": 1,
"fill": "#fff"
}
}
},
"bottom": {
"position": "bottom",
"attrs": {
"circle": {
"r": 3,
"magnet": true,
"stroke": "#666",
"strokeWidth": 1,
"fill": "#fff"
}
}
},
"left": {
"position": "left",
"attrs": {
"circle": {
"r": 3,
"magnet": true,
"stroke": "#666",
"strokeWidth": 1,
"fill": "#fff"
}
}
}
},
"items": [
{
"id": "top",
"group": "top"
},
{
"id": "right",
"group": "right"
},
{
"id": "bottom",
"group": "bottom"
},
{
"id": "left",
"group": "left"
}
]
},
"id": "81e87610-c749-4667-8382-00c62be30e2b",
"zIndex": 1
},
{
"position": {
"x": 86,
"y": 70
},
"size": {
"width": 80,
"height": 80
},
"shape": "imove-start",
"data": {
"label": "开始",
"configSchema": "{\n \n}",
"configData": {},
"trigger": "start",
"dependencies": "{\n \n}",
"code": "export default async function(ctx) {\n \n}"
},
"ports": {
"groups": {
"top": {
"position": "top",
"attrs": {
"circle": {
"r": 3,
"magnet": true,
"stroke": "#666",
"strokeWidth": 1,
"fill": "#fff"
}
}
},
"right": {
"position": "right",
"attrs": {
"circle": {
"r": 3,
"magnet": true,
"stroke": "#666",
"strokeWidth": 1,
"fill": "#fff"
}
}
},
"bottom": {
"position": "bottom",
"attrs": {
"circle": {
"r": 3,
"magnet": true,
"stroke": "#666",
"strokeWidth": 1,
"fill": "#fff"
}
}
},
"left": {
"position": "left",
"attrs": {
"circle": {
"r": 3,
"magnet": true,
"stroke": "#666",
"strokeWidth": 1,
"fill": "#fff"
}
}
}
},
"items": [
{
"id": "top",
"group": "top"
},
{
"id": "right",
"group": "right"
},
{
"id": "bottom",
"group": "bottom"
},
{
"id": "left",
"group": "left"
}
]
},
"id": "de868d18-9ec0-4dac-abbe-5cb9e3c20e2f",
"zIndex": 2
},
{
"position": {
"x": 246,
"y": 90
},
"size": {
"width": 80,
"height": 40
},
"shape": "imove-branch",
"data": {
"label": "判断",
"configSchema": "{\n \n}",
"configData": {},
"dependencies": "{\n \n}",
"ports": {
"right": {
"condition": "true"
},
"bottom": {
"condition": "false"
}
},
"code": "export default async function(ctx) {\n return true;\n}"
},
"ports": {
"groups": {
"top": {
"position": "top",
"attrs": {
"circle": {
"r": 3,
"magnet": true,
"stroke": "#666",
"strokeWidth": 1,
"fill": "#fff"
}
}
},
"right": {
"zIndex": 999999999,
"position": "right",
"attrs": {
"circle": {
"r": 3,
"magnet": true,
"stroke": "#666",
"strokeWidth": 1,
"fill": "#fff"
}
},
"label": {
"position": "right"
}
},
"bottom": {
"zIndex": 999999999,
"position": "bottom",
"attrs": {
"circle": {
"r": 3,
"magnet": true,
"stroke": "#666",
"strokeWidth": 1,
"fill": "#fff"
}
},
"label": {
"position": "bottom"
}
},
"left": {
"position": "left",
"attrs": {
"circle": {
"r": 3,
"magnet": true,
"stroke": "#666",
"strokeWidth": 1,
"fill": "#fff"
}
}
}
},
"items": [
{
"id": "top",
"group": "top"
},
{
"id": "right",
"group": "right",
"attrs": {
"text": {
"text": ""
}
}
},
{
"id": "bottom",
"group": "bottom",
"attrs": {
"text": {
"text": "否"
}
}
},
{
"id": "left",
"group": "left"
}
]
},
"id": "a6da6684-96c8-4595-bec6-a94122b61e30",
"zIndex": 3
},
{
"shape": "edge",
"id": "cbcbd0ea-4a2a-4d2a-8135-7b4b7d7ec50d",
"source": {
"cell": "de868d18-9ec0-4dac-abbe-5cb9e3c20e2f",
"port": "right"
},
"target": {
"cell": "a6da6684-96c8-4595-bec6-a94122b61e30",
"port": "left"
},
"zIndex": 4
},
{
"shape": "edge",
"id": "ea10b718-1c66-4a3d-b29c-b0b20e6bba11",
"source": {
"cell": "a6da6684-96c8-4595-bec6-a94122b61e30",
"port": "right"
},
"target": {
"cell": "81e87610-c749-4667-8382-00c62be30e2b",
"port": "left"
},
"zIndex": 5,
"labels": [
{
"attrs": {
"label": {
"text": "是"
}
}
}
]
}
]
}

13031
package-lock.json

File diff suppressed because it is too large

3
package.json

@ -50,6 +50,7 @@
"jest-environment-jsdom-fourteen": "^1.0.1",
"jest-puppeteer": "^4.4.0",
"lerna": "^3.20.2",
"lowdb": "^1.0.0",
"prettier": "^2.0.4",
"puppeteer": "^5.5.0",
"react-app-polyfill": "^2.0.0",
@ -77,4 +78,4 @@
"umi": "^3.2.2",
"yorkie": "^2.0.0"
}
}
}

54
packages/core/src/mods/header/export/exportModal.tsx

@ -0,0 +1,54 @@
import React from 'react';
import 'antd/es/modal/style';
import styles from './index.module.less';
import { Modal } from 'antd';
import { Graph } from '@antv/x6';
import { base64Url2Blob, downloadFile } from '../../../utils';
interface IExportModalProps {
flowChart: Graph;
visible: boolean;
onClose: () => void;
}
const ExportModal: React.FC<IExportModalProps> = (props) => {
const { flowChart, visible, onClose } = props;
const onExportDSL = () => {
const dsl = JSON.stringify(flowChart.toJSON(), null, 2);
const blob = new Blob([dsl], { type: 'text/plain' });
downloadFile('imove.dsl.json', blob);
};
const onExportFlowChart = () => {
flowChart.toJPEG((dataUri: string) => {
const blob = base64Url2Blob(dataUri);
downloadFile('flowChart.png', blob);
});
};
return (
<Modal
className={styles.editModal}
visible={visible}
footer={null}
title={'导出'}
onOk={onClose}
onCancel={onClose}
>
<div className={styles.modalContent}>
<div className={styles.downloadWrap} onClick={onExportDSL}>
<img src="//gw.alicdn.com/tfs/TB1FaTkuqNj0u4jSZFyXXXgMVXa-128-128.png" />
<span>DSL</span>
</div>
<div className={styles.downloadWrap} onClick={onExportFlowChart}>
<img src="//gw.alicdn.com/tfs/TB1WZeT4Hr1gK0jSZFDXXb9yVXa-128-128.png" />
<span></span>
</div>
</div>
</Modal>
);
};
export default ExportModal;

27
packages/core/src/mods/header/export/index.module.less

@ -0,0 +1,27 @@
.container {
margin-left: 20px;
}
.modalContent {
display: flex;
flex-direction: row;
justify-content: space-around;
width: 100%;
padding: 30px 0;
.downloadWrap {
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
padding: 15px 20px 5px;
border: 0.5px solid #EFEFEF;
&:hover {
cursor: pointer;
background-color: #EFEFEF;
}
}
}

33
packages/core/src/mods/header/export/index.tsx

@ -0,0 +1,33 @@
import React, {useState} from 'react';
import styles from './index.module.less';
import { Button } from 'antd';
import { Graph } from '@antv/x6';
import ExportModal from './exportModal';
interface IProps {
flowChart: Graph;
}
const Export: React.FC<IProps> = props => {
const {flowChart} = props;
const [modalVisible, setModalVisible] = useState<boolean>(false);
const onOpenModal = () => setModalVisible(true);
const onCloseModal = () => setModalVisible(false);
return (
<div className={styles.container}>
<Button type={'primary'} size={'small'} onClick={onOpenModal}></Button>
<ExportModal
flowChart={flowChart}
visible={modalVisible}
onClose={onCloseModal}
/>
</div>
);
};
export default Export;

2
packages/core/src/mods/header/index.tsx

@ -3,6 +3,7 @@ import React from 'react';
import styles from './index.module.less';
import { Graph } from '@antv/x6';
import Export from './export';
import ImportDSL from './importDSL';
import ConnectStatus from './connectStatus';
@ -16,6 +17,7 @@ const Header: React.FC<IProps> = (props) => {
<div className={styles.container}>
<span className={styles.titleText}>iMove</span>
<div className={styles.widgets}>
<Export flowChart={flowChart} />
<ImportDSL flowChart={flowChart} />
<ConnectStatus flowChart={flowChart} />
</div>

20
packages/core/src/utils/index.ts

@ -36,3 +36,23 @@ const parseConfig = {
export const parseQuery = (): { [key: string]: any } => {
return parse(location.search, parseConfig);
};
export const base64Url2Blob = (base64: string) => {
const bytes = window.atob(base64.split(',')[1]);
const buffer = new ArrayBuffer(bytes.length);
const arr = new Uint8Array(buffer);
for (let i = 0; i < bytes.length; i++) {
arr[i] = bytes.charCodeAt(i);
}
return new Blob([arr], { type: 'image/png' });
};
export const downloadFile = (fileName: string, blob: Blob) => {
const elem = document.createElement('a');
elem.href = URL.createObjectURL(blob);
elem.download = fileName;
document.body.appendChild(elem);
elem.click();
URL.revokeObjectURL(elem.href);
document.body.removeChild(elem);
};

Loading…
Cancel
Save