├── .babelrc ├── .editorconfig ├── .eslintignore ├── .eslintrc.js ├── .gitignore ├── .postcssrc.js ├── README.md ├── build ├── build.js ├── check-versions.js ├── logo.png ├── utils.js ├── vue-loader.conf.js ├── webpack.base.conf.js ├── webpack.dev.conf.js ├── webpack.prod.conf.js └── webpack.test.conf.js ├── config ├── dev.env.js ├── index.js ├── prod.env.js └── test.env.js ├── index.html ├── package.json ├── src ├── App.vue ├── assets │ └── logo.png ├── components │ ├── FcCanvas.vue │ ├── FcConnector.vue │ ├── FcEdge.vue │ ├── FcEdgeLabel.vue │ ├── FcMagnet.vue │ └── FcNode.vue ├── config │ └── flowchart.js ├── main.js ├── pages │ └── Index.vue ├── router │ └── index.js ├── service │ ├── edgedragging.js │ ├── edgedrawing.js │ ├── modelvalidation.js │ ├── nodedragging.js │ ├── rectangleselect.js │ ├── store.js │ └── topsort.js └── utils │ ├── setDragImage.polyfill.js │ └── uuid.js ├── static └── .gitkeep └── test ├── e2e ├── custom-assertions │ └── elementCount.js ├── nightwatch.conf.js ├── runner.js └── specs │ └── test.js └── unit ├── .eslintrc ├── index.js ├── karma.conf.js └── specs └── HelloWorld.spec.js /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": [ 3 | ["env", { 4 | "modules": false, 5 | "targets": { 6 | "browsers": ["> 1%", "last 2 versions", "not ie <= 8"] 7 | } 8 | }], 9 | "stage-2" 10 | ], 11 | "plugins": ["transform-vue-jsx", "transform-runtime"], 12 | "env": { 13 | "test": { 14 | "presets": ["env", "stage-2"], 15 | "plugins": ["transform-vue-jsx", "istanbul"] 16 | } 17 | }, 18 | "ignore":["/src/utils"] 19 | } 20 | -------------------------------------------------------------------------------- /.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 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | /build/ 2 | /config/ 3 | /dist/ 4 | /*.js 5 | /test/unit/coverage/ 6 | -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | // http://eslint.org/docs/user-guide/configuring 2 | 3 | module.exports = { 4 | root: true, 5 | 6 | // parser: 'babel-eslint', 7 | parserOptions: { 8 | sourceType: 'module', 9 | parser: 'babel-eslint', 10 | }, 11 | env: { 12 | browser: true, 13 | }, 14 | // https://github.com/feross/standard/blob/master/RULES.md#javascript-standard-style 15 | extends: [ 16 | "plugin:vue/recommended", 17 | "standard" 18 | ], 19 | // required to lint *.vue files 20 | plugins: [ 21 | 'vue' 22 | ], 23 | // add your custom rules here 24 | 'rules': { 25 | // allow paren-less arrow functions 26 | 'arrow-parens': 0, 27 | // allow async-await 28 | 'generator-star-spacing': 0, 29 | // allow debugger during development 30 | 'no-debugger': process.env.NODE_ENV === 'production' ? 2 : 0 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | node_modules/ 3 | /dist/ 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | /test/unit/coverage/ 8 | /test/e2e/reports/ 9 | selenium-debug.log 10 | 11 | # Editor directories and files 12 | .idea 13 | .vscode 14 | *.suo 15 | *.ntvs* 16 | *.njsproj 17 | *.sln 18 | -------------------------------------------------------------------------------- /.postcssrc.js: -------------------------------------------------------------------------------- 1 | // https://github.com/michael-ciniawsky/postcss-load-config 2 | 3 | module.exports = { 4 | "plugins": { 5 | "postcss-import": {}, 6 | "postcss-url": {}, 7 | // to edit target browsers: use "browserslist" field in package.json 8 | "autoprefixer": {} 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ## vue-flowchart 2 | 3 | > 基于Vue.js的流程图框架 4 | 5 | 目前进度: 6 | 7 | - [x] 数据显示 8 | - [x] 节点拖拽 9 | - [x] 连线拖拽 10 | - [x] 多选,划框选择 11 | - [x] 多元素拖拽 12 | - [x] 节点创建:点击新增&预置节点拖拽新增 13 | - [x] 节点、连线编辑和删除 14 | - [x] 画板自适应 15 | - [x] 复制粘贴节点 16 | - [ ] 快捷键处理 17 | - [ ] 画板缩放 18 | - [x] 撤销重做 19 | - [ ] 节点svg化 20 | - [x] 悬浮节点提示 21 | 22 | 23 | ## 构建 24 | 25 | ``` bash 26 | # install dependencies 27 | npm install 28 | 29 | # serve with hot reload at localhost:8080 30 | npm run dev 31 | 32 | # build for production with minification 33 | npm run build 34 | 35 | # build for production and view the bundle analyzer report 36 | npm run build --report 37 | 38 | # run unit tests 39 | npm run unit 40 | 41 | # run e2e tests 42 | npm run e2e 43 | 44 | # run all tests 45 | npm test 46 | ``` 47 | ## 使用文档 48 | 49 | ... 50 | 51 | ## 定义 52 | 53 | ### 画板 Canvas 54 | 55 | 分为两种组件,一种是主界面,一种是放置模板节点的这里叫模板界面,可以有多个模板界面。 56 | 57 | 对于模板界面,没有连线,且仅支持节点拖拽和鼠标悬浮事件 58 | 59 | canvas画板由一个 model 对象控制数据,其中包含两种元素,即节点和连线 60 | 61 | ```javascript 62 | { 63 | nodes: [Node], 64 | edges: [Edge] 65 | } 66 | ``` 67 | 68 | 元素的操作,除了在各种组件上的处理外,事件还会派发到canvas上,由canvas决定是否推入历史栈 69 | 70 | 一些功能按钮,在外部通过slot插槽传入canvas组件,样式让用户自定义 71 | 72 | ### 节点 Node 73 | 74 | 主要元素之一 75 | 76 | ```js 77 | { 78 | id: Number | String, //唯一标识符 79 | name: String,//节点名称 80 | x: Number, // 节点相对canvas的x坐标 81 | y: Number, // 节点相对canvas的y坐标 82 | connectors: [Connector],// 连接点 83 | readonly: Boolean=false,// 是否只读,只读模式下仅支持鼠标悬浮事件 84 | addition:Object// 支持拓展 85 | } 86 | ``` 87 | 88 | ### 连接点 Connector 89 | 90 | 一个node最多仅有两个connector(左右两侧两种类型) 91 | 92 | 用于连线时使用 93 | 94 | ```javascript 95 | { 96 | // 连接点类型,取值范围:leftConnector/rightConnector 97 | [type: String]: { 98 | id: Number | String, //唯一标识符 99 | } 100 | } 101 | ``` 102 | 103 | 组件配置: 104 | 105 | 106 | ### 连线 Edge 107 | 108 | 主要元素之一 109 | 110 | 由某节点的rightConnector连接点和另一节点的leftConnector连接点相连,带箭头 111 | 112 | ```javascript 113 | { 114 | source: Connector.id, 115 | destination: Connector.id, 116 | active: Boolean=false,// 应用连线流动动画 117 | label:String//连线的标签 118 | } 119 | ``` 120 | 121 | ### 标签 122 | 123 | 处于连线中心 124 | 125 | ### 其他坐标 126 | 127 | event.clientX: 基于网页左上角的x坐标 128 | 129 | getBoundingClientRect: 相对视口的坐标(相对),而不是网页左上角(绝对),比如有滚动条且向下滚动,获得的top值偏小 130 | 131 | ## 拖拽 132 | 133 | 连线随节点变动而变动 134 | 135 | 如何做到高效实时拖拽? 136 | 137 | ### 1. 监听待拖放节点的drag事件,跟随clientX/Y移动 138 | 139 | ```js 140 | handleDragstart () { 141 | console.log('node Dragstart:', event) 142 | let elementBox = this.$el.getBoundingClientRect() 143 | this.eventPointOffset.x = event.clientX - elementBox.left 144 | this.eventPointOffset.y = event.clientY - elementBox.top 145 | // this.nodedraggingservice.dragstart(event) 146 | let dataTransfer = event.dataTransfer 147 | dataTransfer.dropEffect = 'move' 148 | dataTransfer.setData('Text', event.target.id) 149 | dataTransfer.setDragImage(this.$el, this.eventPointOffset.x, this.eventPointOffset.y) 150 | this.$emit('node-dragstart', this.node) 151 | this.updateConnectorPosition() 152 | }, 153 | handleDragging (event) { 154 | console.log('handleDragging') 155 | if (!(event.clientX && event.clientY)) { 156 | return 157 | } 158 | let newNode = Object.assign(this.node, { 159 | x: event.clientX - this.canvas.left - this.eventPointOffset.x, 160 | y: event.clientY - this.canvas.top - this.eventPointOffset.y 161 | }) 162 | this.updateNode({ 163 | node: this.node, 164 | newNode 165 | }) 166 | this.updateConnectorPosition() 167 | }, 168 | handleDragend () { 169 | console.log('node Dragend:', event) 170 | let newNode = Object.assign(this.node, { 171 | x: event.clientX - this.canvas.left - this.eventPointOffset.x, 172 | y: event.clientY - this.canvas.top - this.eventPointOffset.y 173 | }) 174 | this.updateNode({ 175 | node: this.node, 176 | newNode, 177 | isPushState: true 178 | }) 179 | this.$emit('node-dragend', event) 180 | // this.updateConnectorPosition() 181 | } 182 | ``` 183 | firefox 下 drag,dragend拿到的event.clientX/Y 为0,只能从容器的 dragover 和drop事件的event中获取 184 | 185 | firefox 必须使用`dataTransfer.setData('text', xxx)` ie11上会报错且getData拿不到数据? 186 | 187 | dragover时拿不到`dataTransfer.getData` 188 | 189 | ### 2. 触发容器的dragover事件 190 | 191 | 这样做的好处在于,被拖动元素将不可拖动至任意地方,仅在容器中重绘 192 | 193 | chrome 中 dragover 不能拿到`event.dataTransfer.getData('Text')` 194 | 195 | ### 采用mouse代替drag 196 | 197 | 从测试上来看,mouse兼容性好,且性能更好一点 198 | ### 批量拖拽 199 | 200 | ## 鼠标悬浮mouseOver 201 | 202 | 类似 mouseover,它们两者之间的差别是 mouseenter 不会冒泡(bubble) -------------------------------------------------------------------------------- /build/build.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | require('./check-versions')() 3 | 4 | process.env.NODE_ENV = 'production' 5 | 6 | const ora = require('ora') 7 | const rm = require('rimraf') 8 | const path = require('path') 9 | const chalk = require('chalk') 10 | const webpack = require('webpack') 11 | const config = require('../config') 12 | const webpackConfig = require('./webpack.prod.conf') 13 | 14 | const spinner = ora('building for production...') 15 | spinner.start() 16 | 17 | rm(path.join(config.build.assetsRoot, config.build.assetsSubDirectory), err => { 18 | if (err) throw err 19 | webpack(webpackConfig, (err, stats) => { 20 | spinner.stop() 21 | if (err) throw err 22 | process.stdout.write(stats.toString({ 23 | colors: true, 24 | modules: false, 25 | children: false, // If you are using ts-loader, setting this to true will make TypeScript errors show up during build. 26 | chunks: false, 27 | chunkModules: false 28 | }) + '\n\n') 29 | 30 | if (stats.hasErrors()) { 31 | console.log(chalk.red(' Build failed with errors.\n')) 32 | process.exit(1) 33 | } 34 | 35 | console.log(chalk.cyan(' Build complete.\n')) 36 | console.log(chalk.yellow( 37 | ' Tip: built files are meant to be served over an HTTP server.\n' + 38 | ' Opening index.html over file:// won\'t work.\n' 39 | )) 40 | }) 41 | }) 42 | -------------------------------------------------------------------------------- /build/check-versions.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | const chalk = require('chalk') 3 | const semver = require('semver') 4 | const packageConfig = require('../package.json') 5 | const shell = require('shelljs') 6 | 7 | function exec (cmd) { 8 | return require('child_process').execSync(cmd).toString().trim() 9 | } 10 | 11 | const versionRequirements = [ 12 | { 13 | name: 'node', 14 | currentVersion: semver.clean(process.version), 15 | versionRequirement: packageConfig.engines.node 16 | } 17 | ] 18 | 19 | if (shell.which('npm')) { 20 | versionRequirements.push({ 21 | name: 'npm', 22 | currentVersion: exec('npm --version'), 23 | versionRequirement: packageConfig.engines.npm 24 | }) 25 | } 26 | 27 | module.exports = function () { 28 | const warnings = [] 29 | 30 | for (let i = 0; i < versionRequirements.length; i++) { 31 | const mod = versionRequirements[i] 32 | 33 | if (!semver.satisfies(mod.currentVersion, mod.versionRequirement)) { 34 | warnings.push(mod.name + ': ' + 35 | chalk.red(mod.currentVersion) + ' should be ' + 36 | chalk.green(mod.versionRequirement) 37 | ) 38 | } 39 | } 40 | 41 | if (warnings.length) { 42 | console.log('') 43 | console.log(chalk.yellow('To use this template, you must update following to modules:')) 44 | console.log() 45 | 46 | for (let i = 0; i < warnings.length; i++) { 47 | const warning = warnings[i] 48 | console.log(' ' + warning) 49 | } 50 | 51 | console.log() 52 | process.exit(1) 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /build/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/francecil/vue-flowchart/89c614b682934d600125850d9095ee9da18eb0b2/build/logo.png -------------------------------------------------------------------------------- /build/utils.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | const path = require('path') 3 | const config = require('../config') 4 | const ExtractTextPlugin = require('extract-text-webpack-plugin') 5 | const packageConfig = require('../package.json') 6 | 7 | exports.assetsPath = function (_path) { 8 | const assetsSubDirectory = process.env.NODE_ENV === 'production' 9 | ? config.build.assetsSubDirectory 10 | : config.dev.assetsSubDirectory 11 | 12 | return path.posix.join(assetsSubDirectory, _path) 13 | } 14 | 15 | exports.cssLoaders = function (options) { 16 | options = options || {} 17 | 18 | const cssLoader = { 19 | loader: 'css-loader', 20 | options: { 21 | sourceMap: options.sourceMap 22 | } 23 | } 24 | 25 | const postcssLoader = { 26 | loader: 'postcss-loader', 27 | options: { 28 | sourceMap: options.sourceMap 29 | } 30 | } 31 | 32 | // generate loader string to be used with extract text plugin 33 | function generateLoaders (loader, loaderOptions) { 34 | const loaders = options.usePostCSS ? [cssLoader, postcssLoader] : [cssLoader] 35 | 36 | if (loader) { 37 | loaders.push({ 38 | loader: loader + '-loader', 39 | options: Object.assign({}, loaderOptions, { 40 | sourceMap: options.sourceMap 41 | }) 42 | }) 43 | } 44 | 45 | // Extract CSS when that option is specified 46 | // (which is the case during production build) 47 | if (options.extract) { 48 | return ExtractTextPlugin.extract({ 49 | use: loaders, 50 | fallback: 'vue-style-loader' 51 | }) 52 | } else { 53 | return ['vue-style-loader'].concat(loaders) 54 | } 55 | } 56 | 57 | // https://vue-loader.vuejs.org/en/configurations/extract-css.html 58 | return { 59 | css: generateLoaders(), 60 | postcss: generateLoaders(), 61 | less: generateLoaders('less'), 62 | sass: generateLoaders('sass', { indentedSyntax: true }), 63 | scss: generateLoaders('sass'), 64 | stylus: generateLoaders('stylus'), 65 | styl: generateLoaders('stylus') 66 | } 67 | } 68 | 69 | // Generate loaders for standalone style files (outside of .vue) 70 | exports.styleLoaders = function (options) { 71 | const output = [] 72 | const loaders = exports.cssLoaders(options) 73 | 74 | for (const extension in loaders) { 75 | const loader = loaders[extension] 76 | output.push({ 77 | test: new RegExp('\\.' + extension + '$'), 78 | use: loader 79 | }) 80 | } 81 | 82 | return output 83 | } 84 | 85 | exports.createNotifierCallback = () => { 86 | const notifier = require('node-notifier') 87 | 88 | return (severity, errors) => { 89 | if (severity !== 'error') return 90 | 91 | const error = errors[0] 92 | const filename = error.file && error.file.split('!').pop() 93 | 94 | notifier.notify({ 95 | title: packageConfig.name, 96 | message: severity + ': ' + error.name, 97 | subtitle: filename || '', 98 | icon: path.join(__dirname, 'logo.png') 99 | }) 100 | } 101 | } 102 | -------------------------------------------------------------------------------- /build/vue-loader.conf.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | const utils = require('./utils') 3 | const config = require('../config') 4 | const isProduction = process.env.NODE_ENV === 'production' 5 | const sourceMapEnabled = isProduction 6 | ? config.build.productionSourceMap 7 | : config.dev.cssSourceMap 8 | 9 | module.exports = { 10 | loaders: utils.cssLoaders({ 11 | sourceMap: sourceMapEnabled, 12 | extract: isProduction 13 | }), 14 | cssSourceMap: sourceMapEnabled, 15 | cacheBusting: config.dev.cacheBusting, 16 | transformToRequire: { 17 | video: ['src', 'poster'], 18 | source: 'src', 19 | img: 'src', 20 | image: 'xlink:href' 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /build/webpack.base.conf.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | const path = require('path') 3 | const utils = require('./utils') 4 | const config = require('../config') 5 | const vueLoaderConfig = require('./vue-loader.conf') 6 | require('babel-polyfill') 7 | 8 | function resolve (dir) { 9 | return path.join(__dirname, '..', dir) 10 | } 11 | 12 | const createLintingRule = () => ({ 13 | test: /\.(js|vue)$/, 14 | loader: 'eslint-loader', 15 | enforce: 'pre', 16 | include: [resolve('src'), resolve('test')], 17 | options: { 18 | formatter: require('eslint-friendly-formatter'), 19 | emitWarning: !config.dev.showEslintErrorsInOverlay 20 | } 21 | }) 22 | 23 | module.exports = { 24 | context: path.resolve(__dirname, '../'), 25 | entry: { 26 | app: ['babel-polyfill', './src/main.js'] 27 | }, 28 | output: { 29 | path: config.build.assetsRoot, 30 | filename: '[name].js', 31 | publicPath: process.env.NODE_ENV === 'production' 32 | ? config.build.assetsPublicPath 33 | : config.dev.assetsPublicPath 34 | }, 35 | resolve: { 36 | extensions: ['.js', '.vue', '.json'], 37 | alias: { 38 | 'vue$': 'vue/dist/vue.esm.js', 39 | '@': resolve('src'), 40 | } 41 | }, 42 | module: { 43 | rules: [ 44 | ...(config.dev.useEslint ? [createLintingRule()] : []), 45 | { 46 | test: /\.vue$/, 47 | loader: 'vue-loader', 48 | options: vueLoaderConfig 49 | }, 50 | { 51 | test: /\.js$/, 52 | loader: 'babel-loader', 53 | include: [resolve('src'), resolve('test'), resolve('node_modules/webpack-dev-server/client')] 54 | }, 55 | { 56 | test: /\.(png|jpe?g|gif|svg)(\?.*)?$/, 57 | loader: 'url-loader', 58 | options: { 59 | limit: 10000, 60 | name: utils.assetsPath('img/[name].[hash:7].[ext]') 61 | } 62 | }, 63 | { 64 | test: /\.(mp4|webm|ogg|mp3|wav|flac|aac)(\?.*)?$/, 65 | loader: 'url-loader', 66 | options: { 67 | limit: 10000, 68 | name: utils.assetsPath('media/[name].[hash:7].[ext]') 69 | } 70 | }, 71 | { 72 | test: /\.(woff2?|eot|ttf|otf)(\?.*)?$/, 73 | loader: 'url-loader', 74 | options: { 75 | limit: 10000, 76 | name: utils.assetsPath('fonts/[name].[hash:7].[ext]') 77 | } 78 | } 79 | ] 80 | }, 81 | node: { 82 | // prevent webpack from injecting useless setImmediate polyfill because Vue 83 | // source contains it (although only uses it if it's native). 84 | setImmediate: false, 85 | // prevent webpack from injecting mocks to Node native modules 86 | // that does not make sense for the client 87 | dgram: 'empty', 88 | fs: 'empty', 89 | net: 'empty', 90 | tls: 'empty', 91 | child_process: 'empty' 92 | } 93 | } 94 | -------------------------------------------------------------------------------- /build/webpack.dev.conf.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | const utils = require('./utils') 3 | const webpack = require('webpack') 4 | const config = require('../config') 5 | const merge = require('webpack-merge') 6 | const path = require('path') 7 | const baseWebpackConfig = require('./webpack.base.conf') 8 | const CopyWebpackPlugin = require('copy-webpack-plugin') 9 | const HtmlWebpackPlugin = require('html-webpack-plugin') 10 | const FriendlyErrorsPlugin = require('friendly-errors-webpack-plugin') 11 | const portfinder = require('portfinder') 12 | 13 | const HOST = process.env.HOST 14 | const PORT = process.env.PORT && Number(process.env.PORT) 15 | 16 | const devWebpackConfig = merge(baseWebpackConfig, { 17 | module: { 18 | rules: utils.styleLoaders({ sourceMap: config.dev.cssSourceMap, usePostCSS: true }) 19 | }, 20 | // cheap-module-eval-source-map is faster for development 21 | devtool: config.dev.devtool, 22 | 23 | // these devServer options should be customized in /config/index.js 24 | devServer: { 25 | clientLogLevel: 'warning', 26 | historyApiFallback: { 27 | rewrites: [ 28 | { from: /.*/, to: path.posix.join(config.dev.assetsPublicPath, 'index.html') }, 29 | ], 30 | }, 31 | hot: true, 32 | contentBase: false, // since we use CopyWebpackPlugin. 33 | compress: true, 34 | host: HOST || config.dev.host, 35 | port: PORT || config.dev.port, 36 | open: config.dev.autoOpenBrowser, 37 | overlay: config.dev.errorOverlay 38 | ? { warnings: false, errors: true } 39 | : false, 40 | publicPath: config.dev.assetsPublicPath, 41 | proxy: config.dev.proxyTable, 42 | quiet: true, // necessary for FriendlyErrorsPlugin 43 | watchOptions: { 44 | poll: config.dev.poll, 45 | } 46 | }, 47 | plugins: [ 48 | new webpack.DefinePlugin({ 49 | 'process.env': require('../config/dev.env') 50 | }), 51 | new webpack.HotModuleReplacementPlugin(), 52 | new webpack.NamedModulesPlugin(), // HMR shows correct file names in console on update. 53 | new webpack.NoEmitOnErrorsPlugin(), 54 | // https://github.com/ampedandwired/html-webpack-plugin 55 | new HtmlWebpackPlugin({ 56 | filename: 'index.html', 57 | template: 'index.html', 58 | inject: true 59 | }), 60 | // copy custom static assets 61 | new CopyWebpackPlugin([ 62 | { 63 | from: path.resolve(__dirname, '../static'), 64 | to: config.dev.assetsSubDirectory, 65 | ignore: ['.*'] 66 | } 67 | ]) 68 | ] 69 | }) 70 | 71 | module.exports = new Promise((resolve, reject) => { 72 | portfinder.basePort = process.env.PORT || config.dev.port 73 | portfinder.getPort((err, port) => { 74 | if (err) { 75 | reject(err) 76 | } else { 77 | // publish the new Port, necessary for e2e tests 78 | process.env.PORT = port 79 | // add port to devServer config 80 | devWebpackConfig.devServer.port = port 81 | 82 | // Add FriendlyErrorsPlugin 83 | devWebpackConfig.plugins.push(new FriendlyErrorsPlugin({ 84 | compilationSuccessInfo: { 85 | messages: [`Your application is running here: http://${devWebpackConfig.devServer.host}:${port}`], 86 | }, 87 | onErrors: config.dev.notifyOnErrors 88 | ? utils.createNotifierCallback() 89 | : undefined 90 | })) 91 | 92 | resolve(devWebpackConfig) 93 | } 94 | }) 95 | }) 96 | -------------------------------------------------------------------------------- /build/webpack.prod.conf.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | const path = require('path') 3 | const utils = require('./utils') 4 | const webpack = require('webpack') 5 | const config = require('../config') 6 | const merge = require('webpack-merge') 7 | const baseWebpackConfig = require('./webpack.base.conf') 8 | const CopyWebpackPlugin = require('copy-webpack-plugin') 9 | const HtmlWebpackPlugin = require('html-webpack-plugin') 10 | const ExtractTextPlugin = require('extract-text-webpack-plugin') 11 | const OptimizeCSSPlugin = require('optimize-css-assets-webpack-plugin') 12 | const UglifyJsPlugin = require('uglifyjs-webpack-plugin') 13 | 14 | const env = process.env.NODE_ENV === 'testing' 15 | ? require('../config/test.env') 16 | : require('../config/prod.env') 17 | 18 | const webpackConfig = merge(baseWebpackConfig, { 19 | module: { 20 | rules: utils.styleLoaders({ 21 | sourceMap: config.build.productionSourceMap, 22 | extract: true, 23 | usePostCSS: true 24 | }) 25 | }, 26 | devtool: config.build.productionSourceMap ? config.build.devtool : false, 27 | output: { 28 | path: config.build.assetsRoot, 29 | filename: utils.assetsPath('js/[name].[chunkhash].js'), 30 | chunkFilename: utils.assetsPath('js/[id].[chunkhash].js') 31 | }, 32 | plugins: [ 33 | // http://vuejs.github.io/vue-loader/en/workflow/production.html 34 | new webpack.DefinePlugin({ 35 | 'process.env': env 36 | }), 37 | new UglifyJsPlugin({ 38 | uglifyOptions: { 39 | compress: { 40 | warnings: false 41 | } 42 | }, 43 | sourceMap: config.build.productionSourceMap, 44 | parallel: true 45 | }), 46 | // extract css into its own file 47 | new ExtractTextPlugin({ 48 | filename: utils.assetsPath('css/[name].[contenthash].css'), 49 | // Setting the following option to `false` will not extract CSS from codesplit chunks. 50 | // Their CSS will instead be inserted dynamically with style-loader when the codesplit chunk has been loaded by webpack. 51 | // It's currently set to `true` because we are seeing that sourcemaps are included in the codesplit bundle as well when it's `false`, 52 | // increasing file size: https://github.com/vuejs-templates/webpack/issues/1110 53 | allChunks: true, 54 | }), 55 | // Compress extracted CSS. We are using this plugin so that possible 56 | // duplicated CSS from different components can be deduped. 57 | new OptimizeCSSPlugin({ 58 | cssProcessorOptions: config.build.productionSourceMap 59 | ? { safe: true, map: { inline: false } } 60 | : { safe: true } 61 | }), 62 | // generate dist index.html with correct asset hash for caching. 63 | // you can customize output by editing /index.html 64 | // see https://github.com/ampedandwired/html-webpack-plugin 65 | new HtmlWebpackPlugin({ 66 | filename: process.env.NODE_ENV === 'testing' 67 | ? 'index.html' 68 | : config.build.index, 69 | template: 'index.html', 70 | inject: true, 71 | minify: { 72 | removeComments: true, 73 | collapseWhitespace: true, 74 | removeAttributeQuotes: true 75 | // more options: 76 | // https://github.com/kangax/html-minifier#options-quick-reference 77 | }, 78 | // necessary to consistently work with multiple chunks via CommonsChunkPlugin 79 | chunksSortMode: 'dependency' 80 | }), 81 | // keep module.id stable when vendor modules does not change 82 | new webpack.HashedModuleIdsPlugin(), 83 | // enable scope hoisting 84 | new webpack.optimize.ModuleConcatenationPlugin(), 85 | // split vendor js into its own file 86 | new webpack.optimize.CommonsChunkPlugin({ 87 | name: 'vendor', 88 | minChunks (module) { 89 | // any required modules inside node_modules are extracted to vendor 90 | return ( 91 | module.resource && 92 | /\.js$/.test(module.resource) && 93 | module.resource.indexOf( 94 | path.join(__dirname, '../node_modules') 95 | ) === 0 96 | ) 97 | } 98 | }), 99 | // extract webpack runtime and module manifest to its own file in order to 100 | // prevent vendor hash from being updated whenever app bundle is updated 101 | new webpack.optimize.CommonsChunkPlugin({ 102 | name: 'manifest', 103 | minChunks: Infinity 104 | }), 105 | // This instance extracts shared chunks from code splitted chunks and bundles them 106 | // in a separate chunk, similar to the vendor chunk 107 | // see: https://webpack.js.org/plugins/commons-chunk-plugin/#extra-async-commons-chunk 108 | new webpack.optimize.CommonsChunkPlugin({ 109 | name: 'app', 110 | async: 'vendor-async', 111 | children: true, 112 | minChunks: 3 113 | }), 114 | 115 | // copy custom static assets 116 | new CopyWebpackPlugin([ 117 | { 118 | from: path.resolve(__dirname, '../static'), 119 | to: config.build.assetsSubDirectory, 120 | ignore: ['.*'] 121 | } 122 | ]) 123 | ] 124 | }) 125 | 126 | if (config.build.productionGzip) { 127 | const CompressionWebpackPlugin = require('compression-webpack-plugin') 128 | 129 | webpackConfig.plugins.push( 130 | new CompressionWebpackPlugin({ 131 | asset: '[path].gz[query]', 132 | algorithm: 'gzip', 133 | test: new RegExp( 134 | '\\.(' + 135 | config.build.productionGzipExtensions.join('|') + 136 | ')$' 137 | ), 138 | threshold: 10240, 139 | minRatio: 0.8 140 | }) 141 | ) 142 | } 143 | 144 | if (config.build.bundleAnalyzerReport) { 145 | const BundleAnalyzerPlugin = require('webpack-bundle-analyzer').BundleAnalyzerPlugin 146 | webpackConfig.plugins.push(new BundleAnalyzerPlugin()) 147 | } 148 | 149 | module.exports = webpackConfig 150 | -------------------------------------------------------------------------------- /build/webpack.test.conf.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | // This is the webpack config used for unit tests. 3 | 4 | const utils = require('./utils') 5 | const webpack = require('webpack') 6 | const merge = require('webpack-merge') 7 | const baseWebpackConfig = require('./webpack.base.conf') 8 | 9 | const webpackConfig = merge(baseWebpackConfig, { 10 | // use inline sourcemap for karma-sourcemap-loader 11 | module: { 12 | rules: utils.styleLoaders() 13 | }, 14 | devtool: '#inline-source-map', 15 | resolveLoader: { 16 | alias: { 17 | // necessary to to make lang="scss" work in test when using vue-loader's ?inject option 18 | // see discussion at https://github.com/vuejs/vue-loader/issues/724 19 | 'scss-loader': 'sass-loader' 20 | } 21 | }, 22 | plugins: [ 23 | new webpack.DefinePlugin({ 24 | 'process.env': require('../config/test.env') 25 | }) 26 | ] 27 | }) 28 | 29 | // no need for app entry during tests 30 | delete webpackConfig.entry 31 | 32 | module.exports = webpackConfig 33 | -------------------------------------------------------------------------------- /config/dev.env.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | const merge = require('webpack-merge') 3 | const prodEnv = require('./prod.env') 4 | 5 | module.exports = merge(prodEnv, { 6 | NODE_ENV: '"development"' 7 | }) 8 | -------------------------------------------------------------------------------- /config/index.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | // Template version: 1.3.1 3 | // see http://vuejs-templates.github.io/webpack for documentation. 4 | 5 | const path = require('path') 6 | 7 | module.exports = { 8 | dev: { 9 | 10 | // Paths 11 | assetsSubDirectory: 'static', 12 | assetsPublicPath: '/', 13 | proxyTable: {}, 14 | 15 | // Various Dev Server settings 16 | host: '10.8.149.94', // can be overwritten by process.env.HOST 17 | port: 8080, // can be overwritten by process.env.PORT, if port is in use, a free one will be determined 18 | autoOpenBrowser: false, 19 | errorOverlay: true, 20 | notifyOnErrors: true, 21 | poll: false, // https://webpack.js.org/configuration/dev-server/#devserver-watchoptions- 22 | 23 | // Use Eslint Loader? 24 | // If true, your code will be linted during bundling and 25 | // linting errors and warnings will be shown in the console. 26 | useEslint: true, 27 | // If true, eslint errors and warnings will also be shown in the error overlay 28 | // in the browser. 29 | showEslintErrorsInOverlay: false, 30 | 31 | /** 32 | * Source Maps 33 | */ 34 | 35 | // https://webpack.js.org/configuration/devtool/#development 36 | devtool: 'cheap-module-eval-source-map', 37 | 38 | // If you have problems debugging vue-files in devtools, 39 | // set this to false - it *may* help 40 | // https://vue-loader.vuejs.org/en/options.html#cachebusting 41 | cacheBusting: true, 42 | 43 | cssSourceMap: true 44 | }, 45 | 46 | build: { 47 | // Template for index.html 48 | index: path.resolve(__dirname, '../dist/index.html'), 49 | 50 | // Paths 51 | assetsRoot: path.resolve(__dirname, '../dist'), 52 | assetsSubDirectory: 'static', 53 | assetsPublicPath: '/', 54 | 55 | /** 56 | * Source Maps 57 | */ 58 | 59 | productionSourceMap: true, 60 | // https://webpack.js.org/configuration/devtool/#production 61 | devtool: '#source-map', 62 | 63 | // Gzip off by default as many popular static hosts such as 64 | // Surge or Netlify already gzip all static assets for you. 65 | // Before setting to `true`, make sure to: 66 | // npm install --save-dev compression-webpack-plugin 67 | productionGzip: false, 68 | productionGzipExtensions: ['js', 'css'], 69 | 70 | // Run the build command with an extra argument to 71 | // View the bundle analyzer report after build finishes: 72 | // `npm run build --report` 73 | // Set to `true` or `false` to always turn it on or off 74 | bundleAnalyzerReport: process.env.npm_config_report 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /config/prod.env.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | module.exports = { 3 | NODE_ENV: '"production"' 4 | } 5 | -------------------------------------------------------------------------------- /config/test.env.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | const merge = require('webpack-merge') 3 | const devEnv = require('./dev.env') 4 | 5 | module.exports = merge(devEnv, { 6 | NODE_ENV: '"testing"' 7 | }) 8 | -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | vue-flowchart 7 | 8 | 9 |
10 | 11 | 12 | 13 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "vue-flowchart", 3 | "version": "1.0.0", 4 | "description": "A Vue.js project", 5 | "author": "gahing", 6 | "private": true, 7 | "scripts": { 8 | "dev": "webpack-dev-server --inline --progress --config build/webpack.dev.conf.js", 9 | "start": "npm run dev", 10 | "unit": "cross-env BABEL_ENV=test karma start test/unit/karma.conf.js --single-run", 11 | "e2e": "node test/e2e/runner.js", 12 | "test": "npm run unit && npm run e2e", 13 | "lint": "eslint --ext .js,.vue src test/unit test/e2e/specs", 14 | "build": "node build/build.js" 15 | }, 16 | "dependencies": { 17 | "element-ui": "^2.5.4", 18 | "vue": "^2.5.2", 19 | "vue-router": "^3.0.1" 20 | }, 21 | "devDependencies": { 22 | "autoprefixer": "^7.1.2", 23 | "babel-core": "^6.22.1", 24 | "babel-eslint": "^8.2.1", 25 | "babel-helper-vue-jsx-merge-props": "^2.0.3", 26 | "babel-loader": "^7.1.1", 27 | "babel-plugin-istanbul": "^4.1.1", 28 | "babel-plugin-syntax-jsx": "^6.18.0", 29 | "babel-plugin-transform-runtime": "^6.22.0", 30 | "babel-plugin-transform-vue-jsx": "^3.5.0", 31 | "babel-polyfill": "^6.26.0", 32 | "babel-preset-env": "^1.3.2", 33 | "babel-preset-stage-2": "^6.22.0", 34 | "babel-register": "^6.22.0", 35 | "chai": "^4.1.2", 36 | "chalk": "^2.0.1", 37 | "chromedriver": "^2.27.2", 38 | "copy-webpack-plugin": "^4.0.1", 39 | "cross-env": "^5.0.1", 40 | "cross-spawn": "^5.0.1", 41 | "css-loader": "^0.28.0", 42 | "eslint": "^4.15.0", 43 | "eslint-config-standard": "^10.2.1", 44 | "eslint-friendly-formatter": "^3.0.0", 45 | "eslint-loader": "^1.7.1", 46 | "eslint-plugin-import": "^2.7.0", 47 | "eslint-plugin-node": "^5.2.0", 48 | "eslint-plugin-promise": "^3.4.0", 49 | "eslint-plugin-standard": "^3.0.1", 50 | "eslint-plugin-vue": "^4.0.0", 51 | "extract-text-webpack-plugin": "^3.0.0", 52 | "file-loader": "^1.1.4", 53 | "friendly-errors-webpack-plugin": "^1.6.1", 54 | "html-webpack-plugin": "^2.30.1", 55 | "inject-loader": "^3.0.0", 56 | "karma": "^1.4.1", 57 | "karma-coverage": "^1.1.1", 58 | "karma-mocha": "^1.3.0", 59 | "karma-phantomjs-launcher": "^1.0.2", 60 | "karma-phantomjs-shim": "^1.4.0", 61 | "karma-sinon-chai": "^1.3.1", 62 | "karma-sourcemap-loader": "^0.3.7", 63 | "karma-spec-reporter": "0.0.31", 64 | "karma-webpack": "^2.0.2", 65 | "mocha": "^3.2.0", 66 | "nightwatch": "^0.9.12", 67 | "node-notifier": "^5.1.2", 68 | "optimize-css-assets-webpack-plugin": "^3.2.0", 69 | "ora": "^1.2.0", 70 | "phantomjs-prebuilt": "^2.1.14", 71 | "portfinder": "^1.0.13", 72 | "postcss-import": "^11.0.0", 73 | "postcss-loader": "^2.0.8", 74 | "postcss-url": "^7.2.1", 75 | "rimraf": "^2.6.0", 76 | "selenium-server": "^3.0.1", 77 | "semver": "^5.3.0", 78 | "shelljs": "^0.7.6", 79 | "sinon": "^4.0.0", 80 | "sinon-chai": "^2.8.0", 81 | "uglifyjs-webpack-plugin": "^1.1.1", 82 | "url-loader": "^0.5.8", 83 | "vue-loader": "^13.3.0", 84 | "vue-style-loader": "^3.0.1", 85 | "vue-template-compiler": "^2.5.2", 86 | "webpack": "^3.6.0", 87 | "webpack-bundle-analyzer": "^3.6.0", 88 | "webpack-dev-server": "^2.11.3", 89 | "webpack-merge": "^4.1.0" 90 | }, 91 | "engines": { 92 | "node": ">= 6.0.0", 93 | "npm": ">= 3.0.0" 94 | }, 95 | "browserslist": [ 96 | "> 1%", 97 | "last 2 versions", 98 | "not ie <= 8" 99 | ] 100 | } 101 | -------------------------------------------------------------------------------- /src/App.vue: -------------------------------------------------------------------------------- 1 | 6 | 7 | 12 | 13 | 15 | -------------------------------------------------------------------------------- /src/assets/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/francecil/vue-flowchart/89c614b682934d600125850d9095ee9da18eb0b2/src/assets/logo.png -------------------------------------------------------------------------------- /src/components/FcCanvas.vue: -------------------------------------------------------------------------------- 1 | 132 | 457 | 576 | -------------------------------------------------------------------------------- /src/components/FcConnector.vue: -------------------------------------------------------------------------------- 1 | 7 | 135 | -------------------------------------------------------------------------------- /src/components/FcEdge.vue: -------------------------------------------------------------------------------- 1 | 15 | 100 | 128 | -------------------------------------------------------------------------------- /src/components/FcEdgeLabel.vue: -------------------------------------------------------------------------------- 1 | 29 | 126 | 176 | -------------------------------------------------------------------------------- /src/components/FcMagnet.vue: -------------------------------------------------------------------------------- 1 | 2 | 10 | 59 | -------------------------------------------------------------------------------- /src/components/FcNode.vue: -------------------------------------------------------------------------------- 1 | 79 | 242 | 344 | -------------------------------------------------------------------------------- /src/config/flowchart.js: -------------------------------------------------------------------------------- 1 | 2 | const constants = { 3 | htmlPrefix: 'fc', 4 | leftConnectorType: 'leftConnector', 5 | rightConnectorType: 'rightConnector', 6 | curvedStyle: 'curved', 7 | lineStyle: 'line', 8 | dragAnimationRepaint: 'repaint', 9 | dragAnimationShadow: 'shadow' 10 | } 11 | constants.canvasClass = constants.htmlPrefix + '-canvas' 12 | constants.selectedClass = constants.htmlPrefix + '-selected' 13 | constants.editClass = constants.htmlPrefix + '-edit' 14 | constants.activeClass = constants.htmlPrefix + '-active' 15 | constants.hoverClass = constants.htmlPrefix + '-hover' 16 | constants.draggingClass = constants.htmlPrefix + '-dragging' 17 | constants.edgeClass = constants.htmlPrefix + '-edge' 18 | constants.edgeLabelClass = constants.htmlPrefix + '-edge-label' 19 | constants.connectorClass = constants.htmlPrefix + '-connector' 20 | constants.magnetClass = constants.htmlPrefix + '-magnet' 21 | constants.nodeClass = constants.htmlPrefix + '-node' 22 | constants.nodeOverlayClass = constants.htmlPrefix + '-node-overlay' 23 | constants.leftConnectorClass = constants.htmlPrefix + '-' + constants.leftConnectorType + 's' 24 | constants.rightConnectorClass = constants.htmlPrefix + '-' + constants.rightConnectorType + 's' 25 | constants.canvasResizeThreshold = 300 26 | constants.canvasResizeStep = 200 27 | export default constants 28 | -------------------------------------------------------------------------------- /src/main.js: -------------------------------------------------------------------------------- 1 | // The Vue build version to load with the `import` command 2 | // (runtime-only or standalone) has been set in webpack.base.conf with an alias. 3 | import Vue from 'vue' 4 | import App from './App' 5 | import router from './router' 6 | import ElementUI from 'element-ui' 7 | import 'element-ui/lib/theme-chalk/index.css' 8 | Vue.config.productionTip = false 9 | Vue.use(ElementUI) 10 | /* eslint-disable no-new */ 11 | new Vue({ 12 | el: '#app', 13 | router, 14 | components: { App }, 15 | template: '' 16 | }) 17 | -------------------------------------------------------------------------------- /src/pages/Index.vue: -------------------------------------------------------------------------------- 1 | 70 | 71 | 250 | 251 | 252 | 278 | -------------------------------------------------------------------------------- /src/router/index.js: -------------------------------------------------------------------------------- 1 | import Vue from 'vue' 2 | import Router from 'vue-router' 3 | 4 | Vue.use(Router) 5 | 6 | export default new Router({ 7 | routes: [ 8 | { 9 | path: '/', 10 | name: 'Index', 11 | component: () => import('@/pages/Index.vue') 12 | } 13 | ] 14 | }) 15 | -------------------------------------------------------------------------------- /src/service/edgedragging.js: -------------------------------------------------------------------------------- 1 | import flowchartConstants from '@/config/flowchart' 2 | import Modelvalidation from '@/service/modelvalidation' 3 | function EdgeDraggingFactory (store, initialState = {}) { 4 | this.isValidEdgeCallback = () => { 5 | return true 6 | } 7 | this.edgeAddCallback = () => {} 8 | this.store = store 9 | this.draggedEdgeSource = null 10 | for (let prop in initialState) { 11 | if (initialState.hasOwnProperty(prop) && this.hasOwnProperty(prop)) { 12 | this[prop] = initialState[prop] 13 | } 14 | } 15 | } 16 | // let dragImage = null 17 | // // 一个透明图像 18 | // const getDragImage = function () { 19 | // if (!dragImage) { 20 | // dragImage = new Image() 21 | // dragImage.src = 'data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7' 22 | // dragImage.style.visibility = 'hidden' 23 | // } 24 | // return dragImage 25 | // } 26 | EdgeDraggingFactory.prototype.init = function () { 27 | let edgeDragging = { 28 | dragging: false, 29 | dragPoint1: null, 30 | dragPoint2: null, 31 | prevEdge: null 32 | } 33 | this.store.commit('UPDATE_EDGE_DRAGGING', edgeDragging) 34 | } 35 | EdgeDraggingFactory.prototype.dragstart = function (event, connector, type) { 36 | let swapConnector = null 37 | let prevEdge = null 38 | let edgeDragging = {} 39 | edgeDragging.dragging = true 40 | // 拖拽点为左类型,则删去原来连线并记录 41 | if (type === flowchartConstants.leftConnectorType) { 42 | for (let edge of this.store.state.model.edges) { 43 | // 选择第一个,当多个的话 拖拽结束会把前面的edge放到列表后面 44 | if (edge.destination === connector.id) { 45 | swapConnector = { 46 | id: edge.source 47 | } 48 | prevEdge = edge 49 | this.store.deleteEdge({ 50 | edge 51 | }) 52 | break 53 | } 54 | } 55 | } 56 | 57 | if (swapConnector) { 58 | this.draggedEdgeSource = swapConnector 59 | edgeDragging.dragPoint1 = this.store.getConnector(swapConnector.id) 60 | edgeDragging.prevEdge = prevEdge 61 | } else { 62 | this.draggedEdgeSource = connector 63 | edgeDragging.dragPoint1 = this.store.getConnector(connector.id) 64 | } 65 | 66 | edgeDragging.dragPoint2 = { 67 | x: event.clientX - this.store.getCanvasOffsetRelativeLeft(), 68 | y: event.clientY - this.store.getCanvasOffsetRelativeTop() 69 | } 70 | // try { 71 | // event.dataTransfer.setData('text', 'Just to support firefox') 72 | // } catch (error) { 73 | // console.warn('ie will report error:', error) 74 | // } 75 | // if (event.dataTransfer.setDragImage) { 76 | // event.dataTransfer.setDragImage(getDragImage(), 0, 0) 77 | // } 78 | this.store.commit('UPDATE_EDGE_DRAGGING', edgeDragging) 79 | } 80 | 81 | EdgeDraggingFactory.prototype.drop = async function (connector) { 82 | let edgeDragging = this.store.state.edgeDragging 83 | try { 84 | if (edgeDragging.dragging) { 85 | let invalid = false 86 | // 验证起始连线是否合法 87 | try { 88 | Modelvalidation.validateEdges(this.store.state.model.edges.concat([{ 89 | source: this.draggedEdgeSource.id, 90 | destination: connector.id 91 | }]), this.store.state.model.nodes) 92 | } catch (error) { 93 | console.warn(error) 94 | if (error instanceof Modelvalidation.ModelvalidationError && edgeDragging.prevEdge) { 95 | invalid = true 96 | } else { 97 | throw error 98 | } 99 | } 100 | let isPushState = true 101 | let edge = { 102 | source: this.draggedEdgeSource.id, 103 | destination: connector.id 104 | } 105 | if (edgeDragging.prevEdge) { 106 | // 连线失败时,恢复原来连线 107 | if (invalid) { 108 | edge = edgeDragging.prevEdge 109 | } else { 110 | edge = Object.assign(edgeDragging.prevEdge, edge) 111 | } 112 | isPushState = false 113 | } else { 114 | try { 115 | edge.label = await this.edgeAddCallback() 116 | } catch (error) { 117 | throw error 118 | } 119 | } 120 | 121 | this.store.addEdge({edge, isPushState: isPushState}) 122 | } 123 | } catch (error) { 124 | 125 | } 126 | 127 | this.init() 128 | } 129 | EdgeDraggingFactory.prototype.dragend = function (event) { 130 | let edgeDragging = this.store.state.edgeDragging 131 | if (edgeDragging.dragging) { 132 | if (edgeDragging.prevEdge) { 133 | this.store.addEdge({edge: edgeDragging.prevEdge}) 134 | } 135 | this.init() 136 | } 137 | } 138 | EdgeDraggingFactory.prototype.dragover = function (event) { 139 | let edgeDragging = this.store.state.edgeDragging 140 | if (edgeDragging.dragging) { 141 | this.store.commit('UPDATE_EDGE_DRAGGING', { 142 | dragPoint2: { 143 | x: event.clientX - this.store.getCanvasOffsetRelativeLeft(), 144 | y: event.clientY - this.store.getCanvasOffsetRelativeTop() 145 | } 146 | }) 147 | } 148 | } 149 | export default EdgeDraggingFactory 150 | -------------------------------------------------------------------------------- /src/service/edgedrawing.js: -------------------------------------------------------------------------------- 1 | import flowchartConstants from '@/config/flowchart' 2 | function EdgedrawingService (flowchartConstants) { 3 | function computeEdgeTangentOffset (pt1, pt2) { 4 | return (pt2.y - pt1.y) / 2 5 | } 6 | 7 | function computeEdgeSourceTangent (pt1, pt2) { 8 | return { 9 | x: pt1.x, 10 | y: pt1.y + computeEdgeTangentOffset(pt1, pt2) 11 | } 12 | } 13 | 14 | function computeEdgeDestinationTangent (pt1, pt2) { 15 | return { 16 | x: pt2.x, 17 | y: pt2.y - computeEdgeTangentOffset(pt1, pt2) 18 | } 19 | } 20 | // 获取的D属性 21 | this.getEdgeDAttribute = function (pt1, pt2, style) { 22 | var dAddribute = 'M ' + pt1.x + ', ' + pt1.y + ' ' 23 | if (style === flowchartConstants.curvedStyle) { 24 | var sourceTangent = computeEdgeSourceTangent(pt1, pt2) 25 | var destinationTangent = computeEdgeDestinationTangent(pt1, pt2) 26 | dAddribute += 'C ' + sourceTangent.x + ', ' + sourceTangent.y + ' ' + (destinationTangent.x - 50) + ', ' + destinationTangent.y + ' ' + pt2.x + ', ' + pt2.y 27 | } else { 28 | dAddribute += 'L ' + pt2.x + ', ' + pt2.y 29 | } 30 | return dAddribute 31 | } 32 | this.getEdgeCenter = function (pt1, pt2) { 33 | return { 34 | x: (pt1.x + pt2.x) / 2, 35 | y: (pt1.y + pt2.y) / 2 36 | } 37 | } 38 | } 39 | export default new EdgedrawingService(flowchartConstants) 40 | -------------------------------------------------------------------------------- /src/service/modelvalidation.js: -------------------------------------------------------------------------------- 1 | import flowchartConstants from '@/config/flowchart' 2 | import { checkGraph } from '@/service/topsort' 3 | function Modelvalidation () { 4 | function ModelvalidationError (message) { 5 | this.message = message 6 | } 7 | ModelvalidationError.prototype = new Error() 8 | ModelvalidationError.prototype.name = 'ModelvalidationError' 9 | ModelvalidationError.prototype.constructor = ModelvalidationError 10 | this.ModelvalidationError = ModelvalidationError 11 | 12 | this.validateModel = function (model) { 13 | console.log('validateModel') 14 | if (model === null) return 15 | this.validateNodes(model.nodes) 16 | this._validateEdges(model.edges, model.nodes) 17 | return model 18 | } 19 | 20 | this.validateNodes = function (nodes) { 21 | let ids = [] 22 | for (let node of nodes) { 23 | this.validateNode(node) 24 | if (ids.indexOf(node.id) !== -1) { 25 | throw new ModelvalidationError('Id not unique.') 26 | } 27 | ids.push(node.id) 28 | } 29 | 30 | let connectorIds = [] 31 | for (let node of nodes) { 32 | for (let type in node.connectors) { 33 | this.validateConnector(node.connectors[type]) 34 | if (connectorIds.indexOf(node.connectors[type].id) !== -1) { 35 | throw new ModelvalidationError('Id not unique.') 36 | } 37 | connectorIds.push(node.connectors[type].id) 38 | } 39 | } 40 | return nodes 41 | } 42 | 43 | this.validateNode = function (node) { 44 | if (node.id === undefined) { 45 | throw new ModelvalidationError('Id not valid.') 46 | } 47 | if (typeof node.name !== 'string') { 48 | throw new ModelvalidationError('Name not valid.') 49 | } 50 | if (typeof node.x !== 'number' || node.x < 0) { 51 | throw new ModelvalidationError('Coordinates not valid.') 52 | } 53 | if (typeof node.y !== 'number' || node.y < 0) { 54 | throw new ModelvalidationError('Coordinates not valid.') 55 | } 56 | if (typeof node.connectors !== 'object' && node.connectors !== undefined) { 57 | throw new ModelvalidationError('Connectors not valid.') 58 | } 59 | for (let type in node.connectors) { 60 | if (type !== flowchartConstants.leftConnectorType && type !== flowchartConstants.rightConnectorType) { 61 | throw new ModelvalidationError('Connectors not valid.') 62 | } 63 | this.validateConnector(node.connectors[type]) 64 | } 65 | return node 66 | } 67 | 68 | this._validateEdges = function (edges, nodes) { 69 | edges.forEach((edge) => { 70 | this._validateEdge(edge, nodes) 71 | }) 72 | // 验证重复边 73 | for (let i = 0; i < edges.length; i++) { 74 | for (let j = i + 1; j < edges.length; j++) { 75 | if ((edges[i].source === edges[j].source && edges[i].destination === edges[j].destination) || (edges[i].source === edges[j].destination && edges[i].destination === edges[j].source)) { 76 | throw new ModelvalidationError('Duplicated edge.') 77 | } 78 | } 79 | } 80 | 81 | if (!checkGraph({nodes: nodes, edges: edges})) { 82 | throw new ModelvalidationError('Graph has a circle.') 83 | } 84 | 85 | return edges 86 | } 87 | 88 | this.validateEdges = function (edges, nodes) { 89 | this.validateNodes(nodes) 90 | return this._validateEdges(edges, nodes) 91 | } 92 | 93 | this._validateEdge = function (edge, nodes) { 94 | if (edge.source === undefined) { 95 | throw new ModelvalidationError('Source not valid.') 96 | } 97 | if (edge.destination === undefined) { 98 | throw new ModelvalidationError('Destination not valid.') 99 | } 100 | 101 | if (edge.source === edge.destination) { 102 | throw new ModelvalidationError('Edge with same source and destination connectors.') 103 | } 104 | let sourceNode = nodes.filter(function (node) { 105 | return node.connectors && node.connectors[flowchartConstants.rightConnectorType] && node.connectors[flowchartConstants.rightConnectorType].id === edge.source 106 | })[0] 107 | if (!sourceNode) { 108 | throw new ModelvalidationError('Source not valid.') 109 | } 110 | let destinationNode = nodes.filter(function (node) { 111 | return node.connectors && node.connectors[flowchartConstants.leftConnectorType] && node.connectors[flowchartConstants.leftConnectorType].id === edge.destination 112 | })[0] 113 | if (!destinationNode) { 114 | throw new ModelvalidationError('Destination not valid.') 115 | } 116 | if (sourceNode === destinationNode) { 117 | throw new ModelvalidationError('Edge with same source and destination nodes.') 118 | } 119 | return edge 120 | } 121 | 122 | this.validateEdge = function (edge, nodes) { 123 | this.validateNodes(nodes) 124 | return this._validateEdge(edge, nodes) 125 | } 126 | 127 | this.validateConnector = function (connector) { 128 | if (connector.id === undefined) { 129 | throw new ModelvalidationError('Id not valid.') 130 | } 131 | return connector 132 | } 133 | } 134 | export default new Modelvalidation() 135 | -------------------------------------------------------------------------------- /src/service/nodedragging.js: -------------------------------------------------------------------------------- 1 | import flowchartConstants from '@/config/flowchart' 2 | import UUIDjs from '@/utils/uuid' 3 | // require('../utils/setDragImage.polyfill.js') 4 | 5 | function NodeDraggingFactory (store, initialState = {}) { 6 | // 待拖拽点相关信息,dragover时使用 7 | this.dropNodeInfo = null 8 | this.nodeAddCallback = () => { } 9 | // 所有待拖拽点 10 | this.draggedNodes = [] 11 | this.store = store 12 | this.automaticResize = true 13 | this.dragAnimation = flowchartConstants.dragAnimationRepaint 14 | for (let prop in initialState) { 15 | if (initialState.hasOwnProperty(prop) && this.hasOwnProperty(prop)) { 16 | this[prop] = initialState[prop] 17 | } 18 | } 19 | } 20 | const resizeCanvas = function (node, automaticResize, store) { 21 | // 调整canvas画板,仅会不断变大 22 | if (automaticResize && !store.isDropSource()) { 23 | let canvasOffset = store.state.canvasOffset 24 | let newOffset = { 25 | width: canvasOffset.width, 26 | height: canvasOffset.height 27 | } 28 | let hasChange = false 29 | if (canvasOffset.width < node.x + flowchartConstants.canvasResizeThreshold) { 30 | hasChange = true 31 | newOffset.width = canvasOffset.width + flowchartConstants.canvasResizeStep 32 | } 33 | if (canvasOffset.height < node.y + flowchartConstants.canvasResizeThreshold) { 34 | hasChange = true 35 | newOffset.height = canvasOffset.height + flowchartConstants.canvasResizeStep 36 | } 37 | if (hasChange) { 38 | store.commit('UPDATE_CANVAS_OFFSET', newOffset) 39 | } 40 | } 41 | } 42 | // let dragImage = null 43 | // // 一个透明图像 44 | // const getDragImage = function () { 45 | // if (!dragImage) { 46 | // dragImage = new Image() 47 | // dragImage.src = 'data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7' 48 | // dragImage.style.visibility = 'hidden' 49 | // } 50 | // return dragImage 51 | // } 52 | const getDragOffset = function (event, dropNodeInfo, canvasOffset) { 53 | return { 54 | x: event.clientX - dropNodeInfo.eventPointOffset.x - canvasOffset.left - dropNodeInfo.node.x, 55 | y: event.clientY - dropNodeInfo.eventPointOffset.y - canvasOffset.top - dropNodeInfo.node.y 56 | } 57 | } 58 | NodeDraggingFactory.prototype.init = function () { 59 | this.dropNodeInfo = null 60 | this.draggedNodes.length = 0 61 | let nodeDragging = { 62 | dragging: false, 63 | downOffset: null 64 | } 65 | console.log('nodedragging init') 66 | this.store.commit('UPDATE_NODE_DRAGGING', nodeDragging) 67 | } 68 | NodeDraggingFactory.prototype.dragstart = function (event, node, eventPointOffset) { 69 | if (node.readonly) { 70 | return 71 | } 72 | let nodeDragging = {} 73 | nodeDragging.dragging = true 74 | nodeDragging.downOffset = { 75 | x: event.clientX, 76 | y: event.clientY 77 | } 78 | this.dropNodeInfo = { 79 | node, 80 | eventPointOffset 81 | } 82 | this.draggedNodes.length = 0 83 | // 被拖拽节点处于选择状态,故可能还有其他被选择节点,将会一起拖动 84 | if (this.store.isSelectedObject(node)) { 85 | this.draggedNodes = this.store.getSelectedNodes() 86 | } else { 87 | this.draggedNodes.push(node) 88 | } 89 | if (this.store.isDropSource()) { 90 | this.dropNodeInfo.isDropSource = true 91 | event.dataTransfer.setData('text', JSON.stringify(this.dropNodeInfo)) 92 | // if (event.dataTransfer.setDragImage) { 93 | // event.dataTransfer.setDragImage(getDragImage(), 0, 0) 94 | // } 95 | } 96 | this.store.commit('UPDATE_NODE_DRAGGING', nodeDragging) 97 | } 98 | 99 | NodeDraggingFactory.prototype.drop = async function (event) { 100 | if (this.store.isDropSource()) { 101 | return 102 | } 103 | try { 104 | let dropNodeInfo = this.dropNodeInfo 105 | if (event.dataTransfer) { 106 | let dropNodeInfoStr = event.dataTransfer.getData('text') 107 | // 画板属于dropsource 或 dropNodeInfo信息不存在 108 | if (!dropNodeInfoStr) { 109 | return 110 | } 111 | dropNodeInfo = JSON.parse(dropNodeInfoStr) 112 | } 113 | // 原节点属于类型节点 114 | if (dropNodeInfo.isDropSource) { 115 | let name = await this.nodeAddCallback(dropNodeInfo.node.name) 116 | let newNode = Object.assign(dropNodeInfo.node, { 117 | id: UUIDjs.create('node'), 118 | name: name, 119 | x: event.clientX - dropNodeInfo.eventPointOffset.x - this.store.getCanvasOffsetRelativeLeft(), 120 | y: event.clientY - dropNodeInfo.eventPointOffset.y - this.store.getCanvasOffsetRelativeTop() 121 | }) 122 | for (let type in newNode.connectors) { 123 | newNode.connectors[type].id = UUIDjs.create('connector') 124 | } 125 | this.store.addNode({ node: newNode, isPushState: true }) 126 | } else { 127 | let downOffset = this.store.state.nodeDragging.downOffset 128 | // console.log(downOffset.x, event.clientX, downOffset.y, event.clientY) 129 | if (downOffset.x === event.clientX && downOffset.y === event.clientY) { 130 | // handle click 131 | setTimeout(() => { 132 | this.init() 133 | }, 0) 134 | return 135 | } else { 136 | // 节点属于目标画板节点,直接应用 137 | let offset = getDragOffset(event, this.dropNodeInfo, { 138 | left: this.store.getCanvasOffsetRelativeLeft(), 139 | top: this.store.getCanvasOffsetRelativeTop() 140 | }) 141 | for (let node of this.draggedNodes) { 142 | let newNode = Object.assign(node, { 143 | x: node.x + offset.x, 144 | y: node.y + offset.y 145 | }) 146 | this.store.updateNode({ 147 | node: node, 148 | newNode, 149 | isPushState: true 150 | }) 151 | } 152 | } 153 | } 154 | } catch (error) { 155 | 156 | } 157 | 158 | this.init() 159 | } 160 | NodeDraggingFactory.prototype.dragover = function (event) { 161 | // 画板属于dropsource 或 拖拽节点不在目标画板 162 | if (this.store.isDropSource() || !this.dropNodeInfo) { 163 | return 164 | } 165 | if (!this.store.state.nodeDragging.dragging) { 166 | return 167 | } 168 | let offset = getDragOffset(event, this.dropNodeInfo, { 169 | left: this.store.getCanvasOffsetRelativeLeft(), 170 | top: this.store.getCanvasOffsetRelativeTop() 171 | }) 172 | for (let node of this.draggedNodes) { 173 | let newNode = Object.assign(node, { 174 | x: node.x + offset.x, 175 | y: node.y + offset.y 176 | }) 177 | this.store.updateNode({ 178 | node: node, 179 | newNode 180 | }) 181 | resizeCanvas(newNode, this.automaticResize, this.store) 182 | } 183 | } 184 | export default NodeDraggingFactory 185 | -------------------------------------------------------------------------------- /src/service/rectangleselect.js: -------------------------------------------------------------------------------- 1 | function RectangleSelectFactory (store, initialState = {}) { 2 | this.store = store 3 | this.selectRect = { 4 | x1: 0, 5 | x2: 0, 6 | y1: 0, 7 | y2: 0 8 | } 9 | this.startSelect = false 10 | for (let prop in initialState) { 11 | if (initialState.hasOwnProperty(prop) && this.hasOwnProperty(prop)) { 12 | this[prop] = initialState[prop] 13 | } 14 | } 15 | } 16 | 17 | function updateSelectRect (selectRect, store) { 18 | var x3 = Math.min(selectRect.x1, selectRect.x2) 19 | var x4 = Math.max(selectRect.x1, selectRect.x2) 20 | var y3 = Math.min(selectRect.y1, selectRect.y2) 21 | var y4 = Math.max(selectRect.y1, selectRect.y2) 22 | store.commit('UPDATE_RECTANGLE_SELECT', { 23 | left: x3, 24 | top: y3, 25 | width: x4 - x3, 26 | height: y4 - y3, 27 | visibility: 'visible' 28 | }) 29 | } 30 | RectangleSelectFactory.prototype.init = function () { 31 | this.store.commit('UPDATE_RECTANGLE_SELECT', { 32 | left: 0, 33 | top: 0, 34 | width: 0, 35 | height: 0, 36 | visibility: 'hidden' 37 | }) 38 | this.selectRect = { 39 | x1: 0, 40 | x2: 0, 41 | y1: 0, 42 | y2: 0 43 | } 44 | this.startSelect = false 45 | } 46 | RectangleSelectFactory.prototype.mousedown = function (e) { 47 | if (this.store.isEditable() && !e.ctrlKey && !e.metaKey && e.button === 0) { 48 | this.startSelect = true 49 | this.selectRect.x1 = Math.round(e.clientX - this.store.getCanvasOffsetRelativeLeft()) 50 | this.selectRect.y1 = Math.round(e.clientY - this.store.getCanvasOffsetRelativeTop()) 51 | } 52 | } 53 | RectangleSelectFactory.prototype.mousemove = function (e) { 54 | if (this.store.isEditable() && !e.ctrlKey && !e.metaKey && e.button === 0 && this.startSelect) { 55 | this.selectRect.x2 = Math.round(e.clientX - this.store.getCanvasOffsetRelativeLeft()) 56 | this.selectRect.y2 = Math.round(e.clientY - this.store.getCanvasOffsetRelativeTop()) 57 | updateSelectRect(this.selectRect, this.store) 58 | } 59 | } 60 | RectangleSelectFactory.prototype.mouseup = function (e) { 61 | if (this.store.isEditable() && !e.ctrlKey && !e.metaKey && e.button === 0 && this.startSelect) { 62 | // var rectBox = rectangleSelectService.selectElement.getBoundingClientRect() 63 | // rectBox.parentOffset = jquery(modelservice.getCanvasHtmlElement()).offset() 64 | this.store.selectAllInRect() 65 | this.init() 66 | } 67 | } 68 | 69 | export default RectangleSelectFactory 70 | -------------------------------------------------------------------------------- /src/service/store.js: -------------------------------------------------------------------------------- 1 | import UUIDjs from '@/utils/uuid' 2 | const SET_MODEL = 'SET_MODEL' 3 | const UPDATE_NODE = 'UPDATE_NODE' 4 | const ADD_NODE = 'ADD_NODE' 5 | const DELETE_NODE = 'DELETE_NODE' 6 | const UPDATE_EDGE = 'UPDATE_EDGE' 7 | const ADD_EDGE = 'ADD_EDGE' 8 | const DELETE_EDGE = 'DELETE_EDGE' 9 | const UPDATE_CONNECTOR = 'UPDATE_CONNECTOR' 10 | const DELETE_CONNECTOR = 'DELETE_CONNECTOR' 11 | const UPDATE_CANVAS_OFFSET = 'UPDATE_CANVAS_OFFSET' 12 | const DESELECT_ALL = 'DESELECT_ALL' 13 | const DESELECT_OBJECT = 'DESELECT_OBJECT' 14 | const SELECT_OBJECT = 'SELECT_OBJECT' 15 | const SET_NODE_ELEMENT = 'SET_NODE_ELEMENT' 16 | const SET_CANVAS_CONTAINER = 'SET_CANVAS_CONTAINER' 17 | const UPDATE_NODE_DRAGGING = 'UPDATE_NODE_DRAGGING' 18 | const UPDATE_EDGE_DRAGGING = 'UPDATE_EDGE_DRAGGING' 19 | const UPDATE_RECTANGLE_SELECT = 'UPDATE_RECTANGLE_SELECT' 20 | const UPDATE_CLIPBOARD = 'UPDATE_CLIPBOARD' 21 | const INIT_SNAPSHOTS = 'INIT_SNAPSHOTS' 22 | const EXECUTE_SNAPSHOT = 'EXECUTE_SNAPSHOT' 23 | const UPDATE_SNAPSHOT_MODEL = 'UPDATE_SNAPSHOT_MODEL' 24 | const INIT_STORE = 'INIT_STORE' 25 | const CanvasStore = function (canvas, initialState = {}) { 26 | if (!canvas) { 27 | throw new Error('Canvas is required.') 28 | } 29 | this.canvas = canvas 30 | 31 | this.state = { 32 | model: null, 33 | dropTargetId: null, 34 | canvasOffset: { 35 | width: 0, 36 | height: 0 37 | }, 38 | connectors: {}, 39 | // 当前选中的元素,包括节点和连线 40 | selectedObjects: [], 41 | // node对应的dom节点 42 | nodeElements: {}, 43 | canvasContainer: null, 44 | // 连线相关 45 | edgeDragging: { 46 | dragging: false, 47 | dragPoint1: null, 48 | dragPoint2: null, 49 | prevEdge: null 50 | }, 51 | nodeDragging: { 52 | dragging: false, 53 | downOffset: null 54 | }, 55 | // 选择区域 56 | rectangleSelect: { 57 | left: 0, 58 | top: 0, 59 | width: 0, 60 | height: 0, 61 | visibility: 'hidden' 62 | }, 63 | // 剪切板 64 | clipboard: 'null', 65 | // 撤回重做历史栈 66 | snapshots: [], 67 | // 所处历史栈下标 68 | cursor: -1, 69 | MAX_HISTORY: 100 70 | } 71 | 72 | for (let prop in initialState) { 73 | if (initialState.hasOwnProperty(prop) && this.state.hasOwnProperty(prop)) { 74 | this.state[prop] = initialState[prop] 75 | } 76 | } 77 | } 78 | /** *************** mutations *****************/ 79 | CanvasStore.prototype.mutations = { 80 | [SET_MODEL] (state, model) { 81 | state.model = model || {} 82 | }, 83 | [UPDATE_CANVAS_OFFSET] (state, offset) { 84 | Object.assign(state.canvasOffset, offset) 85 | }, 86 | [ADD_NODE] (state, node) { 87 | state.model.nodes.push(node) 88 | }, 89 | [UPDATE_NODE] (state, {node, newNode}) { 90 | let index = state.model.nodes.indexOf(node) 91 | if (index !== -1) { 92 | Object.assign(state.model.nodes[index], newNode) 93 | } 94 | }, 95 | [DELETE_NODE] (state, node) { 96 | let index = state.model.nodes.indexOf(node) 97 | if (index !== -1) { 98 | state.model.nodes.splice(index, 1) 99 | delete state.nodeElements[node.id] 100 | } 101 | }, 102 | [UPDATE_EDGE] (state, {edge, newEdge}) { 103 | let index = state.model.edges.indexOf(edge) 104 | if (index !== -1) { 105 | Object.assign(state.model.edges[index], newEdge) 106 | } 107 | }, 108 | [DELETE_EDGE] (state, edge) { 109 | let index = state.model.edges.indexOf(edge) 110 | if (index !== -1) { 111 | state.model.edges.splice(index, 1) 112 | } 113 | }, 114 | [ADD_EDGE] (state, edge) { 115 | state.model.edges.push(edge) 116 | }, 117 | [UPDATE_CONNECTOR] (state, {connectorId, x, y}) { 118 | if (state.connectors[connectorId]) { 119 | state.connectors[connectorId].x = x 120 | state.connectors[connectorId].y = y 121 | } else { 122 | this.canvas.$set(state.connectors, connectorId, {x, y}) 123 | } 124 | }, 125 | [DELETE_CONNECTOR] (state, connectorId) { 126 | delete state.connectors[connectorId] 127 | }, 128 | [DESELECT_ALL] (state) { 129 | state.selectedObjects.splice(0, state.selectedObjects.length) 130 | }, 131 | [SELECT_OBJECT] (state, object) { 132 | if (object && state.selectedObjects.indexOf(object) === -1) { 133 | state.selectedObjects.push(object) 134 | } 135 | }, 136 | [DESELECT_OBJECT] (state, object) { 137 | let index = state.selectedObjects.indexOf(object) 138 | if (index !== -1) { 139 | state.selectedObjects.splice(index, 1) 140 | } 141 | }, 142 | [SET_NODE_ELEMENT] (state, {nodeId, element}) { 143 | // console.log('SET_NODE_ELEMENT', nodeId) 144 | state.nodeElements[nodeId] = element 145 | }, 146 | [SET_CANVAS_CONTAINER] (state, element) { 147 | state.canvasContainer = element 148 | }, 149 | [UPDATE_NODE_DRAGGING] (state, nodeDragging) { 150 | Object.assign(state.nodeDragging, nodeDragging) 151 | }, 152 | [UPDATE_EDGE_DRAGGING] (state, edgeDragging) { 153 | Object.assign(state.edgeDragging, edgeDragging) 154 | }, 155 | [UPDATE_RECTANGLE_SELECT] (state, rectangleSelect) { 156 | Object.assign(state.rectangleSelect, rectangleSelect) 157 | }, 158 | [UPDATE_CLIPBOARD] (state, str) { 159 | state.clipboard = str 160 | }, 161 | [EXECUTE_SNAPSHOT] (state) { 162 | let snapshot = JSON.stringify(state.model) 163 | // 比如当前索引为3 进行新操作后 就需要把 snapshots 数组中索引>3的数据删掉 164 | state.snapshots.splice(state.cursor + 1, state.snapshots.length) 165 | state.snapshots.push(snapshot) 166 | state.cursor++ 167 | while (state.snapshots.length > state.MAX_HISTORY) { 168 | state.snapshots.shift() 169 | state.cursor-- 170 | } 171 | }, 172 | [INIT_SNAPSHOTS] (state) { 173 | state.snapshots = [] 174 | state.cursor = -1 175 | }, 176 | [UPDATE_SNAPSHOT_MODEL] (state, newCursor) { 177 | state.cursor = newCursor 178 | this.state.model = JSON.parse(state.snapshots[newCursor]) 179 | }, 180 | [INIT_STORE] (state) { 181 | state.connectors = {} 182 | // 当前选中的元素,包括节点和连线 183 | state.selectedObjects = [] 184 | // node对应的dom节点 185 | state.nodeElements = {} 186 | state.nodeDragging = { 187 | dragging: false, 188 | downOffset: null 189 | } 190 | // 连线相关 191 | state.edgeDragging = { 192 | dragging: false, 193 | dragPoint1: null, 194 | dragPoint2: null, 195 | prevEdge: null 196 | } 197 | // 选择区域 198 | state.rectangleSelect = { 199 | left: 0, 200 | top: 0, 201 | width: 0, 202 | height: 0, 203 | visibility: 'hidden' 204 | } 205 | // 剪切板 206 | state.clipboard = 'null' 207 | } 208 | } 209 | CanvasStore.prototype.commit = function (name, ...args) { 210 | // console.log(name, args) 211 | const mutations = this.mutations 212 | if (mutations[name]) { 213 | mutations[name].apply(this, [this.state].concat(args)) 214 | } else { 215 | throw new Error(`Action not found: ${name}`) 216 | } 217 | } 218 | /** *************** getters *****************/ 219 | // 预置节点画板 220 | CanvasStore.prototype.isDropSource = function () { 221 | return !!this.state.dropTargetId 222 | } 223 | // 当前画板为主画板 224 | CanvasStore.prototype.isEditable = function () { 225 | return !this.state.dropTargetId 226 | } 227 | // 当前元素是否被选中 228 | CanvasStore.prototype.isSelectedObject = function (object) { 229 | return this.state.selectedObjects.indexOf(object) !== -1 230 | } 231 | // 当前元素是否为可编辑状态 232 | CanvasStore.prototype.isEditObject = function (object) { 233 | return this.state.selectedObjects.length === 1 && 234 | this.isSelectedObject(object) 235 | } 236 | CanvasStore.prototype.getConnector = function (id) { 237 | return this.state.connectors[id] 238 | } 239 | // 所有被选中的节点 240 | CanvasStore.prototype.getSelectedNodes = function () { 241 | return this.state.model.nodes ? this.state.model.nodes.filter((node) => this.isSelectedObject(node)) : [] 242 | } 243 | // canvas的绝对位置,left,top值不受滚动条影响 244 | CanvasStore.prototype.getCanvasOffsetLeft = function () { 245 | return this.state.canvasContainer ? this.state.canvasContainer.getBoundingClientRect().left + this.state.canvasContainer.parentElement.scrollLeft : 0 246 | } 247 | CanvasStore.prototype.getCanvasOffsetTop = function () { 248 | return this.state.canvasContainer ? this.state.canvasContainer.getBoundingClientRect().top + this.state.canvasContainer.parentElement.scrollTop : 0 249 | } 250 | // canvas的相对位置,会偏小 251 | CanvasStore.prototype.getCanvasOffsetRelativeLeft = function () { 252 | return this.state.canvasContainer ? this.state.canvasContainer.getBoundingClientRect().left : 0 253 | } 254 | CanvasStore.prototype.getCanvasOffsetRelativeTop = function () { 255 | return this.state.canvasContainer ? this.state.canvasContainer.getBoundingClientRect().top : 0 256 | } 257 | /** *************** actions *****************/ 258 | CanvasStore.prototype.initModel = function (model) { 259 | this.commit(INIT_STORE) 260 | this.commit(INIT_SNAPSHOTS) 261 | this.commit(SET_MODEL, model) 262 | this.commit(EXECUTE_SNAPSHOT) 263 | } 264 | CanvasStore.prototype.addNode = function ({node, isPushState}) { 265 | if (node.id === undefined) { 266 | node.id = UUIDjs.create('node') 267 | } 268 | if (node.x === undefined) { 269 | node.x = Math.round(Math.random() * 200 + 300) 270 | } 271 | if (node.y === undefined) { 272 | node.y = Math.round(Math.random() * 200 + 300) 273 | } 274 | this.commit(ADD_NODE, node) 275 | if (isPushState) { 276 | this.commit(EXECUTE_SNAPSHOT) 277 | } 278 | } 279 | CanvasStore.prototype.updateNode = function ({node, newNode, isPushState}) { 280 | this.commit(UPDATE_NODE, {node, newNode}) 281 | if (isPushState) { 282 | this.commit(EXECUTE_SNAPSHOT) 283 | } 284 | } 285 | CanvasStore.prototype.deleteNode = function ({node, isPushState}) { 286 | this.commit(DELETE_NODE, node) 287 | this.commit(DESELECT_OBJECT, node) 288 | // 删除节点 同时删除连接点和相关连线 289 | if (node.connectors) { 290 | let connectorIds = [] 291 | for (let type in node.connectors) { 292 | let connector = node.connectors[type] 293 | this.commit(DELETE_CONNECTOR, connector.id) 294 | connectorIds.push(connector.id) 295 | } 296 | for (let i = 0; i < this.state.model.edges.length;) { 297 | let edge = this.state.model.edges[i] 298 | if (connectorIds.indexOf(edge.source) !== -1 || connectorIds.indexOf(edge.destination) !== -1) { 299 | this.deleteEdge({ 300 | edge, 301 | isPushState: false 302 | }) 303 | } else { 304 | i++ 305 | } 306 | } 307 | } 308 | if (isPushState) { 309 | this.commit(EXECUTE_SNAPSHOT) 310 | } 311 | } 312 | CanvasStore.prototype.addEdge = function ({edge, isPushState}) { 313 | this.commit(ADD_EDGE, edge) 314 | if (isPushState) { 315 | this.commit(EXECUTE_SNAPSHOT) 316 | } 317 | } 318 | CanvasStore.prototype.updateEdge = function ({edge, newEdge, isPushState}) { 319 | this.commit(UPDATE_EDGE, {edge, newEdge}) 320 | if (isPushState) { 321 | this.commit(EXECUTE_SNAPSHOT) 322 | } 323 | } 324 | CanvasStore.prototype.deleteEdge = function ({edge, isPushState}) { 325 | this.commit(DELETE_EDGE, edge) 326 | this.commit(DESELECT_OBJECT, edge) 327 | if (isPushState) { 328 | this.commit(EXECUTE_SNAPSHOT) 329 | } 330 | } 331 | // 删除选中元素 332 | CanvasStore.prototype.deleteSelected = function (isPushState) { 333 | while (this.state.selectedObjects.length > 0) { 334 | let item = this.state.selectedObjects[0] 335 | if (item.id !== undefined) { 336 | this.deleteNode({ 337 | node: item 338 | }) 339 | } else { 340 | this.deleteEdge({ 341 | edge: item 342 | }) 343 | } 344 | } 345 | if (isPushState) { 346 | this.commit(EXECUTE_SNAPSHOT) 347 | } 348 | } 349 | // 更新选择元素列表 350 | CanvasStore.prototype.updateSelecctedObjects = function ({object, ctrlKey}) { 351 | if (ctrlKey) { 352 | this.toggleSelectedObject(object) 353 | } else { 354 | this.deselectAll() 355 | this.commit(SELECT_OBJECT, object) 356 | } 357 | } 358 | // 全选 359 | CanvasStore.prototype.selectAll = function () { 360 | this.deselectAll() 361 | for (let node of this.state.model.nodes) { 362 | if (!node.readonly) { 363 | this.commit(SELECT_OBJECT, node) 364 | } 365 | } 366 | for (let edge of this.state.model.edges) { 367 | this.commit(SELECT_OBJECT, edge) 368 | } 369 | } 370 | // 反选全部数据 371 | CanvasStore.prototype.deselectAll = function () { 372 | this.commit(DESELECT_ALL) 373 | } 374 | // 更新当前所选元素状态 375 | CanvasStore.prototype.toggleSelectedObject = function (object) { 376 | if (this.isSelectedObject(object)) { 377 | this.commit(DESELECT_OBJECT, object) 378 | } else { 379 | this.commit(SELECT_OBJECT, object) 380 | } 381 | } 382 | // 选中选择框中元素 383 | CanvasStore.prototype.selectAllInRect = function () { 384 | this.commit(DESELECT_ALL) 385 | let rectBox = { 386 | left: this.state.rectangleSelect.left, 387 | right: this.state.rectangleSelect.left + this.state.rectangleSelect.width, 388 | top: this.state.rectangleSelect.top, 389 | bottom: this.state.rectangleSelect.top + this.state.rectangleSelect.height 390 | } 391 | if (rectBox.left === rectBox.right && rectBox.top === rectBox.bottom) { 392 | // click handle 393 | return 394 | } 395 | let canvasLeft = this.getCanvasOffsetRelativeLeft() 396 | let canvasTop = this.getCanvasOffsetRelativeTop() 397 | for (let node of this.state.model.nodes) { 398 | let nodeElement = this.state.nodeElements[node.id] 399 | let nodeElementBox = nodeElement.getBoundingClientRect() 400 | if (!node.readonly) { 401 | let x = nodeElementBox.left + nodeElementBox.width / 2 - canvasLeft 402 | let y = nodeElementBox.top + nodeElementBox.height / 2 - canvasTop 403 | if (inRectBox(x, y, rectBox)) { 404 | this.commit(SELECT_OBJECT, node) 405 | } 406 | } 407 | } 408 | for (let edge of this.state.model.edges) { 409 | let start = this.getConnector(edge.source) 410 | let end = this.getConnector(edge.destination) 411 | let x = (start.x + end.x) / 2 412 | let y = (start.y + end.y) / 2 413 | if (inRectBox(x, y, rectBox)) { 414 | this.commit(SELECT_OBJECT, edge) 415 | } 416 | } 417 | } 418 | // 复制选中元素 419 | CanvasStore.prototype.copyData = function () { 420 | this.commit(UPDATE_CLIPBOARD, 'null') 421 | // 无选择节点 直接返回 422 | if (this.getSelectedNodes().length === 0) { 423 | return 424 | } 425 | let model = { 426 | nodes: [], 427 | edges: [] 428 | } 429 | for (let item of this.state.selectedObjects) { 430 | if (item.id !== undefined) { 431 | model.nodes.push(item) 432 | } else { 433 | model.edges.push(item) 434 | } 435 | } 436 | this.commit(UPDATE_CLIPBOARD, JSON.stringify(model)) 437 | } 438 | // 粘贴选中元素 439 | CanvasStore.prototype.pasteData = function () { 440 | let model = JSON.parse(this.state.clipboard) 441 | if (model) { 442 | // 修改元素 443 | for (let node of model.nodes) { 444 | node.x = node.x + 50 445 | node.y = node.y + 50 446 | node.id = UUIDjs.create('node') 447 | for (let type in node.connectors) { 448 | let tempId = node.connectors[type].id 449 | node.connectors[type].id = UUIDjs.create('connector') 450 | for (let edge of model.edges) { 451 | if (edge.source === tempId) { 452 | edge.source = node.connectors[type].id 453 | } 454 | if (edge.destination === tempId) { 455 | edge.destination = node.connectors[type].id 456 | } 457 | } 458 | } 459 | this.addNode({node}) 460 | } 461 | for (let edge of model.edges) { 462 | this.addEdge({edge}) 463 | } 464 | } 465 | } 466 | // 撤回 467 | CanvasStore.prototype.undo = function () { 468 | if (this.state.cursor === 0) { 469 | return false 470 | } 471 | this.commit(INIT_STORE) 472 | this.commit(UPDATE_SNAPSHOT_MODEL, this.state.cursor - 1) 473 | } 474 | // 重做 475 | CanvasStore.prototype.redo = function () { 476 | if (this.state.cursor === this.state.snapshots.length - 1) { 477 | return false 478 | } 479 | this.commit(INIT_STORE) 480 | this.commit(UPDATE_SNAPSHOT_MODEL, this.state.cursor + 1) 481 | } 482 | CanvasStore.prototype.hasUndo = function () { 483 | return this.state.cursor > 0 484 | } 485 | CanvasStore.prototype.hasRedo = function () { 486 | return this.state.cursor < this.state.snapshots.length - 1 487 | } 488 | /** ******** utils *********/ 489 | function inRectBox (x, y, rectBox) { 490 | // console.log(x, y, rectBox) 491 | return x >= rectBox.left && x <= rectBox.right && 492 | y >= rectBox.top && y <= rectBox.bottom 493 | } 494 | export default CanvasStore 495 | -------------------------------------------------------------------------------- /src/service/topsort.js: -------------------------------------------------------------------------------- 1 | import flowchartConstants from '@/config/flowchart' 2 | /** 3 | * Kahn算法 检测有向图是否存在环 4 | * @param {*} graph 5 | */ 6 | export function checkGraph (graph) { 7 | // 计算出所有顶点的入度 8 | let adjacentList = {} 9 | graph.nodes.forEach(function (node) { 10 | adjacentList[node.id] = {incoming: 0, outgoing: []} 11 | }) 12 | graph.edges.forEach(function (edge) { 13 | let sourceNode = graph.nodes.filter(function (node) { 14 | return node.connectors && node.connectors[flowchartConstants.rightConnectorType] && node.connectors[flowchartConstants.rightConnectorType].id === edge.source 15 | })[0] 16 | let destinationNode = graph.nodes.filter(function (node) { 17 | return node.connectors && node.connectors[flowchartConstants.leftConnectorType] && node.connectors[flowchartConstants.leftConnectorType].id === edge.destination 18 | })[0] 19 | 20 | adjacentList[sourceNode.id].outgoing.push(destinationNode.id) 21 | adjacentList[destinationNode.id].incoming++ 22 | }) 23 | // 保存出队元素 24 | let orderedNodes = [] 25 | // 将所有入度为0的点加入到一个队列 26 | let sourceNodes = [] 27 | for (let id in adjacentList) { 28 | if (adjacentList[id].incoming === 0) { 29 | sourceNodes.push(id) 30 | } 31 | } 32 | // 记录访问过的顶点个数 33 | let count = 0 34 | while (sourceNodes.length !== 0) { 35 | // 将队头元素pop 36 | let sourceNode = sourceNodes.pop() 37 | count++ 38 | // 将这个点的所有相邻点的入度减一,如果某个相邻点的入度减小为0,则将这个相邻点加入到队列中 39 | for (let destinationNode of adjacentList[sourceNode].outgoing) { 40 | if (--adjacentList[destinationNode].incoming === 0) { 41 | sourceNodes.push(destinationNode) 42 | } 43 | } 44 | orderedNodes.push(sourceNode) 45 | } 46 | return count === graph.nodes.length 47 | } 48 | -------------------------------------------------------------------------------- /src/utils/setDragImage.polyfill.js: -------------------------------------------------------------------------------- 1 | if (typeof DataTransfer.prototype.setDragImage !== 'function') { 2 | DataTransfer.prototype.setDragImage = function (image, offsetX, offsetY) { 3 | var randomDraggingClassName, 4 | dragStylesCSS, 5 | dragStylesEl, 6 | headEl, 7 | parentFn, 8 | eventTarget 9 | 10 | // generate a random class name that will be added to the element 11 | randomDraggingClassName = 'setdragimage-ie-dragging-' + Math.round(Math.random() * Math.pow(10, 5)) + '-' + Date.now() 12 | 13 | // prepare the rules for the random class 14 | dragStylesCSS = [ 15 | '.' + randomDraggingClassName, 16 | '{', 17 | 'background: url("' + image.src + '") no-repeat #fff 0 0 !important;', 18 | 'width: ' + image.width + 'px !important;', 19 | 'height: ' + image.height + 'px !important;', 20 | 'text-indent: -9999px !important;', 21 | 'border: 0 !important;', 22 | 'outline: 0 !important;', 23 | '}', 24 | '.' + randomDraggingClassName + ' * {', 25 | 'display: none !important;', 26 | '}' 27 | ] 28 | // create the element and add it to the head of the page 29 | dragStylesEl = document.createElement('style') 30 | dragStylesEl.innerText = dragStylesCSS.join('') 31 | headEl = document.getElementsByTagName('head')[0] 32 | headEl.appendChild(dragStylesEl) 33 | 34 | /* 35 | since we can't get the target element over which the drag start event occurred 36 | (because the `this` represents the DataTransfer object and not the element), 37 | we will walk through the parents of the current functions until we find one 38 | whose first argument is a drag event 39 | */ 40 | parentFn = DataTransfer.prototype.setDragImage.caller 41 | while (!(parentFn.arguments[0] instanceof DragEvent)) { 42 | parentFn = parentFn.caller 43 | } 44 | 45 | // then, we get the target element from the event (event.target) 46 | eventTarget = parentFn.arguments[0].target 47 | // and add the class we prepared to it 48 | eventTarget.classList.add(randomDraggingClassName) 49 | 50 | /* immediately after adding the class, we remove it. in this way the browser will 51 | have time to make a snapshot and use it just so it looks like the drag element */ 52 | setTimeout(function () { 53 | // remove the styles 54 | headEl.removeChild(dragStylesEl) 55 | // remove the class 56 | eventTarget.classList.remove(randomDraggingClassName) 57 | }, 0) 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /src/utils/uuid.js: -------------------------------------------------------------------------------- 1 | function UUIDjs () { 2 | 3 | } 4 | UUIDjs.create = function (pre) { 5 | return (pre ? pre + '-' : '') + (Math.random() + '').substr(2, 8) + '-' + ((new Date()).getTime() + '').substr(-4, 4) 6 | } 7 | export default UUIDjs 8 | -------------------------------------------------------------------------------- /static/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/francecil/vue-flowchart/89c614b682934d600125850d9095ee9da18eb0b2/static/.gitkeep -------------------------------------------------------------------------------- /test/e2e/custom-assertions/elementCount.js: -------------------------------------------------------------------------------- 1 | // A custom Nightwatch assertion. 2 | // The assertion name is the filename. 3 | // Example usage: 4 | // 5 | // browser.assert.elementCount(selector, count) 6 | // 7 | // For more information on custom assertions see: 8 | // http://nightwatchjs.org/guide#writing-custom-assertions 9 | 10 | exports.assertion = function (selector, count) { 11 | this.message = 'Testing if element <' + selector + '> has count: ' + count 12 | this.expected = count 13 | this.pass = function (val) { 14 | return val === this.expected 15 | } 16 | this.value = function (res) { 17 | return res.value 18 | } 19 | this.command = function (cb) { 20 | var self = this 21 | return this.api.execute(function (selector) { 22 | return document.querySelectorAll(selector).length 23 | }, [selector], function (res) { 24 | cb.call(self, res) 25 | }) 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /test/e2e/nightwatch.conf.js: -------------------------------------------------------------------------------- 1 | require('babel-register') 2 | var config = require('../../config') 3 | 4 | // http://nightwatchjs.org/gettingstarted#settings-file 5 | module.exports = { 6 | src_folders: ['test/e2e/specs'], 7 | output_folder: 'test/e2e/reports', 8 | custom_assertions_path: ['test/e2e/custom-assertions'], 9 | 10 | selenium: { 11 | start_process: true, 12 | server_path: require('selenium-server').path, 13 | host: '127.0.0.1', 14 | port: 4444, 15 | cli_args: { 16 | 'webdriver.chrome.driver': require('chromedriver').path 17 | } 18 | }, 19 | 20 | test_settings: { 21 | default: { 22 | selenium_port: 4444, 23 | selenium_host: 'localhost', 24 | silent: true, 25 | globals: { 26 | devServerURL: 'http://localhost:' + (process.env.PORT || config.dev.port) 27 | } 28 | }, 29 | 30 | chrome: { 31 | desiredCapabilities: { 32 | browserName: 'chrome', 33 | javascriptEnabled: true, 34 | acceptSslCerts: true 35 | } 36 | }, 37 | 38 | firefox: { 39 | desiredCapabilities: { 40 | browserName: 'firefox', 41 | javascriptEnabled: true, 42 | acceptSslCerts: true 43 | } 44 | } 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /test/e2e/runner.js: -------------------------------------------------------------------------------- 1 | // 1. start the dev server using production config 2 | process.env.NODE_ENV = 'testing' 3 | 4 | const webpack = require('webpack') 5 | const DevServer = require('webpack-dev-server') 6 | 7 | const webpackConfig = require('../../build/webpack.prod.conf') 8 | const devConfigPromise = require('../../build/webpack.dev.conf') 9 | 10 | let server 11 | 12 | devConfigPromise.then(devConfig => { 13 | const devServerOptions = devConfig.devServer 14 | const compiler = webpack(webpackConfig) 15 | server = new DevServer(compiler, devServerOptions) 16 | const port = devServerOptions.port 17 | const host = devServerOptions.host 18 | return server.listen(port, host) 19 | }) 20 | .then(() => { 21 | // 2. run the nightwatch test suite against it 22 | // to run in additional browsers: 23 | // 1. add an entry in test/e2e/nightwatch.conf.js under "test_settings" 24 | // 2. add it to the --env flag below 25 | // or override the environment flag, for example: `npm run e2e -- --env chrome,firefox` 26 | // For more information on Nightwatch's config file, see 27 | // http://nightwatchjs.org/guide#settings-file 28 | let opts = process.argv.slice(2) 29 | if (opts.indexOf('--config') === -1) { 30 | opts = opts.concat(['--config', 'test/e2e/nightwatch.conf.js']) 31 | } 32 | if (opts.indexOf('--env') === -1) { 33 | opts = opts.concat(['--env', 'chrome']) 34 | } 35 | 36 | const spawn = require('cross-spawn') 37 | const runner = spawn('./node_modules/.bin/nightwatch', opts, { stdio: 'inherit' }) 38 | 39 | runner.on('exit', function (code) { 40 | server.close() 41 | process.exit(code) 42 | }) 43 | 44 | runner.on('error', function (err) { 45 | server.close() 46 | throw err 47 | }) 48 | }) 49 | -------------------------------------------------------------------------------- /test/e2e/specs/test.js: -------------------------------------------------------------------------------- 1 | // For authoring Nightwatch tests, see 2 | // http://nightwatchjs.org/guide#usage 3 | 4 | module.exports = { 5 | 'default e2e tests': function (browser) { 6 | // automatically uses dev Server port from /config.index.js 7 | // default: http://localhost:8080 8 | // see nightwatch.conf.js 9 | const devServer = browser.globals.devServerURL 10 | 11 | browser 12 | .url(devServer) 13 | .waitForElementVisible('#app', 5000) 14 | .assert.elementPresent('.hello') 15 | .assert.containsText('h1', 'Welcome to Your Vue.js App') 16 | .assert.elementCount('img', 1) 17 | .end() 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /test/unit/.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "env": { 3 | "mocha": true 4 | }, 5 | "globals": { 6 | "expect": true, 7 | "sinon": true 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /test/unit/index.js: -------------------------------------------------------------------------------- 1 | import Vue from 'vue' 2 | 3 | Vue.config.productionTip = false 4 | 5 | // require all test files (files that ends with .spec.js) 6 | const testsContext = require.context('./specs', true, /\.spec$/) 7 | testsContext.keys().forEach(testsContext) 8 | 9 | // require all src files except main.js for coverage. 10 | // you can also change this to match only the subset of files that 11 | // you want coverage for. 12 | const srcContext = require.context('../../src', true, /^\.\/(?!main(\.js)?$)/) 13 | srcContext.keys().forEach(srcContext) 14 | -------------------------------------------------------------------------------- /test/unit/karma.conf.js: -------------------------------------------------------------------------------- 1 | // This is a karma config file. For more details see 2 | // http://karma-runner.github.io/0.13/config/configuration-file.html 3 | // we are also using it with karma-webpack 4 | // https://github.com/webpack/karma-webpack 5 | 6 | var webpackConfig = require('../../build/webpack.test.conf') 7 | 8 | module.exports = function karmaConfig (config) { 9 | config.set({ 10 | // to run in additional browsers: 11 | // 1. install corresponding karma launcher 12 | // http://karma-runner.github.io/0.13/config/browsers.html 13 | // 2. add it to the `browsers` array below. 14 | browsers: ['PhantomJS'], 15 | frameworks: ['mocha', 'sinon-chai', 'phantomjs-shim'], 16 | reporters: ['spec', 'coverage'], 17 | files: ['./index.js'], 18 | preprocessors: { 19 | './index.js': ['webpack', 'sourcemap'] 20 | }, 21 | webpack: webpackConfig, 22 | webpackMiddleware: { 23 | noInfo: true 24 | }, 25 | coverageReporter: { 26 | dir: './coverage', 27 | reporters: [ 28 | { type: 'lcov', subdir: '.' }, 29 | { type: 'text-summary' } 30 | ] 31 | } 32 | }) 33 | } 34 | -------------------------------------------------------------------------------- /test/unit/specs/HelloWorld.spec.js: -------------------------------------------------------------------------------- 1 | import Vue from 'vue' 2 | import HelloWorld from '@/components/HelloWorld' 3 | 4 | describe('HelloWorld.vue', () => { 5 | it('should render correct contents', () => { 6 | const Constructor = Vue.extend(HelloWorld) 7 | const vm = new Constructor().$mount() 8 | expect(vm.$el.querySelector('.hello h1').textContent) 9 | .to.equal('Welcome to Your Vue.js App') 10 | }) 11 | }) 12 | --------------------------------------------------------------------------------