├── .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 | ![demo](http://logicflow.meiling.fun/demo.png) 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 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | -------------------------------------------------------------------------------- /src/App.vue: -------------------------------------------------------------------------------- 1 | 4 | 5 | 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 | 2 | 3 | 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 | 72 | 73 | 106 | -------------------------------------------------------------------------------- /src/components/DndPanel/icons.ts: -------------------------------------------------------------------------------- 1 | const borderClassName = 'panel-icon-border' 2 | 3 | export const StartIcon = ` 4 | 5 | 6 | 7 | ` 8 | 9 | export const UserTaskIcon = ` 10 | 11 | 12 | 13 | 14 | ` 15 | 16 | export const ServiceTaskIcon = ` 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | ` 29 | 30 | export const ExclusiveGatewayIcon = ` 31 | 32 | 33 | ` 34 | 35 | export const EndIcon = ` 36 | 37 | 38 | 39 | ` 40 | 41 | export const ScriptTaskIcon = ` 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | ` 52 | -------------------------------------------------------------------------------- /src/components/DndPanel/index.vue: -------------------------------------------------------------------------------- 1 | 70 | 71 | 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 | 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 | 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 | --------------------------------------------------------------------------------