├── .editorconfig
├── .env.development
├── .env.production
├── .eslintignore
├── .eslintrc.js
├── .github
└── workflows
│ └── node.js.yml
├── .gitignore
├── .npmrc
├── .prettierignore
├── .prettierrc.js
├── .stylelintrc.js
├── README.md
├── index.html
├── package.json
├── pnpm-lock.yaml
├── public
├── 404.html
├── demo.png
└── favicon.svg
├── src
├── App.vue
├── assets
│ ├── iconfont
│ │ └── iconfont.js
│ └── img
│ │ └── remove.svg
├── components.d.ts
├── components
│ ├── Control
│ │ └── index.vue
│ ├── DndPanel
│ │ ├── icons.ts
│ │ └── index.vue
│ └── Icon
│ │ └── index.ts
├── extensions
│ ├── bpmn
│ │ ├── base
│ │ │ └── BpmnBaseNode.ts
│ │ ├── card
│ │ │ ├── BaseCardView.ts
│ │ │ └── NodeCard.vue
│ │ ├── constant.ts
│ │ ├── events
│ │ │ ├── EndEvent.ts
│ │ │ └── StartEvent.ts
│ │ ├── flow
│ │ │ └── SequenceFlow.ts
│ │ ├── gateways
│ │ │ └── ExclusiveGateway.ts
│ │ ├── index.ts
│ │ └── tasks
│ │ │ ├── ScriptTask.ts
│ │ │ ├── ServiceTask.ts
│ │ │ └── UserTask.ts
│ ├── bpmnIds.ts
│ ├── edge-menu
│ │ ├── index.css
│ │ └── index.ts
│ ├── index.ts
│ ├── layout
│ │ ├── index.ts
│ │ └── types.ts
│ ├── minielement
│ │ ├── createMiniElement.ts
│ │ ├── elements.ts
│ │ └── index.ts
│ ├── minimap
│ │ └── index.ts
│ └── smoothpolyline-edge
│ │ ├── SmoothPolylineEdge.ts
│ │ └── basic-shape.ts
├── main.ts
├── models
│ └── graph.ts
├── router
│ └── index.ts
├── stores
│ └── graph.ts
├── styles
│ ├── antd-reset.less
│ ├── flow.less
│ ├── styles.less
│ └── theme.less
├── utils
│ ├── algorithm.ts
│ └── config.ts
└── views
│ └── Modeler.vue
├── tsconfig.json
├── typings.d.ts
└── vite.config.ts
/.editorconfig:
--------------------------------------------------------------------------------
1 | root = true
2 |
3 | [*]
4 | charset = utf-8
5 | indent_style = space
6 | indent_size = 2
7 | end_of_line = lf
8 | insert_final_newline = true
9 | trim_trailing_whitespace = true
10 |
--------------------------------------------------------------------------------
/.env.development:
--------------------------------------------------------------------------------
1 | VITE_APP_ENV=development
2 |
--------------------------------------------------------------------------------
/.env.production:
--------------------------------------------------------------------------------
1 | VITE_APP_ENV=production
2 |
--------------------------------------------------------------------------------
/.eslintignore:
--------------------------------------------------------------------------------
1 | assets
2 | public
3 | dist
4 |
--------------------------------------------------------------------------------
/.eslintrc.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | overrides: [
3 | {
4 | files: ['*.vue'],
5 | parser: 'vue-eslint-parser',
6 | parserOptions: {
7 | parser: '@typescript-eslint/parser',
8 | },
9 | rules: {
10 | 'no-undef': 'off',
11 | },
12 | },
13 | ],
14 | extends: ['prettier', 'plugin:vue/vue3-recommended', 'plugin:@typescript-eslint/recommended'],
15 | parser: '@typescript-eslint/parser',
16 | plugins: ['@typescript-eslint'],
17 | rules: {
18 | // TypeScript
19 | '@typescript-eslint/ban-ts-comment': ['error', { 'ts-ignore': 'allow-with-description' }],
20 | '@typescript-eslint/consistent-type-imports': [
21 | 'error',
22 | { prefer: 'type-imports', disallowTypeAnnotations: false },
23 | ],
24 | '@typescript-eslint/consistent-type-definitions': ['error', 'interface'],
25 | '@typescript-eslint/prefer-ts-expect-error': 'error',
26 | '@typescript-eslint/no-require-imports': 'error',
27 | 'no-useless-constructor': 'off',
28 | // off
29 | '@typescript-eslint/consistent-indexed-object-style': 'off',
30 | '@typescript-eslint/naming-convention': 'off',
31 | '@typescript-eslint/explicit-function-return-type': 'off',
32 | '@typescript-eslint/explicit-member-accessibility': 'off',
33 | '@typescript-eslint/no-explicit-any': 'off',
34 | '@typescript-eslint/parameter-properties': 'off',
35 | '@typescript-eslint/no-empty-interface': 'off',
36 | '@typescript-eslint/ban-ts-ignore': 'off',
37 | '@typescript-eslint/no-empty-function': 'off',
38 | '@typescript-eslint/no-non-null-assertion': 'off',
39 | '@typescript-eslint/explicit-module-boundary-types': 'off',
40 | '@typescript-eslint/ban-types': 'off',
41 | '@typescript-eslint/triple-slash-reference': 'off',
42 | // handled by unused-imports/no-unused-imports
43 | '@typescript-eslint/no-unused-vars': 'error',
44 |
45 | // Vue
46 | 'vue/max-attributes-per-line': 'off',
47 | 'vue/no-v-html': 'off',
48 | 'vue/require-prop-types': 'off',
49 | 'vue/require-default-prop': 'off',
50 | 'vue/multi-word-component-names': 'off',
51 | 'vue/prefer-import-from-vue': 'off',
52 | 'vue/no-v-text-v-html-on-component': 'off',
53 | // reactivity transform
54 | 'vue/no-setup-props-destructure': 'off',
55 | 'vue/component-tags-order': [
56 | 'error',
57 | {
58 | order: ['script', 'template', 'style'],
59 | },
60 | ],
61 | 'vue/component-name-in-template-casing': ['error', 'PascalCase'],
62 | 'vue/component-options-name-casing': ['error', 'PascalCase'],
63 | 'vue/custom-event-name-casing': ['error', 'camelCase'],
64 | 'vue/define-macros-order': [
65 | 'error',
66 | {
67 | order: ['defineProps', 'defineEmits'],
68 | },
69 | ],
70 | 'vue/html-comment-content-spacing': [
71 | 'error',
72 | 'always',
73 | {
74 | exceptions: ['-'],
75 | },
76 | ],
77 | 'vue/no-restricted-v-bind': ['error', '/^v-/'],
78 | 'vue/no-useless-v-bind': 'error',
79 | 'vue/no-unused-refs': 'error',
80 | 'vue/padding-line-between-blocks': ['error', 'always'],
81 | 'vue/prefer-separate-static-class': 'error',
82 | // extensions
83 | 'vue/array-bracket-spacing': ['error', 'never'],
84 | 'vue/dot-notation': ['error', { allowKeywords: true }],
85 | 'vue/eqeqeq': ['error', 'smart'],
86 | // 'vue/func-call-spacing': ['off', 'never'],
87 | 'vue/key-spacing': ['error', { beforeColon: false, afterColon: true }],
88 | 'vue/keyword-spacing': ['error', { before: true, after: true }],
89 | 'vue/no-constant-condition': 'warn',
90 | 'vue/no-empty-pattern': 'error',
91 | 'vue/no-extra-parens': ['error', 'functions'],
92 | 'vue/no-irregular-whitespace': 'error',
93 | 'vue/no-loss-of-precision': 'error',
94 | 'vue/no-restricted-syntax': ['error', 'DebuggerStatement', 'LabeledStatement', 'WithStatement'],
95 | 'vue/no-sparse-arrays': 'error',
96 | 'vue/object-curly-newline': ['error', { multiline: true, consistent: true }],
97 | 'vue/object-curly-spacing': ['error', 'always'],
98 | 'vue/object-property-newline': ['error', { allowMultiplePropertiesPerLine: true }],
99 | 'vue/object-shorthand': [
100 | 'error',
101 | 'always',
102 | {
103 | ignoreConstructors: false,
104 | avoidQuotes: true,
105 | },
106 | ],
107 | 'vue/operator-linebreak': ['error', 'before'],
108 | 'vue/prefer-template': 'error',
109 | 'vue/quote-props': ['error', 'consistent-as-needed'],
110 | 'vue/space-in-parens': ['error', 'never'],
111 | 'vue/space-infix-ops': 'error',
112 | 'vue/space-unary-ops': ['error', { words: true, nonwords: false }],
113 | 'vue/template-curly-spacing': 'error',
114 |
115 | // conflict with prettier
116 | 'vue/html-self-closing': 'off',
117 | 'vue/html-closing-bracket-newline': 'off',
118 | 'vue/html-closing-bracket-spacing': 'off',
119 | 'vue/html-end-tags': 'off',
120 | 'vue/html-indent': 'off',
121 | 'vue/html-quotes': 'off',
122 | 'vue/singleline-html-element-content-newline': 'off',
123 | },
124 | }
125 |
--------------------------------------------------------------------------------
/.github/workflows/node.js.yml:
--------------------------------------------------------------------------------
1 | # This workflow will do a clean installation of node dependencies, cache/restore them, build the source code and run tests across different versions of node
2 | # For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-nodejs
3 |
4 | name: Node.js CI
5 |
6 | on:
7 | push:
8 | branches: [ "main" ]
9 | pull_request:
10 | branches: [ "main" ]
11 |
12 | jobs:
13 | build:
14 |
15 | runs-on: ubuntu-latest
16 |
17 | steps:
18 | - uses: actions/checkout@v3
19 | - name: Use Node.js 18.x
20 | uses: actions/setup-node@v3
21 | with:
22 | node-version: '18.x'
23 |
24 | - name: Install pnpm
25 | uses: pnpm/action-setup@v2.2.4
26 | with:
27 | version: 8
28 |
29 | - name: Get pnpm store directory
30 | id: pnpm-cache
31 | shell: bash
32 | run: |
33 | echo "STORE_PATH=$(pnpm store path | tr -d '\n')" >> $GITHUB_OUTPUT
34 |
35 | - name: Setup pnpm cache
36 | uses: actions/cache@v3
37 | with:
38 | path: ${{ steps.pnpm-cache.outputs.STORE_PATH }}
39 | key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }}
40 | restore-keys: |
41 | ${{ runner.os }}-pnpm-store-
42 |
43 | - name: Install my packages
44 | run: pnpm install
45 |
46 | - name: Build my App
47 | run: pnpm build && touch ./dist/.nojekyll
48 |
49 | - name: Deploy
50 | uses: JamesIves/github-pages-deploy-action@v4.3.3
51 | with:
52 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
53 | BRANCH: gh-pages # The branch the action should deploy to.
54 | FOLDER: dist # The folder the action should deploy.
55 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | .DS_Store
2 | node_modules
3 | /dist
4 | vue.config.js
5 | vite.config.js
6 |
7 | # local env files
8 | .env.local
9 | .env.*.local
10 |
11 | # Log files
12 | npm-debug.log*
13 | yarn-debug.log*
14 | yarn-error.log*
15 | pnpm-debug.log*
16 |
17 | # Editor directories and files
18 | .idea
19 | .vscode
20 | *.suo
21 | *.ntvs*
22 | *.njsproj
23 | *.sln
24 | *.sw?
25 |
--------------------------------------------------------------------------------
/.npmrc:
--------------------------------------------------------------------------------
1 | public-hoist-pattern[]=*@babel*
2 | public-hoist-pattern[]=*eslint*
3 | public-hoist-pattern[]=esbuild
4 | registry=https://registry.npmmirror.com/
5 |
--------------------------------------------------------------------------------
/.prettierignore:
--------------------------------------------------------------------------------
1 | node_modules
2 | dist
3 | pnpm-lock.yaml
4 | assets
5 | public
6 |
--------------------------------------------------------------------------------
/.prettierrc.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | semi: false,
3 | singleQuote: true,
4 | trailingComma: 'all',
5 | printWidth: 100,
6 | proseWrap: 'never',
7 | endOfLine: 'lf',
8 | pluginSearchDirs: ['./node_modules/.pnpm', './node_modules'],
9 | plugins: [
10 | 'prettier-plugin-organize-imports',
11 | 'prettier-plugin-packagejson',
12 | 'prettier-plugin-two-style-order',
13 | ],
14 | }
15 |
--------------------------------------------------------------------------------
/.stylelintrc.js:
--------------------------------------------------------------------------------
1 | 'use strict'
2 | /** @format */
3 | module.exports = {
4 | extends: [
5 | 'stylelint-config-standard',
6 | 'stylelint-config-prettier',
7 | 'stylelint-config-recommended-vue',
8 | ],
9 | overrides: [
10 | {
11 | files: ['**/*.vue'],
12 | customSyntax: 'postcss-html',
13 | },
14 | {
15 | files: ['**/*.less'],
16 | customSyntax: 'postcss-less',
17 | },
18 | ],
19 | plugins: ['stylelint-declaration-block-no-ignored-properties'],
20 | rules: {
21 | 'no-descending-specificity': null,
22 | 'function-url-quotes': 'always',
23 | 'selector-attribute-quotes': 'always',
24 | 'font-family-no-missing-generic-family-keyword': null,
25 | 'plugin/declaration-block-no-ignored-properties': true,
26 | 'unit-no-unknown': [true, { ignoreUnits: ['rpx'] }],
27 | // webcomponent
28 | 'selector-type-no-unknown': null,
29 | 'value-keyword-case': ['lower', { ignoreProperties: ['composes'] }],
30 | 'no-empty-source': null,
31 | },
32 | ignoreFiles: ['**/*.js', '**/*.jsx', '**/*.tsx', '**/*.ts'],
33 | }
34 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # Logicflow Demo
2 |
3 | 一个[LogicFlow](https://github.com/didi/LogicFlow)的 Vue3 使用案例,使用基础的 bpmn 元素作为节点,支持一键布局,支持 Vue 组件实现节点卡片,并实现了更简洁的轮廓小地图。
4 |
5 | [线上地址](http://logicflow.meiling.fun/)
6 |
7 | 
8 |
9 | ## 特性
10 |
11 | - [x] 基于 dagre 实现自动布局
12 | - [x] 自定义 Vue 组件作为节点
13 | - [x] 实现 [react-flow](https://reactflow.dev/docs/api/plugin-components/minimap/) 风格的小地图
14 | - [x] 自定义连线规则
15 | - [x] 基于`PolylineEdge`实现转折点带弧度的折线
16 |
17 | ## 开发
18 |
19 | ```
20 | pnpm install
21 | ```
22 |
23 | ```
24 | pnpm start
25 | ```
26 |
--------------------------------------------------------------------------------
/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 | <%- title %>
9 |
10 |
32 |
33 |
34 |
39 |
40 |
41 |
42 |
43 |
44 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "logicflow-demo",
3 | "version": "0.0.1",
4 | "private": true,
5 | "scripts": {
6 | "build": "vite build",
7 | "dev": "vite",
8 | "postinstall": "npx simple-git-hooks",
9 | "lint:js": "eslint --fix",
10 | "lint:style": "stylelint --fix \"src/**/*.(css|less|vue)\"",
11 | "prettier": "prettier --write \"src/**/*.{js,ts,css,less,vue,md,json}\"",
12 | "preview": "vite preview",
13 | "start": "vite"
14 | },
15 | "simple-git-hooks": {
16 | "pre-commit": "npx lint-staged"
17 | },
18 | "lint-staged": {
19 | "**/*.{css,less}": "npm run lint:style",
20 | "**/*.{js,ts,vue}": "npm run lint:js",
21 | "**/*.{js,ts.vue,css,less,md,json}": [
22 | "prettier --write"
23 | ]
24 | },
25 | "browserslist": [
26 | "> 1%",
27 | "last 2 versions",
28 | "not dead"
29 | ],
30 | "dependencies": {
31 | "@ant-design/colors": "^7.0.0",
32 | "@ant-design/icons-vue": "^6.1.0",
33 | "@logicflow/core": "^1.2.15",
34 | "@logicflow/extension": "^1.2.16",
35 | "@vueuse/core": "^10.1.2",
36 | "@vueuse/integrations": "^9.13.0",
37 | "ant-design-vue": "^3.2.16",
38 | "core-js": "^3.6.5",
39 | "dagre": "^0.8.5",
40 | "ids": "^1.0.0",
41 | "lodash-es": "^4.17.21",
42 | "normalize.css": "^8.0.1",
43 | "pinia": "^2.0.33",
44 | "vue": "^3.2.47",
45 | "vue-router": "^4.1.6",
46 | "vuex": "^4.1.0"
47 | },
48 | "devDependencies": {
49 | "@types/dagre": "^0.7.48",
50 | "@types/lodash": "^4.14.192",
51 | "@types/node": "^18.15.10",
52 | "@typescript-eslint/eslint-plugin": "^5.59.5",
53 | "@typescript-eslint/parser": "^5.59.5",
54 | "@vitejs/plugin-vue": "^4.1.0",
55 | "@vue/compiler-sfc": "^3.2.47",
56 | "eslint": "^8.43.0",
57 | "eslint-config-prettier": "^8.8.0",
58 | "eslint-plugin-prettier": "^4.2.1",
59 | "eslint-plugin-vue": "^9.11.1",
60 | "jsonlint": "^1.6.3",
61 | "less": "^4.1.3",
62 | "lint-staged": "^13.2.2",
63 | "postcss-less": "^6.0.0",
64 | "prettier": "^2.8.8",
65 | "prettier-plugin-organize-imports": "^3.2.2",
66 | "prettier-plugin-packagejson": "^2.4.3",
67 | "prettier-plugin-two-style-order": "^1.0.1",
68 | "simple-git-hooks": "^2.8.1",
69 | "stylelint": "^15.6.1",
70 | "stylelint-config-html": "^1.1.0",
71 | "stylelint-config-prettier": "^9.0.5",
72 | "stylelint-config-recommended-vue": "^1.4.0",
73 | "stylelint-config-standard": "^33.0.0",
74 | "stylelint-declaration-block-no-ignored-properties": "^2.7.0",
75 | "tslib": "^2.5.3",
76 | "unplugin-vue-components": "~0.25.1",
77 | "vite": "^4.2.1",
78 | "vite-plugin-html": "^3.2.0"
79 | }
80 | }
81 |
--------------------------------------------------------------------------------
/public/404.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | Single Page Apps for GitHub Pages
6 |
37 |
38 |
39 |
40 |
41 |
--------------------------------------------------------------------------------
/public/demo.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/hmilin/logicflow-demo/f039b8e5d2fcc4b0ae248bbb333236213efe6897/public/demo.png
--------------------------------------------------------------------------------
/public/favicon.svg:
--------------------------------------------------------------------------------
1 |
16 |
--------------------------------------------------------------------------------
/src/App.vue:
--------------------------------------------------------------------------------
1 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
--------------------------------------------------------------------------------
/src/assets/iconfont/iconfont.js:
--------------------------------------------------------------------------------
1 | window._iconfont_svg_string_4143572='',function(e){var a=(a=document.getElementsByTagName("script"))[a.length-1],t=a.getAttribute("data-injectcss"),a=a.getAttribute("data-disable-injectsvg");if(!a){var o,c,v,l,i,h=function(a,t){t.parentNode.insertBefore(a,t)};if(t&&!e.__iconfont__svg__cssinject__){e.__iconfont__svg__cssinject__=!0;try{document.write("")}catch(a){console&&console.log(a)}}o=function(){var a,t=document.createElement("div");t.innerHTML=e._iconfont_svg_string_4143572,(t=t.getElementsByTagName("svg")[0])&&(t.setAttribute("aria-hidden","true"),t.style.position="absolute",t.style.width=0,t.style.height=0,t.style.overflow="hidden",t=t,(a=document.body).firstChild?h(t,a.firstChild):a.appendChild(t))},document.addEventListener?~["complete","loaded","interactive"].indexOf(document.readyState)?setTimeout(o,0):(c=function(){document.removeEventListener("DOMContentLoaded",c,!1),o()},document.addEventListener("DOMContentLoaded",c,!1)):document.attachEvent&&(v=o,l=e.document,i=!1,m(),l.onreadystatechange=function(){"complete"==l.readyState&&(l.onreadystatechange=null,n())})}function n(){i||(i=!0,v())}function m(){try{l.documentElement.doScroll("left")}catch(a){return void setTimeout(m,50)}n()}}(window);
--------------------------------------------------------------------------------
/src/assets/img/remove.svg:
--------------------------------------------------------------------------------
1 |
4 |
--------------------------------------------------------------------------------
/src/components.d.ts:
--------------------------------------------------------------------------------
1 | /* eslint-disable */
2 | /* prettier-ignore */
3 | // @ts-nocheck
4 | // Generated by unplugin-vue-components
5 | // Read more: https://github.com/vuejs/core/pull/3399
6 | export {}
7 |
8 | declare module 'vue' {
9 | export interface GlobalComponents {
10 | AConfigProvider: typeof import('ant-design-vue/es')['ConfigProvider']
11 | ADropdown: typeof import('ant-design-vue/es')['Dropdown']
12 | AMenu: typeof import('ant-design-vue/es')['Menu']
13 | AMenuItem: typeof import('ant-design-vue/es')['MenuItem']
14 | Control: typeof import('./components/Control/index.vue')['default']
15 | DndPanel: typeof import('./components/DndPanel/index.vue')['default']
16 | RouterLink: typeof import('vue-router')['RouterLink']
17 | RouterView: typeof import('vue-router')['RouterView']
18 | }
19 | }
20 |
--------------------------------------------------------------------------------
/src/components/Control/index.vue:
--------------------------------------------------------------------------------
1 |
64 |
65 |
66 |
71 |
72 |
73 |
106 |
--------------------------------------------------------------------------------
/src/components/DndPanel/icons.ts:
--------------------------------------------------------------------------------
1 | const borderClassName = 'panel-icon-border'
2 |
3 | export const StartIcon = `
7 | `
8 |
9 | export const UserTaskIcon = `
10 |
14 | `
15 |
16 | export const ServiceTaskIcon = `
17 |
28 | `
29 |
30 | export const ExclusiveGatewayIcon = ``
34 |
35 | export const EndIcon = `
39 | `
40 |
41 | export const ScriptTaskIcon = ``
52 |
--------------------------------------------------------------------------------
/src/components/DndPanel/index.vue:
--------------------------------------------------------------------------------
1 |
70 |
71 |
72 |
73 |
74 |
75 |
76 | {{ item.text }}
77 |
78 |
79 |
80 |
81 |
82 |
119 |
--------------------------------------------------------------------------------
/src/components/Icon/index.ts:
--------------------------------------------------------------------------------
1 | import { createFromIconfontCN } from '@ant-design/icons-vue'
2 | import IconfontUrl from '~/assets/iconfont/iconfont.js?url'
3 |
4 | const Icon = createFromIconfontCN({
5 | scriptUrl: IconfontUrl,
6 | })
7 |
8 | export default Icon
9 |
--------------------------------------------------------------------------------
/src/extensions/bpmn/base/BpmnBaseNode.ts:
--------------------------------------------------------------------------------
1 | import type { AnchorConfig, BaseNodeModel, ConnectRule } from '@logicflow/core'
2 |
3 | interface BaseNodeDecoratorProps {
4 | /** default true */
5 | isShowAnchor?: boolean
6 | }
7 |
8 | /** 一个类装饰器,定义所有节点的共同属性 */
9 | export default function BaseNodeDecorator(props: BaseNodeDecoratorProps = {}) {
10 | const properties = { isShowAnchor: true, ...props }
11 |
12 | return function (constructor: T) {
13 | return class extends constructor {
14 | initNodeData(data: any) {
15 | super.initNodeData(data)
16 | }
17 | setIsShowAnchor() {
18 | /** 锚点常显 */
19 | this.isShowAnchor = properties.isShowAnchor
20 | }
21 | getConnectedSourceRules(): ConnectRule[] {
22 | const rules = super.getConnectedSourceRules()
23 | // 自己不能连自己
24 | rules.push({
25 | message: '',
26 | validate(source?, target?) {
27 | return source?.id !== target?.id
28 | },
29 | })
30 | rules.push({
31 | message: '左连接点只能作为终点',
32 | validate(source?, target?, sourceAnchor?) {
33 | return !isLeftAnchor(sourceAnchor?.id)
34 | },
35 | })
36 | // 限制一个右锚点只能出一条线
37 | rules.push({
38 | message: '一个连接点只能有一条连线',
39 | validate: (source, target, sourceAnchor?: AnchorConfig) => {
40 | return this.graphModel.edges.find((edge) => edge.sourceAnchorId === sourceAnchor?.id)
41 | ? false
42 | : true
43 | },
44 | })
45 | return rules
46 | }
47 | getConnectedTargetRules(): ConnectRule[] {
48 | const rules = super.getConnectedTargetRules()
49 | rules.push({
50 | message: '右连接点只能作为起点',
51 | validate(source?, target?, sourceAnchor?, targetAnchor?) {
52 | return isLeftAnchor(targetAnchor?.id)
53 | },
54 | })
55 | // 限制一个左锚点只能入一条线
56 | rules.push({
57 | message: '一个连接点只能有一条连线',
58 | validate: (source, target, sourceAnchor?: AnchorConfig, targetAnchor?: AnchorConfig) => {
59 | return this.graphModel.edges.find((edge) => edge.targetAnchorId === targetAnchor?.id)
60 | ? false
61 | : true
62 | },
63 | })
64 | return rules
65 | }
66 | }
67 | }
68 | }
69 |
70 | function isLeftAnchor(id?: string) {
71 | return id?.endsWith('left') ?? false
72 | }
73 |
--------------------------------------------------------------------------------
/src/extensions/bpmn/card/BaseCardView.ts:
--------------------------------------------------------------------------------
1 | import type { BaseNodeModel } from '@logicflow/core'
2 | import { HtmlNode } from '@logicflow/core'
3 | import type { App, DefineComponent, VNode } from 'vue'
4 | import { createApp, h } from 'vue'
5 | import { getNodeText } from '../constant'
6 | import NodeCard from './NodeCard.vue'
7 |
8 | /** 通用的卡片节点 */
9 | export default class BaseCardView extends HtmlNode {
10 | private isMounted = false
11 | vnode?: VNode
12 | app?: App
13 | constructor(props: any) {
14 | super(props)
15 | this.isMounted = false // 用个属性来避免重复挂载
16 | this.vnode = h(NodeCard as unknown as DefineComponent, {
17 | properties: props.model.getProperties(),
18 | text: getNodeText(props.model.getProperties()),
19 | type: props.model.getData().type,
20 | onEdit: () => {
21 | console.log('编辑节点')
22 | },
23 | onDelete: () => {
24 | props.graphModel.deleteNode(props.model.getData().id)
25 | },
26 | onClone: () => {
27 | const nodeModel = props.graphModel.cloneNode(this.props.model.id) as BaseNodeModel
28 | // 更新ID
29 | props.graphModel.getNodeModelById(nodeModel.id).setProperties({
30 | ...nodeModel.properties,
31 | name: `${nodeModel.properties.name}_copy`,
32 | })
33 | },
34 | })
35 | this.app = createApp({
36 | render: () => this.vnode,
37 | })
38 | }
39 |
40 | get title() {
41 | return getNodeText(this.props.model.getProperties())
42 | }
43 |
44 | setHtml(rootEl: HTMLElement) {
45 | if (!this.isMounted) {
46 | this.isMounted = true
47 | rootEl.parentElement!.setAttribute('class', 'node-filter')
48 | const node = document.createElement('div')
49 | node.style.width = `${this.props.model.width}px`
50 | node.style.height = `${this.props.model.height}px`
51 | rootEl.appendChild(node)
52 | this.app?.mount(node)
53 | } else {
54 | this.vnode!.component!.props.properties = this.props.model.getProperties() // properties发生变化后,将properties作为props传给vue组件
55 | this.vnode!.component!.props.text = this.title
56 | }
57 | }
58 | }
59 |
--------------------------------------------------------------------------------
/src/extensions/bpmn/card/NodeCard.vue:
--------------------------------------------------------------------------------
1 |
10 |
11 |
12 |
13 |
18 |
19 |
{{ props.properties.description }}
20 |
21 |
22 |
23 |
24 |
25 |
26 | 删除
27 | 复制
28 |
29 |
30 |
31 |
32 |
33 |
34 |
35 |
36 |
113 |
--------------------------------------------------------------------------------
/src/extensions/bpmn/constant.ts:
--------------------------------------------------------------------------------
1 | import type { BaseNodeModel } from '@logicflow/core'
2 | import type { Theme } from '@logicflow/core/types/constant/DefaultTheme'
3 |
4 | const fillConfig = {
5 | stroke: '#613EEA',
6 | strokeWidth: 1,
7 | }
8 |
9 | export const theme: Theme = {
10 | rect: {
11 | ...fillConfig,
12 | },
13 | circle: {
14 | r: 40,
15 | ...fillConfig,
16 | },
17 | diamond: {
18 | ...fillConfig,
19 | rx: 40,
20 | ry: 40,
21 | },
22 | polygon: {
23 | ...fillConfig,
24 | rx: 100,
25 | ry: 100,
26 | },
27 | polyline: {
28 | ...fillConfig,
29 | selectedStroke: '#613EEA',
30 | hoverStroke: '#613EEA',
31 | },
32 | bezier: {
33 | ...fillConfig,
34 | selectedStroke: '#613EEA',
35 | hoverStroke: '#613EEA',
36 | },
37 | anchor: {
38 | fill: '#D0D0D0',
39 | stroke: 'none',
40 | hoverStroke: '#613EEA',
41 | },
42 | edgeText: {
43 | fontSize: 12,
44 | color: 'transparent',
45 | },
46 | // 箭头样式
47 | arrow: {
48 | offset: 8,
49 | verticalLength: 4,
50 | },
51 | outline: {
52 | fill: 'none',
53 | strokeWidth: 0,
54 | },
55 | }
56 |
57 | /** 约定的节点类型 */
58 | export enum StepTypes {
59 | Start = '0',
60 | End = '1',
61 | ServiceTask = '2',
62 | ExclusiveGateway = '3',
63 | UserTask = '4',
64 | ScriptTask = '5',
65 | Flow = 'sequenceFlow',
66 | }
67 |
68 | function addEllipsis(text: string, length: number) {
69 | return `${text.substring(0, length)}${text.length > length ? '...' : ''}`
70 | }
71 |
72 | /** 节点展示文本 */
73 | export function getNodeText(node: BaseNodeModel['properties']) {
74 | return `${addEllipsis(node?.name || '', 20)}${
75 | node?.displayName ? `-${addEllipsis(node.displayName, 20)}` : ''
76 | }`
77 | }
78 |
79 | /** 节点宽高 */
80 | export const nodeSize: Record = {
81 | [StepTypes.Start]: {
82 | width: 60,
83 | height: 60,
84 | },
85 | [StepTypes.End]: {
86 | width: 64,
87 | height: 64,
88 | },
89 | [StepTypes.UserTask]: {
90 | width: 280,
91 | height: 148,
92 | },
93 | [StepTypes.ServiceTask]: {
94 | width: 280,
95 | height: 148,
96 | },
97 | [StepTypes.ExclusiveGateway]: {
98 | width: 280,
99 | height: 148,
100 | },
101 | [StepTypes.ScriptTask]: {
102 | width: 280,
103 | height: 148,
104 | },
105 | [StepTypes.Flow]: {
106 | width: 0,
107 | height: 1,
108 | },
109 | }
110 |
111 | export const defaultNodeSize = {
112 | width: 60,
113 | height: 60,
114 | }
115 |
116 | /** 锚点和节点的距离 */
117 | export const anchorOffset = 0
118 | export const anchorRadius = 4
119 |
120 | export const nodeIDPrefixs: Record = {
121 | [StepTypes.Start]: 'Event',
122 | [StepTypes.End]: 'Event',
123 | [StepTypes.ServiceTask]: 'Activity',
124 | [StepTypes.ExclusiveGateway]: 'Gateway',
125 | [StepTypes.ScriptTask]: 'Activity',
126 | [StepTypes.UserTask]: 'Activity',
127 | [StepTypes.Flow]: 'Flow',
128 | }
129 |
--------------------------------------------------------------------------------
/src/extensions/bpmn/events/EndEvent.ts:
--------------------------------------------------------------------------------
1 | import type { GraphModel } from '@logicflow/core'
2 | import { CircleNode, CircleNodeModel, h } from '@logicflow/core'
3 | import { getBpmnId } from '../../bpmnIds'
4 | import BaseNodeDecorator from '../base/BpmnBaseNode'
5 | import { StepTypes, nodeIDPrefixs, nodeSize } from '../constant'
6 |
7 | const radius = nodeSize[StepTypes.End].width / 2
8 | /** 结束节点,在规范基础上新增了调试结果展示的UI */
9 | @BaseNodeDecorator()
10 | class EndEventModel extends CircleNodeModel {
11 | static extendKey = 'EndEventModel'
12 | constructor(data: any, graphModel: GraphModel) {
13 | if (!data.id) {
14 | data.id = `${nodeIDPrefixs[StepTypes.End]}_${getBpmnId()}`
15 | }
16 | super(data, graphModel)
17 | this.setIsShowAnchor()
18 | }
19 |
20 | getConnectedSourceRules() {
21 | const rules = super.getConnectedSourceRules()
22 | const notAsSource = {
23 | message: '结束节点不能作为连线的起点',
24 | validate: () => false,
25 | }
26 | rules.push(notAsSource)
27 | return rules
28 | }
29 |
30 | setAttributes() {
31 | this.r = radius
32 | }
33 |
34 | getDefaultAnchor() {
35 | const { x, y, width, id } = this
36 | return [
37 | {
38 | // x 轴上偏移 size / 2
39 | x: x - width / 2,
40 | y,
41 | id: `${id}_left`,
42 | },
43 | ]
44 | }
45 | }
46 |
47 | class EndEventView extends CircleNode {
48 | static extendKey = 'EndEventView'
49 | getShape() {
50 | const model = this.props.model
51 | const { x, y } = model
52 | const { strokeWidth, fill } = model.getNodeStyle()
53 |
54 | const Icon = h(
55 | 'svg',
56 | {
57 | x: x - 60 / 2 + 18,
58 | y: y - 60 / 2 + 18,
59 | width: 25,
60 | height: 25,
61 | viewBox: '0 0 22 22',
62 | },
63 | h('rect', {
64 | fill: this.props.model.getNodeStyle().stroke,
65 | width: 20,
66 | height: 20,
67 | }),
68 | )
69 | return h(
70 | 'g',
71 | {
72 | class: 'node-filter',
73 | },
74 | [
75 | h('circle', {
76 | cx: x,
77 | cy: y,
78 | fill,
79 | stroke: 'rgba(0, 0, 0, 0.15)',
80 | strokeWidth,
81 | r: radius,
82 | }),
83 | Icon,
84 | ],
85 | )
86 | }
87 | }
88 |
89 | const EndEvent = {
90 | type: StepTypes.End,
91 | view: EndEventView,
92 | model: EndEventModel,
93 | }
94 |
95 | export { EndEventModel, EndEventView }
96 | export default EndEvent
97 |
--------------------------------------------------------------------------------
/src/extensions/bpmn/events/StartEvent.ts:
--------------------------------------------------------------------------------
1 | import type { GraphModel } from '@logicflow/core'
2 | import { h, RectNode, RectNodeModel } from '@logicflow/core'
3 | import { getBpmnId } from '../../bpmnIds'
4 | import BaseNodeDecorator from '../base/BpmnBaseNode'
5 | import { nodeIDPrefixs, nodeSize, StepTypes } from '../constant'
6 |
7 | /** 结束节点 */
8 | @BaseNodeDecorator()
9 | class StartEventModel extends RectNodeModel {
10 | static extendKey = 'StartEventModel'
11 | constructor(data: any, graphModel: GraphModel) {
12 | if (!data.id) {
13 | data.id = `${nodeIDPrefixs[StepTypes.Start]}_${getBpmnId()}`
14 | }
15 | if (!data.text) {
16 | data.text = ''
17 | }
18 | // fix: 不能直接全部加,会导致下载后再次上传,位置错误。
19 | // data.text.y += 40;
20 | super(data, graphModel)
21 | this.setIsShowAnchor()
22 | }
23 |
24 | getConnectedTargetRules() {
25 | const rules = super.getConnectedTargetRules()
26 | const notAsTarget = {
27 | message: '起始节点不能作为连线的终点',
28 | validate: () => false,
29 | }
30 | rules.push(notAsTarget)
31 | return rules
32 | }
33 |
34 | setAttributes() {
35 | this.width = nodeSize[StepTypes.Start].width
36 | this.height = nodeSize[StepTypes.Start].height
37 | }
38 |
39 | // 设置自定义锚点
40 | // 只需要为每个锚点设置相对于节点中心的偏移量
41 | getDefaultAnchor() {
42 | const { x, y, width, id } = this
43 | return [
44 | {
45 | // x 轴上偏移 size / 2
46 | x: x + width / 2,
47 | y,
48 | id: `${id}_right`,
49 | },
50 | ]
51 | }
52 |
53 | getNodeStyle() {
54 | const style = super.getNodeStyle()
55 | style.filter = 'drop-shadow(16px 16px 10px black)'
56 | return style
57 | }
58 | }
59 |
60 | class StartEventView extends RectNode {
61 | static extendKey = 'StartEventNode'
62 |
63 | getLabelShape() {
64 | const model = this.props.model
65 | const { x, y, width, height } = model
66 | const { stroke } = model.getNodeStyle()
67 | return h(
68 | 'svg',
69 | {
70 | x: x - width / 2 + 15,
71 | y: y - height / 2 + 15,
72 | width: 30,
73 | height: 30,
74 | viewBox: '0 0 21 30',
75 | },
76 | h('path', {
77 | fill: stroke,
78 | d: 'M0 27.0725V2.92754C0 1.29297 1.85441 0.348658 3.17634 1.31007L19.776 13.3825C20.8742 14.1812 20.8742 15.8188 19.776 16.6175L3.17634 28.6899C1.8544 29.6513 0 28.707 0 27.0725Z',
79 | }),
80 | )
81 | }
82 | getShape() {
83 | const model = this.props.model
84 | const { x, y, width, height } = model
85 | const { strokeWidth, fill } = model.getNodeStyle()
86 | // todo: 将basic-shape对外暴露,在这里可以直接用。现在纯手写有点麻烦。
87 | return h(
88 | 'g',
89 | {
90 | class: 'node-filter',
91 | },
92 | [
93 | h('rect', {
94 | x: x - width / 2,
95 | y: y - height / 2,
96 | rx: 8,
97 | ry: 8,
98 | fill,
99 | stroke: 'rgba(0, 0, 0, 0.15)',
100 | strokeWidth,
101 | width,
102 | height,
103 | }),
104 | this.getLabelShape(),
105 | ],
106 | )
107 | }
108 | getStateClassName() {
109 | const className = super.getStateClassName()
110 | return `${className} start-node`
111 | }
112 | }
113 |
114 | const StartEvent = {
115 | type: StepTypes.Start,
116 | view: StartEventView,
117 | model: StartEventModel,
118 | }
119 |
120 | export { StartEventModel, StartEventView }
121 | export default StartEvent
122 |
--------------------------------------------------------------------------------
/src/extensions/bpmn/flow/SequenceFlow.ts:
--------------------------------------------------------------------------------
1 | import type { GraphModel } from '@logicflow/core'
2 | import {
3 | SmoothPolylineEdgeModel,
4 | SmoothPolylineEdgeView,
5 | } from '~/extensions/smoothpolyline-edge/SmoothPolylineEdge'
6 | import { getBpmnId } from '../../bpmnIds'
7 | import { StepTypes, nodeIDPrefixs } from '../constant'
8 |
9 | const strokeDefault = '#D0D0D0'
10 | const strokeActive = '#D1BDFF'
11 |
12 | /** 流对象,连线关系 */
13 | class SequenceFlowModel extends SmoothPolylineEdgeModel {
14 | static extendKey = 'SequenceFlowModel'
15 | constructor(data: any, graphModel: GraphModel) {
16 | if (!data.id) {
17 | data.id = `${nodeIDPrefixs[StepTypes.Flow]}_${getBpmnId()}`
18 | }
19 | super(data, graphModel)
20 | }
21 |
22 | getEdgeStyle() {
23 | const style = super.getEdgeStyle()
24 | if (this.isSelected || this.isHovered) {
25 | style.strokeWidth = 3
26 | style.stroke = strokeActive
27 | } else {
28 | style.strokeWidth = 2
29 | style.stroke = strokeDefault
30 | }
31 | style.cursor = 'pointer'
32 | return style
33 | }
34 |
35 | setAttributes() {
36 | this.isAnimation = true
37 | }
38 |
39 | getEdgeAnimationStyle() {
40 | const style = super.getEdgeAnimationStyle()
41 | if (this.isSelected || this.isHovered) {
42 | style.animationName = 'edge-selected'
43 | style.stroke = strokeActive
44 | } else {
45 | style.animationName = 'edge-blur'
46 | style.stroke = strokeDefault
47 | }
48 | style.strokeDasharray = 'none'
49 | style.animationDuration = '0.3s'
50 | style.animationDirection = 'normal'
51 | style.animationIterationCount = '1'
52 | return style
53 | }
54 |
55 | /**
56 | * 重写此方法,使保存数据是能带上锚点数据。
57 | */
58 | getData() {
59 | const data = super.getData()
60 | data.sourceAnchorId = this.sourceAnchorId
61 | data.targetAnchorId = this.targetAnchorId
62 | return data
63 | }
64 |
65 | initEdgeData(data: any): void {
66 | const { x, y } = data.endPoint
67 | // 终点往左边缩小一点
68 | data.endPoint = { x: x - 2, y }
69 | if (data.pointsList) {
70 | data.pointsList[data.pointsList.length - 1] = data.endPoint
71 | }
72 | super.initEdgeData(data)
73 | }
74 | }
75 |
76 | class SequenceFlowView extends SmoothPolylineEdgeView {
77 | constructor() {
78 | super()
79 | }
80 | getEdge() {
81 | const edge = super.getEdge()
82 | return edge
83 | }
84 | }
85 |
86 | const SequenceFlow = {
87 | type: StepTypes.Flow,
88 | view: SequenceFlowView,
89 | model: SequenceFlowModel,
90 | }
91 |
92 | export { SequenceFlowModel, SequenceFlowView }
93 | export default SequenceFlow
94 |
--------------------------------------------------------------------------------
/src/extensions/bpmn/gateways/ExclusiveGateway.ts:
--------------------------------------------------------------------------------
1 | import type { GraphModel } from '@logicflow/core'
2 | import { RectNodeModel } from '@logicflow/core'
3 | import { getBpmnId } from '../../bpmnIds'
4 | import BaseNodeDecorator from '../base/BpmnBaseNode'
5 | import BaseCardView from '../card/BaseCardView'
6 | import { StepTypes, nodeIDPrefixs, nodeSize } from '../constant'
7 |
8 | /**
9 | * 获取锚点坐标位置
10 | * @param {number} x 节点中心X坐标
11 | * @param {number} y 节点中心Y坐标
12 | * @param {number} width 节点中心宽度
13 | */
14 | export const getGatewayAnchor = (
15 | x: number,
16 | y: number,
17 | width: number,
18 | height: number,
19 | id: string,
20 | ) => {
21 | return [
22 | {
23 | x: Math.round(x - width / 2),
24 | y,
25 | id: `${id}_left`,
26 | },
27 | {
28 | x: Math.round(x + width / 2),
29 | y: Math.round(y - height / 6),
30 | id: `${id}_true`,
31 | text: 'true',
32 | },
33 | {
34 | x: Math.round(x + width / 2),
35 | y: Math.round(y + height / 6),
36 | id: `${id}_false`,
37 | text: 'false',
38 | },
39 | ]
40 | }
41 |
42 | /** 条件判断节点 */
43 | @BaseNodeDecorator()
44 | class ExclusiveGatewayModel extends RectNodeModel {
45 | static extendKey = 'ExclusiveGatewayModel'
46 | constructor(data: any, graphModel: GraphModel) {
47 | if (!data.id) {
48 | data.id = `${nodeIDPrefixs[StepTypes.ExclusiveGateway]}_${getBpmnId()}`
49 | }
50 | if (!data.text) {
51 | data.text = ''
52 | }
53 | super(data, graphModel)
54 |
55 | this.setIsShowAnchor()
56 | }
57 | setAttributes() {
58 | this.width = nodeSize[StepTypes.ExclusiveGateway].width
59 | this.height = nodeSize[StepTypes.ExclusiveGateway].height
60 | }
61 |
62 | getDefaultAnchor() {
63 | const { width, height, x, y, id } = this
64 | return getGatewayAnchor(x, y, width, height, id)
65 | }
66 | }
67 |
68 | class ExclusiveGatewayView extends BaseCardView {
69 | static extendKey = 'ExclusiveGatewayNode'
70 |
71 | setHtml(rootEl: HTMLElement) {
72 | super.setHtml(rootEl)
73 | const fragment = document.createDocumentFragment()
74 | rootEl.style.position = 'relactive'
75 | rootEl.style.overflow = 'visible'
76 | const { x, y, width, height } = this.props.model
77 | this.props.model.anchors.forEach((anchor) => {
78 | const anchorTextDom = document.createElement('span')
79 | anchorTextDom.style.color = 'rgba(0, 0, 0, 0.65)'
80 | anchorTextDom.style.position = 'absolute'
81 | anchorTextDom.style.left = `${Math.round(anchor.x - x + width / 2 + 4)}px`
82 | anchorTextDom.style.top = `${Math.round(anchor.y - y + height / 2 - 8)}px`
83 | if (anchor.text) {
84 | anchorTextDom.append(anchor.text as string)
85 | fragment.appendChild(anchorTextDom)
86 | }
87 | })
88 | rootEl.appendChild(fragment)
89 | }
90 | }
91 |
92 | const ExclusiveGateway = {
93 | type: StepTypes.ExclusiveGateway,
94 | view: ExclusiveGatewayView,
95 | model: ExclusiveGatewayModel,
96 | }
97 |
98 | export { ExclusiveGatewayModel, ExclusiveGatewayView }
99 | export default ExclusiveGateway
100 |
--------------------------------------------------------------------------------
/src/extensions/bpmn/index.ts:
--------------------------------------------------------------------------------
1 | import { StepTypes, theme } from './constant'
2 | import EndEvent, { EndEventModel, EndEventView } from './events/EndEvent'
3 | import StartEvent, { StartEventModel, StartEventView } from './events/StartEvent'
4 | import SequenceFlow, { SequenceFlowModel, SequenceFlowView } from './flow/SequenceFlow'
5 | import ExclusiveGateway, {
6 | ExclusiveGatewayModel,
7 | ExclusiveGatewayView,
8 | } from './gateways/ExclusiveGateway'
9 | import ScriptTask, { ScriptTaskModel, ScriptTaskView } from './tasks/ScriptTask'
10 | import ServiceTask, { ServiceTaskModel, ServiceTaskView } from './tasks/ServiceTask'
11 | import UserTask, { UserTaskModel, UserTaskView } from './tasks/UserTask'
12 |
13 | class BpmnElement {
14 | static pluginName = 'BpmnElement'
15 | constructor({ lf }) {
16 | lf.setTheme(theme)
17 | lf.register(StartEvent)
18 | lf.register(EndEvent)
19 | lf.register(ExclusiveGateway)
20 | lf.register(ServiceTask)
21 | lf.register(UserTask)
22 | lf.register(ScriptTask)
23 | // 支持自定义bpmn元素的连线
24 | if (!lf.options.customBpmnEdge) {
25 | lf.register(SequenceFlow)
26 | lf.setDefaultEdgeType(StepTypes.Flow)
27 | }
28 | }
29 | }
30 |
31 | export {
32 | BpmnElement,
33 | EndEventModel,
34 | EndEventView,
35 | ExclusiveGatewayModel,
36 | ExclusiveGatewayView,
37 | ScriptTaskModel,
38 | ScriptTaskView,
39 | SequenceFlowModel,
40 | SequenceFlowView,
41 | ServiceTaskModel,
42 | ServiceTaskView,
43 | StartEventModel,
44 | StartEventView,
45 | UserTaskModel,
46 | UserTaskView,
47 | }
48 |
--------------------------------------------------------------------------------
/src/extensions/bpmn/tasks/ScriptTask.ts:
--------------------------------------------------------------------------------
1 | import type { GraphModel } from '@logicflow/core'
2 | import { RectNodeModel } from '@logicflow/core'
3 | import { getBpmnId } from '../../bpmnIds'
4 | import BaseNodeDecorator from '../base/BpmnBaseNode'
5 | import BaseCardView from '../card/BaseCardView'
6 | import { StepTypes, nodeIDPrefixs, nodeSize } from '../constant'
7 |
8 | /** 数据处理节点 */
9 | @BaseNodeDecorator()
10 | class ScriptTaskModel extends RectNodeModel {
11 | static extendKey = 'ServiceTaskModel'
12 | constructor(data: any, graphModel: GraphModel) {
13 | if (!data.id) {
14 | data.id = `${nodeIDPrefixs[StepTypes.ScriptTask]}_${getBpmnId()}`
15 | }
16 | super(data, graphModel)
17 | this.setIsShowAnchor()
18 | }
19 |
20 | setAttributes() {
21 | this.width = nodeSize[StepTypes.ScriptTask].width
22 | this.height = nodeSize[StepTypes.ScriptTask].height
23 | }
24 |
25 | getDefaultAnchor() {
26 | const { x, y, width, id } = this
27 | return [
28 | {
29 | // x 轴上偏移 size / 2
30 | x: x - width / 2,
31 | y,
32 | id: `${id}_left`,
33 | },
34 | {
35 | // x 轴上偏移 size / 2
36 | x: x + width / 2,
37 | y,
38 | id: `${id}_right`,
39 | },
40 | ]
41 | }
42 | }
43 |
44 | class ScriptTaskView extends BaseCardView {
45 | static extendKey = 'ServiceTaskNode'
46 | }
47 |
48 | const ScriptTask = {
49 | type: StepTypes.ScriptTask,
50 | view: ScriptTaskView,
51 | model: ScriptTaskModel,
52 | }
53 |
54 | export { ScriptTaskView, ScriptTaskModel }
55 | export default ScriptTask
56 |
--------------------------------------------------------------------------------
/src/extensions/bpmn/tasks/ServiceTask.ts:
--------------------------------------------------------------------------------
1 | import type { GraphModel } from '@logicflow/core'
2 | import { RectNodeModel } from '@logicflow/core'
3 | import { getBpmnId } from '../../bpmnIds'
4 | import BaseNodeDecorator from '../base/BpmnBaseNode'
5 | import BaseCardView from '../card/BaseCardView'
6 | import { StepTypes, nodeIDPrefixs, nodeSize } from '../constant'
7 |
8 | /** api调用节点 */
9 | @BaseNodeDecorator()
10 | class ServiceTaskModel extends RectNodeModel {
11 | static extendKey = 'ServiceTaskModel'
12 | constructor(data: any, graphModel: GraphModel) {
13 | if (!data.id) {
14 | data.id = `${nodeIDPrefixs[StepTypes.ServiceTask]}_${getBpmnId()}`
15 | }
16 | super(data, graphModel)
17 | this.setIsShowAnchor()
18 | }
19 |
20 | setAttributes() {
21 | this.width = nodeSize[StepTypes.ServiceTask].width
22 | this.height = nodeSize[StepTypes.ServiceTask].height
23 | }
24 |
25 | getDefaultAnchor() {
26 | const { x, y, width, id } = this
27 | return [
28 | {
29 | // x 轴上偏移 size / 2
30 | x: x - width / 2,
31 | y,
32 | id: `${id}_left`,
33 | },
34 | {
35 | // x 轴上偏移 size / 2
36 | x: x + width / 2,
37 | y,
38 | id: `${id}_right`,
39 | },
40 | ]
41 | }
42 | }
43 |
44 | class ServiceTaskView extends BaseCardView {
45 | static extendKey = 'ServiceTaskNode'
46 | }
47 |
48 | const ServiceTask = {
49 | type: StepTypes.ServiceTask,
50 | view: ServiceTaskView,
51 | model: ServiceTaskModel,
52 | }
53 |
54 | export { ServiceTaskModel, ServiceTaskView }
55 | export default ServiceTask
56 |
--------------------------------------------------------------------------------
/src/extensions/bpmn/tasks/UserTask.ts:
--------------------------------------------------------------------------------
1 | import type { GraphModel } from '@logicflow/core'
2 | import { RectNodeModel } from '@logicflow/core'
3 | import { getBpmnId } from '../../bpmnIds'
4 | import BaseNodeDecorator from '../base/BpmnBaseNode'
5 | import BaseCardView from '../card/BaseCardView'
6 | import { StepTypes, nodeIDPrefixs, nodeSize } from '../constant'
7 |
8 | /** api调用节点 */
9 | @BaseNodeDecorator()
10 | class UserTaskModel extends RectNodeModel {
11 | static extendKey = 'UserTaskModel'
12 | constructor(data: any, graphModel: GraphModel) {
13 | if (!data.id) {
14 | data.id = `${nodeIDPrefixs[StepTypes.UserTask]}_${getBpmnId()}`
15 | }
16 | super(data, graphModel)
17 | this.setIsShowAnchor()
18 | }
19 |
20 | setAttributes() {
21 | this.width = nodeSize[StepTypes.UserTask].width
22 | this.height = nodeSize[StepTypes.UserTask].height
23 | }
24 |
25 | getDefaultAnchor() {
26 | const { x, y, width, id } = this
27 | return [
28 | {
29 | // x 轴上偏移 size / 2
30 | x: x - width / 2,
31 | y,
32 | id: `${id}_left`,
33 | },
34 | {
35 | // x 轴上偏移 size / 2
36 | x: x + width / 2,
37 | y,
38 | id: `${id}_right`,
39 | },
40 | ]
41 | }
42 | }
43 |
44 | class UserTaskView extends BaseCardView {
45 | static extendKey = 'UserTaskNode'
46 | }
47 |
48 | const UserTask = {
49 | type: StepTypes.UserTask,
50 | view: UserTaskView,
51 | model: UserTaskModel,
52 | }
53 |
54 | export { UserTaskModel, UserTaskView }
55 | export default UserTask
56 |
--------------------------------------------------------------------------------
/src/extensions/bpmnIds.ts:
--------------------------------------------------------------------------------
1 | import Ids from 'ids'
2 |
3 | const ids = new Ids([32, 32, 1])
4 |
5 | export function getBpmnId() {
6 | return ids.next()
7 | }
8 |
--------------------------------------------------------------------------------
/src/extensions/edge-menu/index.css:
--------------------------------------------------------------------------------
1 | .lf-edge-menu {
2 | position: absolute;
3 | transform: translate(-50%, -50%);
4 | }
5 |
6 | .lf-edge-menu-item {
7 | cursor: pointer;
8 | }
9 |
--------------------------------------------------------------------------------
/src/extensions/edge-menu/index.ts:
--------------------------------------------------------------------------------
1 | import type LogicFlow from '@logicflow/core'
2 | import type { PolygonNodeModel } from '@logicflow/core'
3 | import type { PolyPoint } from '~/utils/algorithm'
4 | import {
5 | getCenterSegment,
6 | getEdgeCenter,
7 | getSegmentDirection,
8 | shortPolyPoints,
9 | } from '~/utils/algorithm'
10 |
11 | import './index.css'
12 |
13 | interface MenuItem {
14 | icon: string
15 | callback?: (data: PolygonNodeModel) => void
16 | className?: string
17 | properties: Record
18 | }
19 |
20 | /** 边的菜单 */
21 | export default class EdgeMenu {
22 | static pluginName = 'edgeMenu'
23 | private menuDOM?: HTMLElement
24 | private lf: LogicFlow
25 | private toolOverlay: any
26 | private _activeData?: PolygonNodeModel
27 | commonMenuItems?: MenuItem[]
28 | isShow = false
29 |
30 | constructor({ lf }: { lf: LogicFlow }) {
31 | this.lf = lf
32 | this.lf.setEdgeMenuItems = (menus: MenuItem[]) => {
33 | this.setEdgeMenuItems(menus)
34 | }
35 | this.lf.hideContextMenu = () => {
36 | this.hideMenu()
37 | }
38 | }
39 |
40 | /**
41 | * 设置通用的菜单选项
42 | */
43 | setEdgeMenuItems(items: MenuItem[]) {
44 | this.commonMenuItems = items
45 | }
46 |
47 | render(lf: LogicFlow, toolOverlay: HTMLElement) {
48 | this.toolOverlay = toolOverlay
49 | // 边右键
50 | lf.on('edge:contextmenu', ({ data }) => {
51 | this._activeData = data
52 | this.createMenu()
53 | })
54 | lf.on('blank:click', () => {
55 | this.hideMenu()
56 | })
57 | }
58 |
59 | createMenu() {
60 | const menuDOM = document.createElement('div')
61 | menuDOM.setAttribute('class', 'lf-edge-menu')
62 | const menus = document.createDocumentFragment()
63 | this.commonMenuItems?.forEach((item) => {
64 | const menuItem = document.createElement('div')
65 | menuItem.className = 'lf-edge-menu-item'
66 | const img = document.createElement('img')
67 | img.src = item.icon
68 | img.className = 'lf-edge-menu-img'
69 | if (item.className) {
70 | menuItem.className = `${menuItem.className} ${item.className}`
71 | }
72 | img.addEventListener('click', () => {
73 | this.hideMenu()
74 | if (item.callback) {
75 | item.callback(this._activeData!)
76 | }
77 | })
78 | menuItem.appendChild(img)
79 | menus.appendChild(menuItem)
80 | })
81 | menuDOM.innerHTML = ''
82 | menuDOM.appendChild(menus)
83 | this.menuDOM = menuDOM
84 | this.showMenu()
85 | }
86 |
87 | // 计算出菜单应该显示的位置(节点的右上角)
88 | getContextMenuPosition() {
89 | const data = this._activeData
90 | if (!data) return []
91 | const model = this.lf.graphModel.getEdgeModelById(data.id)
92 | if (!model?.pointsList) return []
93 | // 将菜单放在最长那一段的中间
94 | const points = getCenterSegment(shortPolyPoints(model.pointsList))
95 | const [{ x: sourceX, y: sourceY }, { x: targetX, y: targetY }] = points
96 | const [x, y] = getEdgeCenter({ sourceX, sourceY, targetX, targetY })
97 | const direction = getSegmentDirection(points as [PolyPoint, PolyPoint])
98 | // 根据线段方向加偏移量。避免菜单挡住线
99 | const point: [number, number] = direction === 'horizontal' ? [x, y - 10] : [x + 10, y]
100 | return this.lf.graphModel.transformModel.CanvasPointToHtmlPoint(point)
101 | }
102 |
103 | showMenu() {
104 | if (!this.menuDOM) return
105 | const [x, y] = this.getContextMenuPosition()
106 | this.menuDOM.style.display = 'flex'
107 | // 将菜单显示到对应的位置
108 | this.menuDOM.style.top = `${y}px`
109 | this.menuDOM.style.left = `${x}px`
110 | this.toolOverlay.appendChild(this.menuDOM)
111 | }
112 |
113 | /**
114 | * 隐藏菜单
115 | */
116 | hideMenu() {
117 | if (!this.menuDOM) return
118 | this.menuDOM.innerHTML = ''
119 | this.menuDOM.style.display = 'none'
120 | if (this.isShow) {
121 | this.toolOverlay.removeChild(this.menuDOM)
122 | }
123 | this.lf.off('node:delete,edge:delete,node:drag,graph:transform', this.listenDelete)
124 | this.isShow = false
125 | }
126 |
127 | listenDelete = () => {
128 | this.hideMenu()
129 | }
130 | }
131 |
--------------------------------------------------------------------------------
/src/extensions/index.ts:
--------------------------------------------------------------------------------
1 | export * from './bpmn'
2 |
--------------------------------------------------------------------------------
/src/extensions/layout/index.ts:
--------------------------------------------------------------------------------
1 | import type LogicFlow from '@logicflow/core'
2 | import type { EdgeConfig, Point } from '@logicflow/core'
3 | import type { graphlib } from 'dagre'
4 | import dagre from 'dagre'
5 | import { groupBy } from 'lodash'
6 | import { getEdgeCenter, shortPolyPoints } from '~/utils/algorithm'
7 | import { StepTypes, anchorOffset, defaultNodeSize, nodeIDPrefixs, nodeSize } from '../bpmn/constant'
8 | import { getGatewayAnchor } from '../bpmn/gateways/ExclusiveGateway'
9 |
10 | /** 自动布局插件 */
11 | export default class Layout {
12 | static pluginName = 'layout'
13 | // 算法库没有设置节点距离的参数,为了增大节点直接的距离,需要适当增加节点宽度,然后在线的起点和终点坐标抵消掉
14 | offsetLeft = 236
15 | offsetTop = 140
16 | private lf: LogicFlow
17 | /** dagreGraph instance */
18 | private dagreGraph?: graphlib.Graph | null
19 |
20 | constructor({ lf }: { lf: LogicFlow }) {
21 | this.lf = lf
22 | }
23 |
24 | /** 自动布局 */
25 | async autoLayout() {
26 | /** 设置dagreGraph数据 */
27 | this.initDagreGraph()
28 | this.updateLayoutElements()
29 | /** 计算布局 */
30 | dagre.layout(this.dagreGraph!)
31 | /** 将新的位置更新到logicFlow的数据里 */
32 | this.updateLogicFlowLayout()
33 | this.lf.resetTranslate()
34 | }
35 |
36 | private initDagreGraph() {
37 | this.dagreGraph = new dagre.graphlib.Graph({ multigraph: true })
38 | this.dagreGraph.setDefaultEdgeLabel(() => ({}))
39 | this.dagreGraph.setGraph({ rankdir: 'LR', marginx: 48, marginy: 128, ranksep: 100 })
40 | }
41 |
42 | private updateLayoutElements() {
43 | const { nodes, edges } = this.lf.getGraphRawData()
44 |
45 | // 同一源节点下的连线分组
46 | const sourceEdges = groupBy(edges, (edge) => edge.sourceNodeId)
47 | const nodeOrders: Record = {}
48 | for (const edge in sourceEdges) {
49 | sourceEdges[edge].sort((a, b) => a.startPoint!.y - b.startPoint!.y)
50 | sourceEdges[edge].forEach((edge, index) => {
51 | nodeOrders[edge.targetNodeId] = (nodeOrders[edge.targetNodeId] || 0) + 1 + index
52 | })
53 | }
54 |
55 | // dagre算法中同一层级节点的order是根据node的先后顺序确定,所以手动排下序
56 | nodes.sort((a, b) => nodeOrders[a.id!] - nodeOrders[b.id!])
57 | // 设置节点
58 | nodes.forEach((node) => {
59 | const t = node.type as StepTypes
60 | this.dagreGraph!.setNode(node.id!, {
61 | width: nodeSize[t]?.width || defaultNodeSize.width,
62 | height: nodeSize[t]?.height || defaultNodeSize.height,
63 | })
64 | })
65 |
66 | edges.forEach((edge) => {
67 | const name = this.getEdgeName(edge)
68 | this.dagreGraph!.setEdge(
69 | edge.sourceNodeId,
70 | edge.targetNodeId,
71 | {
72 | points: edge.pointsList,
73 | },
74 | name,
75 | )
76 | })
77 | }
78 |
79 | private updateLogicFlowLayout() {
80 | const { nodes, edges } = this.lf.getGraphRawData()
81 | nodes.forEach((node) => {
82 | const nodeWithPosition = this.dagreGraph!.node(node.id!)
83 | // dagre 也是以节点的中心位置为坐标值,所以不需要转换
84 | node.x = nodeWithPosition.x
85 | node.y = nodeWithPosition.y
86 | })
87 | edges.forEach((edge) => {
88 | const name = this.getEdgeName(edge)
89 | const edgeWithPosition = this.dagreGraph!.edge(edge.sourceNodeId, edge.targetNodeId, name)
90 | const points = edgeWithPosition.points
91 | const pointsLength = points.length
92 | // 起点和终点算上锚点和节点之间的距离
93 | points[0].x += anchorOffset
94 | points[pointsLength - 1].x -= anchorOffset
95 | // 以条件节点开始的线需要重新计算起点
96 | if (edge.sourceNodeId?.startsWith(nodeIDPrefixs[StepTypes.ExclusiveGateway])) {
97 | const p = this.getGatewayPointStart(edge.sourceNodeId, edge.sourceAnchorId!)
98 | if (p) {
99 | points[0].x = p.x
100 | points[0].y = p.y
101 | }
102 | }
103 | // 当结束点被多次链接的时候,默认会铺开,所有结束节点都要重新计算位置
104 | const targetNodePosition = this.dagreGraph!.node(edge.targetNodeId)
105 | points[pointsLength - 1].x = targetNodePosition.x - targetNodePosition.width / 2
106 | points[pointsLength - 1].y = targetNodePosition.y
107 |
108 | edge.startPoint = points[0]
109 | edge.endPoint = points[pointsLength - 1]
110 | edge.pointsList = edgeWithPosition.points
111 | // 有多段的都要重新计算中间点
112 | if (points.length > 2) {
113 | // 添加一个点
114 | const start = { ...points[0] }
115 | const end = { ...points[pointsLength - 1] }
116 | // 计算中点
117 | const center = getEdgeCenter({
118 | sourceX: start.x,
119 | sourceY: start.y,
120 | targetX: end.x,
121 | targetY: end.y,
122 | })
123 | edge.pointsList = [start, { x: center[0], y: start.y }, { x: center[0], y: end.y }, end]
124 | // 去重
125 | edge.pointsList = shortPolyPoints(edge.pointsList) as Point[]
126 | }
127 | })
128 | this.lf.renderRawData({ nodes, edges })
129 |
130 | this.dagreGraph = null
131 | }
132 |
133 | private getEdgeName(edge: EdgeConfig) {
134 | return `${edge.sourceAnchorId}-${edge.targetAnchorId}`
135 | }
136 |
137 | private getGatewayPointStart(sourceNodeId: string, sourceAnchorId: string) {
138 | // 获取条件节点位置,计算锚点位置
139 | const nodePosition = this.dagreGraph!.node(sourceNodeId)
140 | const anchors = getGatewayAnchor(
141 | nodePosition.x,
142 | nodePosition.y,
143 | nodeSize[StepTypes.ExclusiveGateway].width,
144 | nodeSize[StepTypes.ExclusiveGateway].height,
145 | sourceNodeId,
146 | )
147 | const targetAnchor = anchors.find(({ id }) => id === sourceAnchorId)
148 | if (targetAnchor) {
149 | return {
150 | x: targetAnchor.x,
151 | y: targetAnchor.y,
152 | }
153 | }
154 | }
155 | }
156 |
--------------------------------------------------------------------------------
/src/extensions/layout/types.ts:
--------------------------------------------------------------------------------
1 | export interface TreeNode {
2 | id: string
3 | children: TreeNode[]
4 | [key: string]: any
5 | }
6 |
--------------------------------------------------------------------------------
/src/extensions/minielement/createMiniElement.ts:
--------------------------------------------------------------------------------
1 | import type { GraphModel } from '@logicflow/core'
2 | import { RectNode, RectNodeModel } from '@logicflow/core'
3 | import type { StepTypes } from '../bpmn/constant'
4 | import { nodeIDPrefixs, nodeSize } from '../bpmn/constant'
5 | import { getBpmnId } from '../bpmnIds'
6 |
7 | const createMiniElement = function (type: StepTypes) {
8 | return {
9 | type,
10 | model: class ExclusiveGatewayModel extends RectNodeModel {
11 | constructor(data: any, graphModel: GraphModel) {
12 | if (!data.id) {
13 | data.id = `${nodeIDPrefixs[type]}_${getBpmnId()}`
14 | }
15 | if (!data.text) {
16 | data.text = ''
17 | }
18 | super(data, graphModel)
19 |
20 | this.setIsShowAnchor()
21 | }
22 | setAttributes() {
23 | this.width = nodeSize[type].width
24 | this.height = nodeSize[type].height
25 | }
26 | },
27 | view: class ExclusiveGatewayView extends RectNode {},
28 | }
29 | }
30 |
31 | export default createMiniElement
32 |
--------------------------------------------------------------------------------
/src/extensions/minielement/elements.ts:
--------------------------------------------------------------------------------
1 | import { StepTypes } from '../bpmn/constant'
2 | import createMiniElement from './createMiniElement'
3 |
4 | const StartEvent = createMiniElement(StepTypes.Start)
5 |
6 | const EndEvent = createMiniElement(StepTypes.End)
7 | const ExclusiveGateway = createMiniElement(StepTypes.ExclusiveGateway)
8 | const ServiceTask = createMiniElement(StepTypes.ServiceTask)
9 | const ScriptTask = createMiniElement(StepTypes.ScriptTask)
10 | const UserTask = createMiniElement(StepTypes.UserTask)
11 |
12 | export { EndEvent, ExclusiveGateway, ScriptTask, ServiceTask, StartEvent, UserTask }
13 |
--------------------------------------------------------------------------------
/src/extensions/minielement/index.ts:
--------------------------------------------------------------------------------
1 | import {
2 | EndEvent,
3 | ExclusiveGateway,
4 | ScriptTask,
5 | ServiceTask,
6 | StartEvent,
7 | UserTask,
8 | } from './elements'
9 |
10 | const theme = {
11 | rect: {
12 | fill: 'rgb(222, 222, 222)',
13 | strokeWidth: 2,
14 | stroke: 'transparent',
15 | },
16 | }
17 |
18 | // 小地图里的节点
19 | class MiniElement {
20 | static pluginName = 'MiniElement'
21 | constructor({ lf }) {
22 | lf.setTheme(theme)
23 | lf.register(StartEvent)
24 | lf.register(EndEvent)
25 | lf.register(ExclusiveGateway)
26 | lf.register(ServiceTask)
27 | lf.register(ScriptTask)
28 | lf.register(UserTask)
29 | }
30 | }
31 |
32 | export default MiniElement
33 |
--------------------------------------------------------------------------------
/src/extensions/minimap/index.ts:
--------------------------------------------------------------------------------
1 | import { throttle } from 'lodash';
2 |
3 | /**
4 | * https://github1s.com/didi/LogicFlow/blob/HEAD/packages/extension/src/components/mini-map/index.ts
5 | * 从官方插件复制过来的,修改了:
6 | * 1. 支持传入plugins进行覆盖
7 | */
8 | interface MiniMapStaticOption {
9 | width?: number,
10 | height?: number,
11 | isShowHeader?: boolean,
12 | isShowCloseIcon?: boolean,
13 | leftPosition?: number,
14 | rightPosition?: number,
15 | topPosition?: number,
16 | bottomPosition?: number,
17 | headerTitle?: string,
18 | }
19 | class MiniMap {
20 | static pluginName = 'miniMap';
21 | static width = 150;
22 | static height = 220;
23 | static viewPortWidth = 150;
24 | static viewPortHeight = 75;
25 | static isShowHeader = true;
26 | static isShowCloseIcon = true;
27 | static leftPosition = 0;
28 | static topPosition = 0;
29 | static rightPosition = null;
30 | static bottomPosition = null;
31 | static headerTitle = '导航';
32 | private lf = null;
33 | private container = null;
34 | private miniMapWrap = null;
35 | private miniMapContainer = null;
36 | private lfMap = null;
37 | private viewport = null;
38 | private width = 150;
39 | private height = 220;
40 | private leftPosition = undefined;
41 | private topPosition = undefined;
42 | private rightPosition = undefined;
43 | private bottomPosition = undefined;
44 | private miniMapWidth =450;
45 | private miniMapHeight = 660;
46 | private viewPortTop = 0;
47 | private viewPortLeft = 0;
48 | private startPosition = null;
49 | private viewPortScale = 1;
50 | private viewPortWidth = 150;
51 | private viewPortHeight = 75;
52 | private resetDataX = 0;
53 | private resetDataY = 0;
54 | private LogicFlow = null;
55 | private isShow = false;
56 | private isShowHeader = true;
57 | private isShowCloseIcon = true;
58 | private dragging = false;
59 | private disabledPlugins = ['miniMap', 'control', 'selectionSelect'];
60 | private plugins = [];
61 | constructor({ lf, LogicFlow, options }) {
62 | this.lf = lf;
63 | if (options && options.MiniMap) {
64 | this.setOption(options);
65 | }
66 | this.miniMapWidth = lf.graphModel.width;
67 | this.miniMapHeight = (lf.graphModel.width * this.height) / this.width;
68 | this.LogicFlow = LogicFlow;
69 | this.initMiniMap();
70 | }
71 | render(lf, container) {
72 | this.container = container;
73 | this.lf.on('history:change', () => {
74 | if (this.isShow) {
75 | this.setView();
76 | }
77 | });
78 | this.lf.on('graph:transform', throttle(() => {
79 | // 小地图已展示,并且没有拖拽小地图视口
80 | if (this.isShow && !this.dragging) {
81 | this.setView();
82 | }
83 | }, 300));
84 | }
85 | init(option) {
86 | this.disabledPlugins = this.disabledPlugins.concat(
87 | option.disabledPlugins || [],
88 | );
89 | this.plugins = this.plugins.concat(
90 | option.plugins || [],
91 | );
92 | }
93 | /**
94 | * 显示mini map
95 | */
96 | show = (leftPosition?: number, topPosition?: number) => {
97 | this.setView();
98 | if (!this.isShow) {
99 | this.createMiniMap(leftPosition, topPosition);
100 | }
101 | this.isShow = true;
102 | };
103 | /**
104 | * 隐藏mini map
105 | */
106 | hide = () => {
107 | if (this.isShow) {
108 | this.removeMiniMap();
109 | }
110 | this.isShow = false;
111 | };
112 | reset = () => {
113 | this.lf.resetTranslate();
114 | this.lf.resetZoom();
115 | this.hide();
116 | this.show();
117 | };
118 | private setOption(options) {
119 | const {
120 | width = 150,
121 | height = 220,
122 | isShowHeader = true,
123 | isShowCloseIcon = true,
124 | leftPosition = 0,
125 | topPosition = 0,
126 | rightPosition,
127 | bottomPosition,
128 | } = options.MiniMap as MiniMapStaticOption;
129 | this.width = width;
130 | this.height = height;
131 | this.isShowHeader = isShowHeader;
132 | this.isShowCloseIcon = isShowCloseIcon;
133 | this.viewPortWidth = width;
134 | this.leftPosition = leftPosition;
135 | this.topPosition = topPosition;
136 | this.rightPosition = rightPosition;
137 | this.bottomPosition = bottomPosition;
138 | this.init(options.MiniMap)
139 | }
140 | private initMiniMap() {
141 | const miniMapWrap = document.createElement('div');
142 | miniMapWrap.className = 'lf-mini-map-graph';
143 | miniMapWrap.style.width = `${this.width + 4}px`;
144 | miniMapWrap.style.height = `${this.height}px`;
145 | this.lfMap = new this.LogicFlow({
146 | container: miniMapWrap,
147 | isSilentMode: true,
148 | stopZoomGraph: true,
149 | stopScrollGraph: true,
150 | stopMoveGraph: true,
151 | hideAnchors: true,
152 | hoverOutline: false,
153 | plugins: this.plugins,
154 | disabledPlugins: this.disabledPlugins,
155 | });
156 | // minimap中禁用adapter。
157 | this.lfMap.adapterIn = (a) => a;
158 | this.lfMap.adapterOut = (a) => a;
159 | this.miniMapWrap = miniMapWrap;
160 | this.createViewPort();
161 | miniMapWrap.addEventListener('click', this.mapClick);
162 | }
163 | private createMiniMap(left?: number, top?: number) {
164 | const miniMapContainer = document.createElement('div');
165 | miniMapContainer.appendChild(this.miniMapWrap);
166 | if (typeof left !== 'undefined' || typeof top !== 'undefined') {
167 | miniMapContainer.style.left = `${left || 0}px`;
168 | miniMapContainer.style.top = `${top || 0}px`;
169 | } else {
170 | if (typeof this.rightPosition !== 'undefined') {
171 | miniMapContainer.style.right = `${this.rightPosition}px`;
172 | } else if (typeof this.leftPosition !== 'undefined') {
173 | miniMapContainer.style.left = `${this.leftPosition}px`;
174 | }
175 | if (typeof this.bottomPosition !== 'undefined') {
176 | miniMapContainer.style.bottom = `${this.bottomPosition}px`;
177 | } else if (typeof this.topPosition !== 'undefined') {
178 | miniMapContainer.style.top = `${this.topPosition}px`;
179 | }
180 | }
181 | miniMapContainer.style.position = 'absolute';
182 | miniMapContainer.className = 'lf-mini-map';
183 | if (!this.isShowCloseIcon) {
184 | miniMapContainer.classList.add('lf-mini-map-no-close-icon');
185 | }
186 | if (!this.isShowHeader) {
187 | miniMapContainer.classList.add('lf-mini-map-no-header');
188 | }
189 | this.container.appendChild(miniMapContainer);
190 | this.miniMapWrap.appendChild(this.viewport);
191 |
192 | const header = document.createElement('div');
193 | header.className = 'lf-mini-map-header';
194 | header.innerText = MiniMap.headerTitle;
195 | miniMapContainer.appendChild(header);
196 |
197 | const close = document.createElement('span');
198 | close.className = 'lf-mini-map-close';
199 | close.addEventListener('click', this.hide);
200 | miniMapContainer.appendChild(close);
201 | this.miniMapContainer = miniMapContainer;
202 | }
203 | private removeMiniMap() {
204 | this.container.removeChild(this.miniMapContainer);
205 | }
206 | /**
207 | * 计算所有图形一起,占领的区域范围。
208 | * @param data
209 | */
210 | private getBounds(data) {
211 | let left = 0;
212 | let right = this.miniMapWidth;
213 | let top = 0;
214 | let bottom = this.miniMapHeight;
215 | const { nodes } = data;
216 | if (nodes && nodes.length > 0) {
217 | // 因为获取的节点不知道真实的宽高,这里需要补充一点数值
218 | nodes.forEach(({ x, y, width = 200, height = 200 }) => {
219 | const nodeLeft = x - width / 2;
220 | const nodeRight = x + width / 2;
221 | const nodeTop = y - height / 2;
222 | const nodeBottom = y + height / 2;
223 | left = nodeLeft < left ? nodeLeft : left;
224 | right = nodeRight > right ? nodeRight : right;
225 | top = nodeTop < top ? nodeTop : top;
226 | bottom = nodeBottom > bottom ? nodeBottom : bottom;
227 | });
228 | }
229 | return {
230 | left,
231 | top,
232 | bottom,
233 | right,
234 | };
235 | }
236 | /**
237 | * 将负值的平移转换为正值。
238 | * 保证渲染的时候,minimap能完全展示。
239 | * 获取将画布所有元素平移到0,0开始时,所有节点数据
240 | */
241 | private resetData(data) {
242 | const { nodes, edges } = data;
243 | let left = 0;
244 | let top = 0;
245 | if (nodes && nodes.length > 0) {
246 | // 因为获取的节点不知道真实的宽高,这里需要补充一点数值
247 | nodes.forEach(({ x, y, width = 200, height = 200 }) => {
248 | const nodeLeft = x - width / 2;
249 | const nodeTop = y - height / 2;
250 | left = nodeLeft < left ? nodeLeft : left;
251 | top = nodeTop < top ? nodeTop : top;
252 | });
253 | if (left < 0 || top < 0) {
254 | this.resetDataX = left;
255 | this.resetDataY = top;
256 | nodes.forEach((node) => {
257 | node.x = node.x - left;
258 | node.y = node.y - top;
259 | if (node.text) {
260 | node.text.x = node.text.x - left;
261 | node.text.y = node.text.y - top;
262 | }
263 | });
264 | edges.forEach((edge) => {
265 | if (edge.startPoint) {
266 | edge.startPoint.x = edge.startPoint.x - left;
267 | edge.startPoint.y = edge.startPoint.y - top;
268 | }
269 | if (edge.endPoint) {
270 | edge.endPoint.x = edge.endPoint.x - left;
271 | edge.endPoint.y = edge.endPoint.y - top;
272 | }
273 | if (edge.text) {
274 | edge.text.x = edge.text.x - left;
275 | edge.text.y = edge.text.y - top;
276 | }
277 | if (edge.pointsList) {
278 | edge.pointsList.forEach((point) => {
279 | point.x = point.x - left;
280 | point.y = point.y - top;
281 | });
282 | }
283 | });
284 | }
285 | }
286 | return data;
287 | }
288 | /**
289 | * 显示导航
290 | * 显示视口范围
291 | * 1. 基于画布的范围比例,设置视口范围比例。宽度默认为导航宽度。
292 | */
293 | private setView() {
294 | // 1. 获取到图中所有的节点中的位置,将其偏移到原点开始(避免节点位置为负的时候无法展示问题)。
295 | const graphData = this.lf.getGraphRawData();
296 | const data = this.resetData(graphData);
297 | // 由于随时都会有新节点注册进来,需要同步将注册的
298 | const { viewMap } : { viewMap: Map } = this.lf;
299 | const { modelMap } : { modelMap: Map } = this.lf.graphModel;
300 | const { viewMap: minimapViewMap } : { viewMap: Map } = this.lfMap;
301 | // todo: no-restricted-syntax
302 | for (const key of viewMap.keys()) {
303 | if (!minimapViewMap.has(key)) {
304 | this.lfMap.setView(key, viewMap.get(key));
305 | this.lfMap.graphModel.modelMap.set(key, modelMap.get(key));
306 | }
307 | }
308 | this.lfMap.render(data);
309 | // 2. 将偏移后的数据渲染到minimap画布上
310 | // 3. 计算出所有节点在一起的边界。
311 | const { left, top, right, bottom } = this.getBounds(data);
312 | // 4. 计算所有节点的边界与minimap看板的边界的比例.
313 | const realWidthScale = this.width / (right - left);
314 | const realHeightScale = this.height / (bottom - top);
315 | // 5. 取比例最小的值,将渲染的画布缩小对应比例。
316 | const innerStyle = this.miniMapWrap.firstChild.style;
317 | const scale = Math.min(realWidthScale, realHeightScale);
318 | innerStyle.transform = `matrix(${scale}, 0, 0, ${scale}, 0, 0)`;
319 | innerStyle.transformOrigin = 'left top';
320 | innerStyle.height = `${bottom - Math.min(top, 0)}px`;
321 | innerStyle.width = `${right - Math.min(left, 0)}px`;
322 | this.viewPortScale = scale;
323 | this.setViewPort(scale, {
324 | left,
325 | top,
326 | right,
327 | bottom,
328 | });
329 | }
330 | // 设置视口
331 | private setViewPort(scale, { left, right }) {
332 | const viewStyle = this.viewport.style;
333 | viewStyle.width = `${this.viewPortWidth}px`;
334 | viewStyle.height = `${
335 | (this.viewPortWidth) / (this.lf.graphModel.width / this.lf.graphModel.height)
336 | }px`;
337 | const { TRANSLATE_X, TRANSLATE_Y, SCALE_X, SCALE_Y } = this.lf.getTransform();
338 | const realWidth = right - left;
339 | // 视口宽 = 小地图宽 / (所有元素一起占据的真实宽 / 绘布宽)
340 | const viewPortWidth = (this.width) / (realWidth / this.lf.graphModel.width);
341 | // 实际视口宽 = 小地图宽 * 占宽度比例
342 | const realViewPortWidth = this.width * (viewPortWidth / this.width);
343 | const graphRatio = (this.lf.graphModel.width / this.lf.graphModel.height);
344 | // 视口实际高 = 视口实际宽 / (绘布宽 / 绘布高)
345 | const realViewPortHeight = realViewPortWidth / graphRatio;
346 | const graphData = this.lf.getGraphRawData();
347 | const { left: graphLeft, top: graphTop } = this.getBounds(graphData);
348 | let viewportLeft = graphLeft;
349 | let viewportTop = graphTop;
350 | viewportLeft += TRANSLATE_X / SCALE_X;
351 | viewportTop += TRANSLATE_Y / SCALE_Y;
352 | this.viewPortTop = viewportTop > 0 ? 0 : (-viewportTop * scale);
353 | this.viewPortLeft = viewportLeft > 0 ? 0 : (-viewportLeft * scale);
354 | this.viewPortWidth = realViewPortWidth;
355 | this.viewPortHeight = realViewPortHeight;
356 | viewStyle.top = `${this.viewPortTop}px`;
357 | viewStyle.left = `${this.viewPortLeft}px`;
358 | viewStyle.width = `${realViewPortWidth / SCALE_X}px`;
359 | viewStyle.height = `${realViewPortHeight / SCALE_Y}px`;
360 | }
361 | // 预览视窗
362 | private createViewPort() {
363 | const div = document.createElement('div');
364 | div.className = 'lf-minimap-viewport';
365 | div.addEventListener('mousedown', this.startDrag);
366 | this.viewport = div;
367 | }
368 | private startDrag = (e) => {
369 | document.addEventListener('mousemove', this.drag);
370 | document.addEventListener('mouseup', this.drop);
371 | this.startPosition = {
372 | x: e.x,
373 | y: e.y,
374 | };
375 | };
376 | private moveViewport = (top, left) => {
377 | const viewStyle = this.viewport.style;
378 | this.viewPortTop = top;
379 | this.viewPortLeft = left;
380 | viewStyle.top = `${this.viewPortTop}px`;
381 | viewStyle.left = `${this.viewPortLeft}px`;
382 | };
383 | private drag = (e) => {
384 | this.dragging = true;
385 | const top = this.viewPortTop + e.y - this.startPosition.y;
386 | const left = this.viewPortLeft + e.x - this.startPosition.x;
387 | this.moveViewport(top, left);
388 | this.startPosition = {
389 | x: e.x,
390 | y: e.y,
391 | };
392 | const centerX = (this.viewPortLeft + this.viewPortWidth / 2)
393 | / this.viewPortScale;
394 | const centerY = (this.viewPortTop + this.viewPortHeight / 2)
395 | / this.viewPortScale;
396 | this.lf.focusOn({
397 | coordinate: {
398 | x: centerX + this.resetDataX,
399 | y: centerY + this.resetDataY,
400 | },
401 | });
402 | };
403 | private drop = () => {
404 | document.removeEventListener('mousemove', this.drag);
405 | document.removeEventListener('mouseup', this.drop);
406 | let top = this.viewPortTop;
407 | let left = this.viewPortLeft;
408 | if (this.viewPortLeft > this.width) {
409 | left = this.width - this.viewPortWidth;
410 | }
411 | if (this.viewPortTop > this.height) {
412 | top = this.height - this.viewPortHeight;
413 | }
414 | if (this.viewPortLeft < -this.width) {
415 | left = 0;
416 | }
417 | if (this.viewPortTop < -this.height) {
418 | top = 0;
419 | }
420 | this.moveViewport(top, left);
421 | };
422 | private mapClick = (e) => {
423 | if (this.dragging) {
424 | this.dragging = false;
425 | } else {
426 | const { layerX, layerY } = e;
427 | const ViewPortCenterX = layerX;
428 | const ViewPortCenterY = layerY;
429 | const graphData = this.lf.getGraphRawData();
430 | const { left, top } = this.getBounds(graphData);
431 | const resetGraphX = left + ViewPortCenterX / this.viewPortScale;
432 | const resetGraphY = top + ViewPortCenterY / this.viewPortScale;
433 | this.lf.focusOn({ coordinate: { x: resetGraphX, y: resetGraphY } });
434 | }
435 | };
436 | }
437 |
438 | export default MiniMap;
439 |
440 | export { MiniMap };
441 |
--------------------------------------------------------------------------------
/src/extensions/smoothpolyline-edge/SmoothPolylineEdge.ts:
--------------------------------------------------------------------------------
1 | import type { GraphModel } from '@logicflow/core'
2 | import { PolylineEdge, PolylineEdgeModel } from '@logicflow/core'
3 | import { getSmoothPolylineShape } from './basic-shape'
4 |
5 | /** 转折点平滑的折线 */
6 | class SmoothPolylineEdgeModel extends PolylineEdgeModel {
7 | static extendKey = 'smoothPolyline'
8 | /** 圆弧部分的长度,当borderRadius为0时,路径和PolylineEdge一致 */
9 | borderRadius = 8
10 | constructor(data: any, graphModel: GraphModel) {
11 | super(data, graphModel)
12 | }
13 | }
14 |
15 | class SmoothPolylineEdgeView extends PolylineEdge {
16 | constructor() {
17 | super()
18 | }
19 | getEdge() {
20 | const { model } = this.props
21 | const { pointsList, points, borderRadius, isAnimation, arrowConfig } = model
22 |
23 | const style = model.getEdgeStyle()
24 | const animationStyle = model.getEdgeAnimationStyle()
25 | const {
26 | strokeDasharray,
27 | stroke,
28 | strokeDashoffset,
29 | animationName,
30 | animationDuration,
31 | animationIterationCount,
32 | animationTimingFunction,
33 | animationDirection,
34 | } = animationStyle
35 |
36 | return getSmoothPolylineShape({
37 | pointsList,
38 | points,
39 | borderRadius,
40 | ...style,
41 | ...arrowConfig,
42 | ...(isAnimation
43 | ? {
44 | strokeDasharray,
45 | stroke,
46 | style: {
47 | strokeDashoffset,
48 | animationName,
49 | animationDuration,
50 | animationIterationCount,
51 | animationTimingFunction,
52 | animationDirection,
53 | },
54 | }
55 | : {}),
56 | })
57 | }
58 | }
59 |
60 | const SmoothPolylineEdge = {
61 | type: SmoothPolylineEdgeModel.extendKey,
62 | view: SmoothPolylineEdgeView,
63 | model: SmoothPolylineEdgeModel,
64 | }
65 |
66 | export { SmoothPolylineEdgeModel, SmoothPolylineEdgeView }
67 | export default SmoothPolylineEdge
68 |
--------------------------------------------------------------------------------
/src/extensions/smoothpolyline-edge/basic-shape.ts:
--------------------------------------------------------------------------------
1 | import { h } from '@logicflow/core'
2 |
3 | interface Point {
4 | x: number
5 | y: number
6 | }
7 |
8 | interface Props {
9 | points: string
10 | pointsList: Point[]
11 | borderRadius: number
12 | [key: string]: any
13 | }
14 |
15 | export function getSmoothPolylineShape({ pointsList, points, borderRadius, ...oth }: Props) {
16 | if (pointsList.length <= 2) {
17 | return h('polyline', {
18 | points,
19 | ...oth,
20 | })
21 | }
22 | const newPoints = getSmoothPolylinePoints(pointsList, borderRadius)
23 |
24 | return h(
25 | 'g',
26 | {},
27 | h('path', {
28 | d: getSmoothPath(newPoints),
29 | fill: 'none',
30 | ...oth,
31 | }),
32 | )
33 | }
34 |
35 | interface SmoothPoint {
36 | start: Point
37 | end: Point
38 | // 只有曲线有控制点
39 | control?: Point
40 | }
41 | /** 获取每一个线段的起点 终点 和 控制点 */
42 | export function getSmoothPolylinePoints(pointList: Point[], borderRadius: number) {
43 | let i = 1
44 | const result: SmoothPoint[] = [
45 | {
46 | start: pointList[0],
47 | end: pointList[1],
48 | },
49 | ]
50 | // 除了起点和终点,每个点需要变成两个点,造出一段曲线
51 | while (i < pointList.length - 1) {
52 | const start = movePointBTowardsPointA(pointList[i - 1], pointList[i], borderRadius)
53 | const end = movePointBTowardsPointA(pointList[i + 1], pointList[i], borderRadius)
54 |
55 | // 修改上一段直线的终点
56 | result[result.length - 1].end = { ...start }
57 |
58 | result.push(
59 | ...[
60 | {
61 | start,
62 | end,
63 | control: pointList[i],
64 | },
65 | {
66 | start: end,
67 | end: pointList[i + 1],
68 | },
69 | ],
70 | )
71 |
72 | i++
73 | }
74 | return result
75 | }
76 |
77 | function movePointBTowardsPointA(pointA: Point, pointB: Point, offset: number): Point {
78 | // 计算从点B到点A的向量
79 | const vectorAB = {
80 | x: pointA.x - pointB.x,
81 | y: pointA.y - pointB.y,
82 | }
83 |
84 | // 计算向量AB的长度
85 | const distanceAB = Math.sqrt(vectorAB.x * vectorAB.x + vectorAB.y * vectorAB.y)
86 |
87 | // 根据偏移量将点B向点A移动 如果offset小于长度的一半,只移动一半的距离,防止曲线重叠
88 | const scaleFactor = Math.min(offset, distanceAB / 2) / distanceAB
89 | const movedPointB = {
90 | x: pointB.x + vectorAB.x * scaleFactor,
91 | y: pointB.y + vectorAB.y * scaleFactor,
92 | }
93 |
94 | return movedPointB
95 | }
96 |
97 | function getSmoothPath(points: SmoothPoint[]) {
98 | const { start } = points[0]
99 | let path = `M ${start.x} ${start.y}`
100 | let i = 0
101 | while (i < points.length) {
102 | const { end, control } = points[i]
103 |
104 | path += (control ? ` Q ${control.x} ${control.y} ` : ' L') + ` ${end.x} ${end.y}`
105 | i++
106 | }
107 | return path
108 | }
109 |
--------------------------------------------------------------------------------
/src/main.ts:
--------------------------------------------------------------------------------
1 | import 'normalize.css'
2 | import { createPinia } from 'pinia'
3 | import { createApp } from 'vue'
4 | import App from './App.vue'
5 | import Icon from './components/Icon'
6 | import router from './router'
7 | import './styles/styles.less'
8 |
9 | const app = createApp(App)
10 |
11 | app.component('Icon', Icon)
12 |
13 | if (process.env.NODE_ENV === 'development') {
14 | app.config.devtools = true
15 | }
16 |
17 | const pinia = createPinia()
18 |
19 | app.use(router)
20 | app.use(pinia)
21 |
22 | app.mount('#app')
23 |
--------------------------------------------------------------------------------
/src/models/graph.ts:
--------------------------------------------------------------------------------
1 | /** 一个节点的业务数据 */
2 | export interface NodeProperties {
3 | /** 图标 */
4 | icon: string;
5 | /** 显示名称 */
6 | title: string
7 | /** 描述 */
8 | description: string
9 | }
10 |
--------------------------------------------------------------------------------
/src/router/index.ts:
--------------------------------------------------------------------------------
1 | import type { RouteRecordRaw } from 'vue-router'
2 | import { createRouter, createWebHistory } from 'vue-router'
3 | import Modeler from '~/views/Modeler.vue'
4 |
5 | const routes: RouteRecordRaw[] = [
6 | { path: '/', redirect: '/modeler' },
7 | { path: '/modeler', component: Modeler },
8 | ]
9 |
10 | export const routerHistory = createWebHistory(import.meta.env.BASE_URL)
11 |
12 | const router = createRouter({
13 | history: routerHistory,
14 | routes,
15 | })
16 |
17 | export default router
18 |
--------------------------------------------------------------------------------
/src/stores/graph.ts:
--------------------------------------------------------------------------------
1 | import type LogicFlow from '@logicflow/core'
2 | import type { BaseNodeModel } from '@logicflow/core'
3 | import { acceptHMRUpdate, defineStore } from 'pinia'
4 | import { StepTypes } from '~/extensions/bpmn/constant'
5 | import type { NodeProperties } from '~/models/graph'
6 |
7 | interface GraphState {
8 | /** 是否打开编辑节点的弹窗 */
9 | modal: boolean
10 | /** 正在编辑的elementId */
11 | targetId: string | null
12 | /** 正在编辑的节点类型 */
13 | nodeType: string
14 | /** 当前编辑对象的数据模型 */
15 | targetModel: Partial | null
16 | /** LogicFlow实例 */
17 | lfInstance?: LogicFlow | null
18 | /** 当前节点处于编辑状态 */
19 | editMode: 'update' | 'add'
20 | // 是否有改动
21 | change: boolean
22 | }
23 |
24 | export const useGraphStore = defineStore('graph', {
25 | state: (): GraphState => ({
26 | modal: false,
27 | nodeType: '',
28 | targetModel: {},
29 | targetId: null,
30 | editMode: 'add',
31 | lfInstance: null,
32 | change: false,
33 | }),
34 | getters: {
35 | /** 当前节点列表 */
36 | nodes: (state) => state.lfInstance?.getGraphRawData().nodes,
37 | apiNames: (state) =>
38 | state.lfInstance
39 | ?.getGraphRawData()
40 | .nodes.filter(({ type }) => type === StepTypes.ServiceTask)
41 | .map(({ properties }) => properties?.name as string),
42 | },
43 | actions: {
44 | editModel(editMode: GraphState['editMode'], elementId: string, initModel?: BaseNodeModel) {
45 | // 开始节点不可编辑
46 | if (initModel?.type === StepTypes.Start) return
47 | this.$patch({
48 | targetId: elementId,
49 | modal: true,
50 | nodeType: initModel?.type,
51 | targetModel: initModel?.properties,
52 | editMode,
53 | })
54 | },
55 | saveModel(elementId: string, model: NodeProperties) {
56 | // 更新节点信息
57 | this.lfInstance?.setProperties(elementId, model)
58 | this.modal = false
59 | this.resetTarget()
60 | this.change = true
61 | },
62 | closeModal() {
63 | if (this.editMode === 'add') {
64 | // 添加了节点没保存数据,删除该节点
65 | this.lfInstance?.deleteElement(this.targetId)
66 | }
67 | this.modal = false
68 | this.resetTarget()
69 | },
70 | resetTarget() {
71 | this.$patch({
72 | targetId: null,
73 | nodeType: '',
74 | targetModel: null,
75 | })
76 | },
77 | initLF(lf: LogicFlow) {
78 | this.lfInstance = lf
79 | },
80 | saveGraph() {
81 | this.change = false
82 | },
83 | },
84 | })
85 |
86 | if (import.meta.hot) {
87 | import.meta.hot.accept(acceptHMRUpdate(useGraphStore, import.meta.hot))
88 | }
89 |
--------------------------------------------------------------------------------
/src/styles/antd-reset.less:
--------------------------------------------------------------------------------
1 | .ant-collapse-borderless {
2 | background-color: transparent;
3 |
4 | > .ant-collapse-item {
5 | border-bottom: none;
6 |
7 | > .ant-collapse-header {
8 | padding-left: 0;
9 | }
10 | }
11 | }
12 |
13 | .ant-input-group.ant-input-group-compact {
14 | display: flex;
15 |
16 | > :last-child {
17 | flex: 1 1 0px;
18 | }
19 | }
20 |
21 | .ant-form {
22 | // 设置no-style属性不会显示验证器错误,通过css清除样式
23 | .ant-form-item.no-style {
24 | display: inline-block;
25 | margin-bottom: 0;
26 | }
27 | }
28 |
29 | .ant-input-search > .ant-input-group > .ant-input {
30 | height: 32px;
31 | }
32 |
33 | .ant-modal-title {
34 | font-weight: @typography-title-font-weight;
35 | }
36 |
37 | // 在内部滚动的modal
38 | .scroll-inner-modal.ant-modal {
39 | .ant-modal-content {
40 | display: flex;
41 | flex-direction: column;
42 | max-height: calc(100vh - 140px);
43 |
44 | .ant-modal-body {
45 | flex: 1 1 auto;
46 | overflow: auto;
47 | }
48 | }
49 | }
50 |
--------------------------------------------------------------------------------
/src/styles/flow.less:
--------------------------------------------------------------------------------
1 | @edge-color: #d0d0d0;
2 | @edge-color-hover: #d1bdff;
3 |
4 | @keyframes edge-selected {
5 | 0% {
6 | stroke: @edge-color;
7 | }
8 |
9 | 100% {
10 | stroke: @edge-color-hover;
11 | }
12 | }
13 |
14 | @keyframes edge-blur {
15 | 0% {
16 | stroke: @edge-color-hover;
17 | }
18 |
19 | 100% {
20 | stroke: @edge-color;
21 | }
22 | }
23 |
24 | .node-filter {
25 | &:hover {
26 | filter: drop-shadow(0 0 4px @primary-3);
27 | }
28 | }
29 |
30 | .lf-text-tspan {
31 | color: @text-color;
32 | }
33 |
34 | // 选区
35 | .lf-selection-select {
36 | border: 2px dashed @primary-4;
37 | }
38 |
39 | // 选中区域
40 | .lf-multiple-select {
41 | border: 2px dashed @primary-3;
42 | box-shadow: 0 0 3px 0 @primary-2;
43 | }
44 |
45 | // 小地图
46 | .lf-mini-map {
47 | top: revert !important;
48 | right: 0 !important;
49 | bottom: 0 !important;
50 | left: revert !important;
51 | padding-top: 0;
52 | background-color: #fff;
53 | border: none;
54 | border-radius: 4px;
55 | box-shadow: 0 2px 8px 0 rgb(0 0 0 / 20%);
56 | opacity: 0.75;
57 |
58 | .lf-graph {
59 | background-color: #fff;
60 | }
61 | }
62 |
63 | .lf-mini-map-header,
64 | .lf-mini-map-close {
65 | display: none;
66 | }
67 |
68 | .lf-minimap-viewport {
69 | background-color: rgb(0 0 0 / 8%);
70 | border: none;
71 | }
72 |
--------------------------------------------------------------------------------
/src/styles/styles.less:
--------------------------------------------------------------------------------
1 | @import url('ant-design-vue/dist/antd.less');
2 | @import url('./antd-reset.less');
3 | @import url('./flow.less');
4 |
5 | #container {
6 | width: 100%;
7 | height: 100%;
8 | line-height: 0;
9 | }
10 |
11 | #app {
12 | width: 100vw;
13 | min-height: 100vh;
14 | }
15 |
16 | // 修改滚动条样式
17 | ::-webkit-scrollbar {
18 | position: absolute;
19 | width: 20px;
20 | height: 20px;
21 | background-color: #f5f5f5;
22 | }
23 |
24 | ::-webkit-scrollbar-track {
25 | background-color: #f5f5f5;
26 | }
27 |
28 | ::-webkit-scrollbar-thumb {
29 | width: 10px;
30 | height: 10px;
31 | background-color: rgb(0 0 0 / 15%);
32 | border: 5px solid #f5f5f5;
33 | border-radius: 100px;
34 | }
35 |
36 | ::-webkit-scrollbar-thumb:hover {
37 | background-color: rgb(0 0 0 / 25%);
38 | }
39 |
40 | ul,
41 | ol {
42 | padding-inline-start: 16px;
43 | }
44 |
45 | .flex-input {
46 | position: absolute;
47 | top: 0;
48 | transition: all 0.3s, height 0.2s !important;
49 |
50 | &:not(:focus) {
51 | max-height: 32px !important;
52 | overflow: hidden;
53 | text-overflow: ellipsis;
54 | }
55 | }
56 |
--------------------------------------------------------------------------------
/src/styles/theme.less:
--------------------------------------------------------------------------------
1 | // https://github.com/vueComponent/ant-design-vue/blob/main/components/style/themes/default.less
2 | @import url('~ant-design-vue/lib/style/themes/default.less');
3 |
4 | @font-family: -apple-system, BlinkMacSystemFont, '思源黑体', 'Segoe UI', Roboto, 'Helvetica Neue',
5 | Arial, 'Noto Sans', sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol',
6 | 'Noto Color Emoji';
7 | @body-background: #f5f5f5;
8 | @text-color: rgba(0, 0, 0, 0.85);
9 | @heading-color: rgba(0, 0, 0, 0.88);
10 | @primary-color: #613eea;
11 | @info-color: #613eea;
12 | @progress-text-color: #613eea;
13 | @success-color: #5cc42f;
14 | @warning-color: #ebae21;
15 | @error-color: #eb3850;
16 | @highlight-color: #eb3850;
17 | @border-color-base: #d9d9d9;
18 | @border-radius-base: 2px;
19 | @box-shadow-base: 0 4px 16px rgba(0, 0, 0, 0.1);
20 | @card-shadow: 0 4px 16px rgba(0, 0, 0, 0.1);
21 | @typography-title-font-weight: 600;
22 | @heading-5-size: 14px;
23 | @modal-body-padding: 24px 40px;
24 | @avatar-bg: @primary-1;
25 | @avatar-color: @primary-color;
26 | @result-title-font-size: 18px;
27 | @result-subtitle-font-size: @font-size-base;
28 | @result-icon-font-size: 48px;
29 | @result-extra-margin: 24px 0 0 0;
30 |
--------------------------------------------------------------------------------
/src/utils/algorithm.ts:
--------------------------------------------------------------------------------
1 | export interface PolyPoint {
2 | x: number
3 | y: number
4 | id?: string
5 | }
6 |
7 | /* 两点之间距离 */
8 | export const distance = (x1: number, y1: number, x2: number, y2: number): number =>
9 | Math.hypot(x1 - x2, y1 - y2)
10 |
11 | /**
12 | * 获取折线中最长的一个线
13 | * @param pointsList 多个点组成的数组
14 | */
15 | export const getLongestEdge = (pointsList: PolyPoint[]) => {
16 | let points: [PolyPoint, PolyPoint]
17 | if (pointsList.length === 1) {
18 | points = [pointsList[0], pointsList[0]]
19 | } else if (pointsList.length >= 2) {
20 | let point1 = pointsList[0]
21 | let point2 = pointsList[1]
22 | let edgeLength = distance(point1.x, point1.y, point2.x, point2.y)
23 | for (let i = 1; i < pointsList.length - 1; i++) {
24 | const newPoint1 = pointsList[i]
25 | const newPoint2 = pointsList[i + 1]
26 | const newEdgeLength = distance(newPoint1.x, newPoint1.y, newPoint2.x, newPoint2.y)
27 | if (newEdgeLength > edgeLength) {
28 | edgeLength = newEdgeLength
29 | point1 = newPoint1
30 | point2 = newPoint2
31 | }
32 | }
33 | points = [point1, point2]
34 | }
35 | return points
36 | }
37 |
38 | /** 获取线的中点 */
39 | export const getEdgeCenter = ({
40 | sourceX,
41 | sourceY,
42 | targetX,
43 | targetY,
44 | }: {
45 | sourceX: number
46 | sourceY: number
47 | targetX: number
48 | targetY: number
49 | }): [number, number] => {
50 | const xOffset = Math.abs(targetX - sourceX) / 2
51 | const centerX = targetX < sourceX ? targetX + xOffset : targetX - xOffset
52 |
53 | const yOffset = Math.abs(targetY - sourceY) / 2
54 | const centerY = targetY < sourceY ? targetY + yOffset : targetY - yOffset
55 |
56 | return [centerX, centerY]
57 | }
58 |
59 | /** 删除折线多余的点 */
60 | export function shortPolyPoints(pointsList: PolyPoint[]) {
61 | const prePointsList = [...pointsList]
62 | // 在多个点在同一条线上,只保留头尾两个点
63 | const linePoints: PolyPoint[] = []
64 | // 当前比较线段的斜率
65 | let slope: number | null = null
66 | const points: PolyPoint[] = []
67 |
68 | while (prePointsList.length) {
69 | const target = prePointsList.shift()
70 | if (!target) continue
71 | if (!linePoints.length) {
72 | linePoints.push(target)
73 | } else {
74 | // 比较
75 | let failed = false
76 | const start = linePoints[0]
77 | // 每个比较的点都和起始点计算斜率比较
78 | const currentSlope = getSlope(start.x, start.y, target.x, target.y)
79 | if (slope === null || currentSlope === slope) {
80 | slope = currentSlope
81 | linePoints.push(target)
82 | } else {
83 | failed = true
84 | }
85 | if (!failed && prePointsList.length) continue
86 | // 只保留前后两个点
87 | const startPoint = linePoints.shift()
88 | startPoint ? points.push(startPoint) : null
89 | const endPoint = linePoints.pop()
90 | endPoint ? points.push(endPoint) : null
91 | linePoints.length = 0
92 | if (failed) {
93 | if (prePointsList.length) {
94 | // 如果后面还有点,把目标节点作为下一个比较对象
95 | linePoints.push(target)
96 | } else {
97 | points.push(target)
98 | }
99 | }
100 | failed = false
101 | slope = null
102 | }
103 | }
104 |
105 | return points
106 | }
107 |
108 | export const getSlope = (x1: number, y1: number, x2: number, y2: number) => {
109 | return (y2 - y1) / (x2 - x1)
110 | }
111 |
112 | /** 获取折线中间段 */
113 | export const getCenterSegment = (pointsList: PolyPoint[]) => {
114 | const center = Math.ceil(pointsList.length / 2 - 1)
115 | return [pointsList[center], pointsList[center + 1]]
116 | }
117 |
118 | /** 获取一段线段的方向 */
119 | export const getSegmentDirection = (pointsList: [PolyPoint, PolyPoint]) => {
120 | const [{ x: x1, y: y1 }, { x: x2, y: y2 }] = pointsList
121 | const slop = getSlope(x1, y1, x2, y2)
122 | // 斜率等于x轴和线段的夹角的正切值 夹角在-45度和45度之间,则为水平
123 | return Math.abs(slop) > 1 ? 'vertical' : 'horizontal'
124 | }
125 |
--------------------------------------------------------------------------------
/src/utils/config.ts:
--------------------------------------------------------------------------------
1 | import type LogicFlow from '@logicflow/core'
2 | import type { BaseEdgeModel, BaseNodeModel, Definition } from '@logicflow/core'
3 | import removeIconUrl from '~/assets/img/remove.svg?url'
4 |
5 | // 插件
6 | import { Menu, SelectionSelect } from '@logicflow/extension'
7 | import { BpmnElement } from '~/extensions'
8 | import { StepTypes } from '~/extensions/bpmn/constant'
9 | import EdgeMenu from '~/extensions/edge-menu'
10 | import Layout from '~/extensions/layout'
11 | import MiniElement from '~/extensions/minielement'
12 | import { MiniMap } from '~/extensions/minimap'
13 |
14 | export const lfConfig: Omit = {
15 | snapline: true,
16 | stopScrollGraph: false,
17 | background: {
18 | backgroundColor: '#F5F5F5',
19 | },
20 | keyboard: {
21 | enabled: true,
22 | },
23 | /** 不允许修改折线的锚点 */
24 | adjustEdgeMiddle: true,
25 | plugins: [
26 | // Snapshot,
27 | BpmnElement,
28 | SelectionSelect,
29 | Layout,
30 | Menu,
31 | EdgeMenu,
32 | MiniMap,
33 | ],
34 | pluginsOptions: {
35 | MiniMap: {
36 | width: 240,
37 | // 小地图会创建一个lf实例,disabledPlugins定义初始化lf时禁用的插件
38 | disabledPlugins: ['BpmnElement', 'menu', 'edgeMenu', 'layout', 'startAnchorMenu', 'snapshot'],
39 | // 自定义miniMap插件,只显示节点轮廓
40 | plugins: [MiniElement],
41 | },
42 | },
43 | }
44 |
45 | export const setMenuConfig = (lf: LogicFlow) => {
46 | const menu = [
47 | {
48 | text: '复制',
49 | callback(node: BaseNodeModel) {
50 | lf.cloneNode(node.id)
51 | },
52 | },
53 | {
54 | text: '删除',
55 | callback(node: BaseNodeModel): void {
56 | // node为该节点数据
57 | lf.deleteNode(node.id)
58 | },
59 | },
60 | ]
61 | lf.setMenuConfig({
62 | nodeMenu: [],
63 | edgeMenu: [],
64 | graphMenu: [], // 覆盖默认的边右键菜单,与false表现一样
65 | })
66 | lf.setMenuByType({
67 | type: StepTypes.Start,
68 | menu,
69 | })
70 | lf.setMenuByType({
71 | type: StepTypes.End,
72 | menu,
73 | })
74 | lf.setEdgeMenuItems([
75 | {
76 | icon: removeIconUrl,
77 | callback(data: BaseEdgeModel) {
78 | lf.deleteEdge(data.id)
79 | },
80 | },
81 | ])
82 | }
83 |
84 | export const nodeDefaultValues = {
85 | [StepTypes.Start]: {
86 | name: '开始',
87 | icon: 'icon-start',
88 | },
89 | [StepTypes.UserTask]: {
90 | name: '用户任务',
91 | icon: 'icon-user',
92 | },
93 | [StepTypes.ServiceTask]: {
94 | name: '系统任务',
95 | icon: 'icon-service',
96 | },
97 | [StepTypes.ScriptTask]: {
98 | name: '数据处理',
99 | icon: 'icon-db',
100 | },
101 | [StepTypes.ExclusiveGateway]: {
102 | name: '条件判断',
103 | icon: 'icon-condition',
104 | },
105 | [StepTypes.End]: {
106 | name: '结束',
107 | icon: 'icon-stop',
108 | },
109 | [StepTypes.Flow]: {},
110 | }
111 |
--------------------------------------------------------------------------------
/src/views/Modeler.vue:
--------------------------------------------------------------------------------
1 |
70 |
71 |
72 |
73 |
74 |
75 |
76 |
77 |
78 |
79 |
80 |
81 |
82 |
83 |
128 |
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "baseUrl": ".",
4 | "module": "ESNext",
5 | "target": "ESNext",
6 | "lib": ["DOM", "ESNext"],
7 | "strict": true,
8 | "esModuleInterop": true,
9 | "jsx": "preserve",
10 | "skipLibCheck": true,
11 | "moduleResolution": "node",
12 | "resolveJsonModule": true,
13 | "importHelpers": true,
14 | "noUnusedLocals": true,
15 | "strictNullChecks": true,
16 | "allowJs": true,
17 | "forceConsistentCasingInFileNames": true,
18 | "experimentalDecorators": true,
19 | "types": ["vite/client", "ant-design-vue/typings/global"],
20 | "paths": {
21 | "~/*": ["src/*"]
22 | }
23 | },
24 | "exclude": ["dist", "node_modules", "eslint.config.js"]
25 | }
26 |
--------------------------------------------------------------------------------
/typings.d.ts:
--------------------------------------------------------------------------------
1 | declare module '*.vue' {
2 | import { type DefineComponent } from 'vue'
3 | const component: DefineComponent<{}, {}, any>
4 | export default component
5 | }
6 |
7 | /** 设置部分key为required */
8 | type RequiredKeys = Required> & Partial>
9 |
--------------------------------------------------------------------------------
/vite.config.ts:
--------------------------------------------------------------------------------
1 | import vue from '@vitejs/plugin-vue'
2 | import path from 'path'
3 | import { AntDesignVueResolver } from 'unplugin-vue-components/resolvers'
4 | import Components from 'unplugin-vue-components/vite'
5 | import type { UserConfigExport } from 'vite'
6 | import { splitVendorChunkPlugin } from 'vite'
7 | import { createHtmlPlugin } from 'vite-plugin-html'
8 |
9 | const config: UserConfigExport = {
10 | resolve: {
11 | alias: [
12 | // ~导入指向node_modules
13 | {
14 | find: /^~(?!\/)/,
15 | replacement: 'node_modules/',
16 | },
17 | {
18 | find: '~/',
19 | replacement: `${path.resolve(__dirname, 'src')}/`,
20 | },
21 | {
22 | find: /^lodash$/,
23 | replacement: 'lodash-es',
24 | },
25 | ],
26 | },
27 | server: {
28 | port: 3000,
29 | },
30 | plugins: [
31 | vue({
32 | include: [/\.vue$/],
33 | }),
34 | // https://github.com/antfu/unplugin-vue-components
35 | Components({
36 | extensions: ['vue'],
37 | // 自动引入./src/components下的组件并生成类型
38 | include: [/\.vue$/, /\.vue\?vue/],
39 | dirs: ['./src/components'],
40 | directoryAsNamespace: true,
41 | dts: 'src/components.d.ts',
42 | globalNamespaces: ['global'],
43 | resolvers: [AntDesignVueResolver()],
44 | }),
45 | createHtmlPlugin({
46 | inject: {
47 | data: {
48 | title: 'LogicFlow Demo',
49 | },
50 | },
51 | }),
52 | splitVendorChunkPlugin(),
53 | ],
54 | css: {
55 | preprocessorOptions: {
56 | less: {
57 | // https://github.com/vueComponent/ant-design-vue/blob/main/components/style/themes/default.less
58 | modifyVars: {
59 | hack: `true; @import "./src/styles/theme.less";`,
60 | },
61 | javascriptEnabled: true,
62 | },
63 | },
64 | },
65 | build: {
66 | minify: 'terser',
67 | terserOptions: {
68 | compress: {
69 | //生成去除调试
70 | drop_console: true,
71 | drop_debugger: true,
72 | },
73 | },
74 | },
75 | }
76 |
77 | export default config
78 |
--------------------------------------------------------------------------------