├── .babelrc ├── .editorconfig ├── .eslintignore ├── .eslintrc.js ├── .gitignore ├── CMD-120.jpg ├── LICENSE ├── README.md ├── build ├── build.js ├── check-versions.js ├── dev-client.js ├── dev-server.js ├── utils.js ├── webpack.base.conf.js ├── webpack.dev.conf.js └── webpack.prod.conf.js ├── collaborative-markdown-editor.sublime-project ├── collaborative-markdown-editor.sublime-workspace ├── config ├── .env.example.js ├── dev.env.js ├── index.js ├── prod.env.js └── test.env.js ├── dist ├── index.html └── static │ ├── css │ ├── app.5552dfce4bd612ed340b7b37a997c57e.css │ └── app.5552dfce4bd612ed340b7b37a997c57e.css.map │ └── js │ ├── app.bff16cc56b799fe482ad.js │ ├── app.bff16cc56b799fe482ad.js.map │ ├── manifest.9cfe72b33c0b39a4e7b3.js │ ├── manifest.9cfe72b33c0b39a4e7b3.js.map │ ├── vendor.f6e66b6267588c2857fc.js │ └── vendor.f6e66b6267588c2857fc.js.map ├── index.html ├── package.json ├── src ├── App.vue ├── assets │ ├── 3rd-party │ │ └── js │ │ │ └── client.js │ ├── google-drive.svg │ ├── logo.png │ ├── material.cyan-amber.min.css │ └── material.min.css ├── components │ ├── CollaboratorCursor.vue │ ├── CreateNewFileDialog.vue │ ├── PreviewView.vue │ ├── TextView.vue │ └── menu │ │ ├── Collaborator.vue │ │ ├── PageHeader.vue │ │ ├── ProfileMenu.vue │ │ └── SideMenu.vue ├── gapi │ ├── gapi-integration.js │ └── multipart.js ├── main.js ├── services │ ├── file.js │ └── index.js ├── store │ ├── actions.js │ ├── index.js │ ├── modules │ │ ├── collaborators.js │ │ └── file.js │ └── mutation-types.js ├── stores │ └── user.js └── styles │ └── _globals.scss ├── static └── .gitkeep ├── test ├── e2e │ ├── custom-assertions │ │ └── elementCount.js │ ├── nightwatch.conf.js │ ├── runner.js │ └── specs │ │ └── test.js └── unit │ ├── .eslintrc │ ├── index.js │ ├── karma.conf.js │ └── specs │ ├── components │ ├── CreateNewFileDialog.spec.js │ ├── TextView.spec.js │ └── menu │ │ └── SideMenu.spec.js │ └── services │ └── file.spec.js └── yarn.lock /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": ["es2015", "stage-2"], 3 | "plugins": ["transform-runtime"], 4 | "comments": false 5 | } 6 | -------------------------------------------------------------------------------- /.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 | src/assets/3rd-party/* -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | root: true, 3 | parser: 'babel-eslint', 4 | parserOptions: { 5 | sourceType: 'module' 6 | }, 7 | // https://github.com/feross/standard/blob/master/RULES.md#javascript-standard-style 8 | extends: 'standard', 9 | // required to lint *.vue files 10 | plugins: [ 11 | 'html' 12 | ], 13 | // add your custom rules here 14 | 'rules': { 15 | // allow paren-less arrow functions 16 | 'arrow-parens': 0, 17 | // allow async-await 18 | 'generator-star-spacing': 0, 19 | // allow debugger during development 20 | 'no-debugger': process.env.NODE_ENV === 'production' ? 2 : 0 21 | // 'no-native-reassign': 0, 22 | // 'no-global-assign': 0, 23 | // 'no-unused-vars': 0 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | node_modules/ 3 | npm-debug.log 4 | selenium-debug.log 5 | test/unit/coverage 6 | test/e2e/reports 7 | config/.env.js 8 | vue-gdrive.sublime-workspace 9 | -------------------------------------------------------------------------------- /CMD-120.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tralves/collaborative-markdown-editor/b00ef1c0955f801663d7dc830eaa2b51cbf17068/CMD-120.jpg -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "{}" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright {yyyy} {name of copyright owner} 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Collaborative Markdown Editor 2 | 3 | > Markdown editor with Google Drive integration 4 | 5 | This is a markdown editor written in VueJS that uses google drive API for storage, sharing and realtime colaboration. It was made from the [Vuejs + GDrive](https://github.com/tralves/vue-gdrive) project. 6 | 7 | ## DEMO 8 | Try the app [here](https://tralves.github.io/collaborative-markdown-editor/)! 9 | 10 | ## Building from source 11 | 12 | ### Build Setup 13 | 14 | ``` bash 15 | # clone 16 | git clone https://github.com/tralves/vue-gdrive.git 17 | cd vue-gdrive 18 | 19 | # install dependencies 20 | npm install 21 | ``` 22 | 23 | ### Create a Google APIs project and Activate the Drive API 24 | 25 | First, you need to activate the Drive API for your app. You can do it by configuring your API project in the 26 | [Google Developers Console](https://console.developers.google.com/). 27 | 28 | 29 | - Go to [https://console.developers.google.com/apis/library](https://console.developers.google.com/apis/library). 30 | - Ppen the dropdown in the top bar, next to the GoogleAPIs logo. 31 | - Select **Create project**. 32 | - Choose the project name. 33 | - Press **Create** 34 | - Open the **API Manager** on the left sidebar. 35 | - Select **Credentials** -> **Create Credentials** -> **OAuth Client ID** 36 | - If using a new project, select **Configure consent screen* and fill out the form 37 | - Select an **EMAIL ADDRESS** and enter a **PRODUCT NAME** if not already set and click the Save button. 38 | - Select the application type *Web application** 39 | - List your hostname in the **Authorized JavaScript Origins** field. 40 | - Click the **Create** button. Note the resulting client ID and secret which you'll need later on. 41 | 42 | > The hostname cannot be `localhost`. To test from your machine, create an alias in `etc/hosts` like `127.0.0.1 myvuegdrive.dev`. In this case, if you use `npm run dev`, the hostname of your application will be `myvuegdrive.dev:8080`. 43 | 44 | To enable integration with the Drive UI, including the sharing dialog, perform the following steps. 45 | 46 | - Select **Library** section in **API Manager**. 47 | - Search for 'Drive API' and click on 'Drive API' in the results. 48 | - Click **Enable API**. 49 | - Select the **Drive UI Integration** tab. 50 | - Fill out the **Application Name** and upload at least one **Application Icon**. 51 | - Set the *Open URL** to `http://YOURHOST?file={ids}&user={userId}&action={action}`. 52 | - Check the *Creating files** option and set the **New URL** to `http://YOURHOST/?user={userId}&action={action}`. 53 | - Fill *Default MIME types* with `text/markdown`. 54 | - Fill *Default File Extensions* with `.md`. 55 | - Click **Save Changes**. 56 | 57 | To enable integration with the Google+ API to retrieve the user name, email and avatar, perform the following steps. 58 | 59 | - Select **Library** section in **API Manager**. 60 | - Search for 'Google+' and click on 'Google+ API' in the results. 61 | - Click **Enable**. 62 | 63 | Adjust the above URLs as needed for the correct hostname or path. Localhost is currently not allowed. 64 | 65 | Note the resulting application ID on top of the page. 66 | 67 | ### Setup your App information 68 | 69 | Copy `config/.env.example.js` to `config/.env.js`. 70 | ``` bash 71 | cp config/.env.example.js config/.env.js 72 | ``` 73 | Update the `CLIENT_ID` and `APPLICATION_ID` constants in `config/.env.js` file. Configurations cascade from `prod` to `dev` to `test`. 74 | 75 | ## Run and deploy 76 | 77 | ``` bash 78 | # serve with hot reload at localhost:8080 79 | npm run dev 80 | 81 | # build for production with minification 82 | npm run build 83 | ``` 84 | 85 | This application was build from the VueJS webpack template. For detailed explanation on how things work, checkout the [guide](http://vuejs-templates.github.io/webpack/) and [docs for vue-loader](http://vuejs.github.io/vue-loader). 86 | 87 | 93 | -------------------------------------------------------------------------------- /build/build.js: -------------------------------------------------------------------------------- 1 | // https://github.com/shelljs/shelljs 2 | require('./check-versions')() 3 | require('shelljs/global') 4 | env.NODE_ENV = 'production' 5 | 6 | var path = require('path') 7 | var config = require('../config') 8 | var ora = require('ora') 9 | var webpack = require('webpack') 10 | var webpackConfig = require('./webpack.prod.conf') 11 | 12 | console.log( 13 | ' Tip:\n' + 14 | ' Built files are meant to be served over an HTTP server.\n' + 15 | ' Opening index.html over file:// won\'t work.\n' 16 | ) 17 | 18 | var spinner = ora('building for production...') 19 | spinner.start() 20 | 21 | var assetsPath = path.join(config.build.assetsRoot, config.build.assetsSubDirectory) 22 | rm('-rf', assetsPath) 23 | mkdir('-p', assetsPath) 24 | cp('-R', 'static/*', assetsPath) 25 | 26 | webpack(webpackConfig, function (err, stats) { 27 | spinner.stop() 28 | if (err) throw err 29 | process.stdout.write(stats.toString({ 30 | colors: true, 31 | modules: false, 32 | children: false, 33 | chunks: false, 34 | chunkModules: false 35 | }) + '\n') 36 | }) 37 | -------------------------------------------------------------------------------- /build/check-versions.js: -------------------------------------------------------------------------------- 1 | var semver = require('semver') 2 | var chalk = require('chalk') 3 | var packageConfig = require('../package.json') 4 | var exec = function (cmd) { 5 | return require('child_process') 6 | .execSync(cmd).toString().trim() 7 | } 8 | 9 | var versionRequirements = [ 10 | { 11 | name: 'node', 12 | currentVersion: semver.clean(process.version), 13 | versionRequirement: packageConfig.engines.node 14 | }, 15 | { 16 | name: 'npm', 17 | currentVersion: exec('npm --version'), 18 | versionRequirement: packageConfig.engines.npm 19 | } 20 | ] 21 | 22 | module.exports = function () { 23 | var warnings = [] 24 | for (var i = 0; i < versionRequirements.length; i++) { 25 | var mod = versionRequirements[i] 26 | if (!semver.satisfies(mod.currentVersion, mod.versionRequirement)) { 27 | warnings.push(mod.name + ': ' + 28 | chalk.red(mod.currentVersion) + ' should be ' + 29 | chalk.green(mod.versionRequirement) 30 | ) 31 | } 32 | } 33 | 34 | if (warnings.length) { 35 | console.log('') 36 | console.log(chalk.yellow('To use this template, you must update following to modules:')) 37 | console.log() 38 | for (var i = 0; i < warnings.length; i++) { 39 | var warning = warnings[i] 40 | console.log(' ' + warning) 41 | } 42 | console.log() 43 | process.exit(1) 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /build/dev-client.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable */ 2 | require('eventsource-polyfill') 3 | var hotClient = require('webpack-hot-middleware/client?noInfo=true&reload=true') 4 | 5 | hotClient.subscribe(function (event) { 6 | if (event.action === 'reload') { 7 | window.location.reload() 8 | } 9 | }) 10 | -------------------------------------------------------------------------------- /build/dev-server.js: -------------------------------------------------------------------------------- 1 | require('./check-versions')() 2 | var config = require('../config') 3 | if (!process.env.NODE_ENV) process.env.NODE_ENV = JSON.parse(config.dev.env.NODE_ENV) 4 | var path = require('path') 5 | var express = require('express') 6 | var webpack = require('webpack') 7 | var opn = require('opn') 8 | var proxyMiddleware = require('http-proxy-middleware') 9 | var webpackConfig = process.env.NODE_ENV === 'testing' 10 | ? require('./webpack.prod.conf') 11 | : require('./webpack.dev.conf') 12 | 13 | // default port where dev server listens for incoming traffic 14 | var port = process.env.PORT || config.dev.port 15 | // Define HTTP proxies to your custom API backend 16 | // https://github.com/chimurai/http-proxy-middleware 17 | var proxyTable = config.dev.proxyTable 18 | 19 | var app = express() 20 | var compiler = webpack(webpackConfig) 21 | 22 | var devMiddleware = require('webpack-dev-middleware')(compiler, { 23 | publicPath: webpackConfig.output.publicPath, 24 | stats: { 25 | colors: true, 26 | chunks: false 27 | } 28 | }) 29 | 30 | var hotMiddleware = require('webpack-hot-middleware')(compiler) 31 | // force page reload when html-webpack-plugin template changes 32 | compiler.plugin('compilation', function (compilation) { 33 | compilation.plugin('html-webpack-plugin-after-emit', function (data, cb) { 34 | hotMiddleware.publish({ action: 'reload' }) 35 | cb() 36 | }) 37 | }) 38 | 39 | // proxy api requests 40 | Object.keys(proxyTable).forEach(function (context) { 41 | var options = proxyTable[context] 42 | if (typeof options === 'string') { 43 | options = { target: options } 44 | } 45 | app.use(proxyMiddleware(context, options)) 46 | }) 47 | 48 | // handle fallback for HTML5 history API 49 | app.use(require('connect-history-api-fallback')()) 50 | 51 | // serve webpack bundle output 52 | app.use(devMiddleware) 53 | 54 | // enable hot-reload and state-preserving 55 | // compilation error display 56 | app.use(hotMiddleware) 57 | 58 | // serve pure static assets 59 | var staticPath = path.posix.join(config.dev.assetsPublicPath, config.dev.assetsSubDirectory) 60 | app.use(staticPath, express.static('./static')) 61 | 62 | module.exports = app.listen(port, function (err) { 63 | if (err) { 64 | console.log(err) 65 | return 66 | } 67 | var uri = 'http://localhost:' + port 68 | console.log('Listening at ' + uri + '\n') 69 | 70 | // when env is testing, don't need open it 71 | if (process.env.NODE_ENV !== 'testing') { 72 | opn(uri) 73 | } 74 | }) 75 | -------------------------------------------------------------------------------- /build/utils.js: -------------------------------------------------------------------------------- 1 | var path = require('path') 2 | var config = require('../config') 3 | var ExtractTextPlugin = require('extract-text-webpack-plugin') 4 | 5 | exports.assetsPath = function (_path) { 6 | var assetsSubDirectory = process.env.NODE_ENV === 'production' 7 | ? config.build.assetsSubDirectory 8 | : config.dev.assetsSubDirectory 9 | return path.posix.join(assetsSubDirectory, _path) 10 | } 11 | 12 | exports.cssLoaders = function (options) { 13 | options = options || {} 14 | // generate loader string to be used with extract text plugin 15 | function generateLoaders (loaders) { 16 | var sourceLoader = loaders.map(function (loader) { 17 | var extraParamChar 18 | if (/\?/.test(loader)) { 19 | loader = loader.replace(/\?/, '-loader?') 20 | extraParamChar = '&' 21 | } else { 22 | loader = loader + '-loader' 23 | extraParamChar = '?' 24 | } 25 | return loader + (options.sourceMap ? extraParamChar + 'sourceMap' : '') 26 | }).join('!') 27 | 28 | // Extract CSS when that option is specified 29 | // (which is the case during production build) 30 | if (options.extract) { 31 | return ExtractTextPlugin.extract('vue-style-loader', sourceLoader) 32 | } else { 33 | return ['vue-style-loader', sourceLoader].join('!') 34 | } 35 | } 36 | 37 | // http://vuejs.github.io/vue-loader/en/configurations/extract-css.html 38 | return { 39 | css: generateLoaders(['css']), 40 | postcss: generateLoaders(['css']), 41 | less: generateLoaders(['css', 'less']), 42 | sass: generateLoaders(['css', 'sass?indentedSyntax']), 43 | scss: generateLoaders(['css', 'sass']), 44 | stylus: generateLoaders(['css', 'stylus']), 45 | styl: generateLoaders(['css', 'stylus']) 46 | } 47 | } 48 | 49 | // Generate loaders for standalone style files (outside of .vue) 50 | exports.styleLoaders = function (options) { 51 | var output = [] 52 | var loaders = exports.cssLoaders(options) 53 | for (var extension in loaders) { 54 | var loader = loaders[extension] 55 | output.push({ 56 | test: new RegExp('\\.' + extension + '$'), 57 | loader: loader 58 | }) 59 | } 60 | return output 61 | } 62 | -------------------------------------------------------------------------------- /build/webpack.base.conf.js: -------------------------------------------------------------------------------- 1 | var path = require('path') 2 | var config = require('../config') 3 | var utils = require('./utils') 4 | var projectRoot = path.resolve(__dirname, '../') 5 | 6 | var env = process.env.NODE_ENV 7 | // check env & config/index.js to decide weither to enable CSS Sourcemaps for the 8 | // various preprocessor loaders added to vue-loader at the end of this file 9 | var cssSourceMapDev = (env === 'development' && config.dev.cssSourceMap) 10 | var cssSourceMapProd = (env === 'production' && config.build.productionSourceMap) 11 | var useCssSourceMap = cssSourceMapDev || cssSourceMapProd 12 | 13 | module.exports = { 14 | entry: { 15 | app: './src/main.js' 16 | }, 17 | output: { 18 | path: config.build.assetsRoot, 19 | publicPath: process.env.NODE_ENV === 'production' ? config.build.assetsPublicPath : config.dev.assetsPublicPath, 20 | filename: '[name].js' 21 | }, 22 | resolve: { 23 | extensions: ['', '.js', '.vue'], 24 | fallback: [path.join(__dirname, '../node_modules')], 25 | alias: { 26 | 'vue$': 'vue/dist/vue', 27 | 'src': path.resolve(__dirname, '../src'), 28 | 'assets': path.resolve(__dirname, '../src/assets'), 29 | 'components': path.resolve(__dirname, '../src/components') 30 | } 31 | }, 32 | resolveLoader: { 33 | fallback: [path.join(__dirname, '../node_modules')] 34 | }, 35 | module: { 36 | preLoaders: [ 37 | { 38 | test: /\.vue$/, 39 | loader: 'eslint', 40 | include: projectRoot, 41 | exclude: /node_modules/ 42 | }, 43 | { 44 | test: /\.js$/, 45 | loader: 'eslint', 46 | include: projectRoot, 47 | exclude: /node_modules/ 48 | } 49 | ], 50 | loaders: [ 51 | { 52 | test: /\.vue$/, 53 | loader: 'vue' 54 | }, 55 | { 56 | test: /\.js$/, 57 | loader: 'babel', 58 | include: projectRoot, 59 | exclude: /node_modules/ 60 | }, 61 | { 62 | test: /\.json$/, 63 | loader: 'json' 64 | }, 65 | { 66 | test: /\.(png|jpe?g|gif|svg)(\?.*)?$/, 67 | loader: 'url', 68 | query: { 69 | limit: 10000, 70 | name: utils.assetsPath('img/[name].[hash:7].[ext]') 71 | } 72 | }, 73 | { 74 | test: /\.(woff2?|eot|ttf|otf)(\?.*)?$/, 75 | loader: 'url', 76 | query: { 77 | limit: 10000, 78 | name: utils.assetsPath('fonts/[name].[hash:7].[ext]') 79 | } 80 | } 81 | ] 82 | }, 83 | eslint: { 84 | formatter: require('eslint-friendly-formatter') 85 | }, 86 | vue: { 87 | loaders: utils.cssLoaders({ sourceMap: useCssSourceMap }), 88 | postcss: [ 89 | require('autoprefixer')({ 90 | browsers: ['last 2 versions'] 91 | }) 92 | ] 93 | } 94 | } 95 | -------------------------------------------------------------------------------- /build/webpack.dev.conf.js: -------------------------------------------------------------------------------- 1 | var config = require('../config') 2 | var webpack = require('webpack') 3 | var merge = require('webpack-merge') 4 | var utils = require('./utils') 5 | var baseWebpackConfig = require('./webpack.base.conf') 6 | var HtmlWebpackPlugin = require('html-webpack-plugin') 7 | 8 | // add hot-reload related code to entry chunks 9 | Object.keys(baseWebpackConfig.entry).forEach(function (name) { 10 | baseWebpackConfig.entry[name] = ['./build/dev-client'].concat(baseWebpackConfig.entry[name]) 11 | }) 12 | 13 | module.exports = merge(baseWebpackConfig, { 14 | module: { 15 | loaders: utils.styleLoaders({ sourceMap: config.dev.cssSourceMap }) 16 | }, 17 | // eval-source-map is faster for development 18 | devtool: '#eval-source-map', 19 | plugins: [ 20 | new webpack.DefinePlugin({ 21 | 'process.env': config.dev.env 22 | }), 23 | // https://github.com/glenjamin/webpack-hot-middleware#installation--usage 24 | new webpack.optimize.OccurenceOrderPlugin(), 25 | new webpack.HotModuleReplacementPlugin(), 26 | new webpack.NoErrorsPlugin(), 27 | // https://github.com/ampedandwired/html-webpack-plugin 28 | new HtmlWebpackPlugin({ 29 | filename: 'index.html', 30 | template: 'index.html', 31 | inject: true 32 | }) 33 | ] 34 | }) 35 | -------------------------------------------------------------------------------- /build/webpack.prod.conf.js: -------------------------------------------------------------------------------- 1 | var path = require('path') 2 | var config = require('../config') 3 | var utils = require('./utils') 4 | var webpack = require('webpack') 5 | var merge = require('webpack-merge') 6 | var baseWebpackConfig = require('./webpack.base.conf') 7 | var ExtractTextPlugin = require('extract-text-webpack-plugin') 8 | var HtmlWebpackPlugin = require('html-webpack-plugin') 9 | 10 | var env = process.env.NODE_ENV === 'testing' 11 | ? require('../config/test.env') 12 | : config.build.env 13 | 14 | var webpackConfig = merge(baseWebpackConfig, { 15 | module: { 16 | // loaders: utils.styleLoaders({ sourceMap: config.build.productionSourceMap, extract: true }) 17 | loaders: [ 18 | utils.styleLoaders({ sourceMap: config.build.productionSourceMap, extract: true }), 19 | { 20 | test: /\.html$/, 21 | loader: 'raw' 22 | }, 23 | { 24 | test: /index\.html$/, 25 | loader: 'string-replace', 26 | query: { 27 | search: '', 28 | replace: config.build.env.GOOGLE_TRACKING_ID 29 | } 30 | } 31 | ] 32 | }, 33 | devtool: config.build.productionSourceMap ? '#source-map' : false, 34 | output: { 35 | path: config.build.assetsRoot, 36 | filename: utils.assetsPath('js/[name].[chunkhash].js'), 37 | chunkFilename: utils.assetsPath('js/[id].[chunkhash].js') 38 | }, 39 | vue: { 40 | loaders: utils.cssLoaders({ 41 | sourceMap: config.build.productionSourceMap, 42 | extract: true 43 | }) 44 | }, 45 | plugins: [ 46 | // http://vuejs.github.io/vue-loader/en/workflow/production.html 47 | new webpack.DefinePlugin({ 48 | 'process.env': env 49 | }), 50 | new webpack.optimize.UglifyJsPlugin({ 51 | compress: { 52 | warnings: false 53 | } 54 | }), 55 | new webpack.optimize.OccurrenceOrderPlugin(), 56 | // extract css into its own file 57 | new ExtractTextPlugin(utils.assetsPath('css/[name].[contenthash].css')), 58 | // generate dist index.html with correct asset hash for caching. 59 | // you can customize output by editing /index.html 60 | // see https://github.com/ampedandwired/html-webpack-plugin 61 | new HtmlWebpackPlugin({ 62 | filename: process.env.NODE_ENV === 'testing' 63 | ? 'index.html' 64 | : config.build.index, 65 | template: 'index.html', 66 | inject: true, 67 | minify: { 68 | removeComments: true, 69 | collapseWhitespace: true, 70 | removeAttributeQuotes: true 71 | // more options: 72 | // https://github.com/kangax/html-minifier#options-quick-reference 73 | }, 74 | // necessary to consistently work with multiple chunks via CommonsChunkPlugin 75 | chunksSortMode: 'dependency' 76 | }), 77 | // split vendor js into its own file 78 | new webpack.optimize.CommonsChunkPlugin({ 79 | name: 'vendor', 80 | minChunks: function (module, count) { 81 | // any required modules inside node_modules are extracted to vendor 82 | return ( 83 | module.resource && 84 | /\.js$/.test(module.resource) && 85 | module.resource.indexOf( 86 | path.join(__dirname, '../node_modules') 87 | ) === 0 88 | ) 89 | } 90 | }), 91 | // extract webpack runtime and module manifest to its own file in order to 92 | // prevent vendor hash from being updated whenever app bundle is updated 93 | new webpack.optimize.CommonsChunkPlugin({ 94 | name: 'manifest', 95 | chunks: ['vendor'] 96 | }) 97 | ] 98 | }) 99 | 100 | if (config.build.productionGzip) { 101 | var CompressionWebpackPlugin = require('compression-webpack-plugin') 102 | 103 | webpackConfig.plugins.push( 104 | new CompressionWebpackPlugin({ 105 | asset: '[path].gz[query]', 106 | algorithm: 'gzip', 107 | test: new RegExp( 108 | '\\.(' + 109 | config.build.productionGzipExtensions.join('|') + 110 | ')$' 111 | ), 112 | threshold: 10240, 113 | minRatio: 0.8 114 | }) 115 | ) 116 | } 117 | 118 | module.exports = webpackConfig 119 | -------------------------------------------------------------------------------- /collaborative-markdown-editor.sublime-project: -------------------------------------------------------------------------------- 1 | { 2 | "folders": 3 | [ 4 | { 5 | "folder_exclude_patterns": 6 | [ 7 | "node_modules", 8 | "build", 9 | "dist", 10 | "test/unit/coverage" 11 | ], 12 | "path": "." 13 | } 14 | ], 15 | "SublimeLinter": 16 | { 17 | "linters": { 18 | "jshint": { 19 | "@disable": true 20 | }, 21 | "eslint": { 22 | "@disable": false 23 | } 24 | } 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /collaborative-markdown-editor.sublime-workspace: -------------------------------------------------------------------------------- 1 | {} -------------------------------------------------------------------------------- /config/.env.example.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Configuration variables for specific environments. 3 | * 4 | * Configurations cascade from `prod` to `dev` to `test`. 5 | */ 6 | 7 | module.exports = { 8 | prod: { 9 | CLIENT_ID: '"YOUR-CLIENT-ID"', 10 | APPLICATION_ID: '"YOUR-APPLICATION-ID"', 11 | GOOGLE_TRACKING_ID: 'YOUR_GOOGLE_TRACKING_ID' 12 | /* project configs */ 13 | DEFAULT_MIMETYPE: 'text/plain' 14 | }, 15 | dev: { 16 | // CLIENT_ID: '"YOUR-CLIENT-ID"', 17 | // APPLICATION_ID: '"YOUR-APPLICATION-ID"' 18 | }, 19 | test: { 20 | // CLIENT_ID: '"YOUR-CLIENT-ID"', 21 | // APPLICATION_ID: '"YOUR-APPLICATION-ID"' 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /config/dev.env.js: -------------------------------------------------------------------------------- 1 | var merge = require('webpack-merge') 2 | var prodEnv = require('./prod.env') 3 | var dotEnv = require('./.env') 4 | 5 | module.exports = merge(prodEnv, { 6 | NODE_ENV: '"development"' 7 | }, 8 | dotEnv.dev) 9 | -------------------------------------------------------------------------------- /config/index.js: -------------------------------------------------------------------------------- 1 | // see http://vuejs-templates.github.io/webpack for documentation. 2 | var path = require('path') 3 | 4 | module.exports = { 5 | build: { 6 | env: require('./prod.env'), 7 | index: path.resolve(__dirname, '../dist/index.html'), 8 | assetsRoot: path.resolve(__dirname, '../dist'), 9 | assetsSubDirectory: 'static', 10 | assetsPublicPath: './', 11 | productionSourceMap: true, 12 | // Gzip off by default as many popular static hosts such as 13 | // Surge or Netlify already gzip all static assets for you. 14 | // Before setting to `true`, make sure to: 15 | // npm install --save-dev compression-webpack-plugin 16 | productionGzip: false, 17 | productionGzipExtensions: ['js', 'css'] 18 | }, 19 | dev: { 20 | env: require('./dev.env'), 21 | port: 8080, 22 | assetsSubDirectory: 'static', 23 | assetsPublicPath: '/', 24 | proxyTable: {}, 25 | // CSS Sourcemaps off by default because relative paths are "buggy" 26 | // with this option, according to the CSS-Loader README 27 | // (https://github.com/webpack/css-loader#sourcemaps) 28 | // In our experience, they generally work as expected, 29 | // just be aware of this issue when enabling this option. 30 | cssSourceMap: false 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /config/prod.env.js: -------------------------------------------------------------------------------- 1 | var merge = require('webpack-merge') 2 | var dotEnv = require('./.env') 3 | 4 | module.exports = merge({ 5 | NODE_ENV: '"production"' 6 | }, 7 | dotEnv.prod) 8 | -------------------------------------------------------------------------------- /config/test.env.js: -------------------------------------------------------------------------------- 1 | var merge = require('webpack-merge') 2 | var devEnv = require('./dev.env') 3 | var dotEnv = require('./.env') 4 | 5 | module.exports = merge(devEnv, { 6 | NODE_ENV: '"testing"' 7 | }, 8 | dotEnv.test) 9 | -------------------------------------------------------------------------------- /dist/index.html: -------------------------------------------------------------------------------- 1 | Collaborative Markdown Editor
-------------------------------------------------------------------------------- /dist/static/js/app.bff16cc56b799fe482ad.js: -------------------------------------------------------------------------------- 1 | webpackJsonp([2,0],{0:function(e,t,n){"use strict";function a(e){return e&&e.__esModule?e:{default:e}}var o=n(197),i=a(o),r=n(349),s=a(r),l=n(365),u=a(l),c=n(48),d=a(c);n(344),n(253),n(262),n(252).polyfill(),i.default.use(u.default),new i.default({store:d.default,el:"#app",template:"",components:{App:s.default}})},11:function(e,t,n){"use strict";Object.defineProperty(t,"__esModule",{value:!0});var a=n(200);Object.defineProperty(t,"file",{enumerable:!0,get:function(){return a.file}})},30:function(e,t,n){"use strict";function a(e){return e&&e.__esModule?e:{default:e}}Object.defineProperty(t,"__esModule",{value:!0});var o=n(212),i=a(o),r=n(32),s=a(r),l=n(51),u=a(l),c=n(52),d=a(c),f=n(199),p=a(f),h=function(){function e(){(0,u.default)(this,e),this.CLIENT_ID="649336170162-gkip34o9rqpu9tkbtg0ggu5fs792il94.apps.googleusercontent.com",this.SCOPES=["email","profile","https://www.googleapis.com/auth/drive","https://www.googleapis.com/auth/drive.install"],this.DEFAULT_FIELDS="capabilities(canCopy,canEdit),createdTime,fileExtension,id,mimeType,modifiedTime,name,shared,size,version"}return(0,d.default)(e,[{key:"loadDriveApis",value:function(){return new s.default(function(e,t){!function t(){console.info("loadDriveApi..."),gapi&&gapi.client?s.default.all([gapi.client.load("drive","v3"),gapi.load("picker"),gapi.load("drive-share"),gapi.load("drive-realtime")]).then(function(){console.info("gapi.client.load finished!"),e()}):(setTimeout(function(){return t()},100),console.info("wait for it..."))}()})}},{key:"authorize",value:function(){var e=this,t=!(arguments.length>0&&void 0!==arguments[0])||arguments[0],n=arguments.length>1&&void 0!==arguments[1]?arguments[1]:null;return console.info("authorize"),new s.default(function(a,o){gapi.auth.authorize(e.buildAuthRequest(t,n),function(e){console.info("RESULT!!!!!!!!"),e&&!e.error?(console.info("resolved!"),a()):(console.info("rejected!"),o("Sorry, you are not allowed to open the file..."))})})}},{key:"buildAuthRequest",value:function(e,t){var n={client_id:this.CLIENT_ID,scope:this.SCOPES.join(" "),immediate:e};return t&&(n.login_hint=t,n.authuser=-1),n}},{key:"saveFile",value:function(e,t){var n,a;e.metadata.id?(n="/upload/drive/v3/files/"+encodeURIComponent(e.metadata.id),a="PATCH"):(n="/upload/drive/v3/files",a="POST");var o={mimeType:e.metadata.mimeType,name:t||e.metadata.name},r=(new p.default).append("application/json",(0,i.default)(o)).append(e.metadata.mimeType,e.content).finish();return gapi.client.request({path:n,method:a,params:{uploadType:"multipart",fields:this.DEFAULT_FIELDS},headers:{"Content-Type":r.type},body:r.body})}},{key:"combineAndStoreResults",value:function(e,t){var n={metadata:e,content:t};return n}},{key:"loadFile",value:function(e){var t=this;return new s.default(function(n,a){var o=gapi.client.drive.files.get({fileId:e,fields:t.DEFAULT_FIELDS}),i=gapi.client.drive.files.get({fileId:e,alt:"media"});n(s.default.all([o,i]))}).then(function(e){return{metadata:e[0].result,content:e[1].body}})}},{key:"loadRtDoc",value:function(e,t,n,a,o){var i=this,r=this;return new s.default(function(s,l){gapi.drive.realtime.load(e.metadata.id,function(e){console.log("loaded realtime doc",e),r.contentText=e.getModel().getRoot().get("content"),r.contentText.addEventListener(gapi.drive.realtime.EventType.TEXT_INSERTED,t),r.contentText.addEventListener(gapi.drive.realtime.EventType.TEXT_DELETED,t),r.filenameText=e.getModel().getRoot().get("filename"),r.filenameText.addEventListener(gapi.drive.realtime.EventType.TEXT_INSERTED,n),r.filenameText.addEventListener(gapi.drive.realtime.EventType.TEXT_DELETED,n),e.addEventListener(gapi.drive.realtime.EventType.COLLABORATOR_JOINED,a),e.addEventListener(gapi.drive.realtime.EventType.COLLABORATOR_LEFT,a),a({target:e,type:"init_collaborators"}),r.cursorsMap=e.getModel().getRoot().get("cursors"),r.cursorsMap.addEventListener(gapi.drive.realtime.EventType.VALUE_CHANGED,o),s(e.getModel())},function(t){console.log("initializing model",t);var n=t.createString(e.content);t.getRoot().set("content",n);var a=t.createString(e.content);t.getRoot().set("filename",a),r.cursorsMap=t.createMap(),t.getRoot().set("cursors",r.cursorsMap)},function(r){console.log("failed realtime load",r),r.type===window.gapi.drive.realtime.ErrorType.TOKEN_REFRESH_REQUIRED?i.authorize(!0).then(function(){i.loadRtDoc(e,t,n,a,o)}).catch(function(){l("Could not authorize")}):r.type===window.gapi.drive.realtime.ErrorType.CLIENT_ERROR?l("An Error happened: "+r.message):r.type===window.gapi.drive.realtime.ErrorType.NOT_FOUND?l("The file does not exist or you do not have permissions to access it."):r.type===window.gapi.drive.realtime.ErrorType.FORBIDDEN&&(l("You do not have access to this file. Try having the owner share it with you from Google Drive."),window.location.href="/")})})}},{key:"showPicker",value:function(){return new s.default(function(e,t){var n=new google.picker.DocsView(google.picker.ViewId.DOCS);n.setMimeTypes("text/markdown"),n.setSelectFolderEnabled(!0),n.setIncludeFolders(!0);var a=(new google.picker.PickerBuilder).setAppId("649336170162").setOAuthToken(gapi.auth.getToken().access_token).addView(n).setCallback(function(n){if("picked"===n.action){var a=n.docs[0].id;e(a)}else"cancel"===n.action&&t("cancel")}).build();a.setVisible(!0)})}},{key:"showSharing",value:function(e){var t=new gapi.drive.share.ShareClient("649336170162");t.setOAuthToken(gapi.auth.getToken().access_token),t.setItemIds([e]),t.showSettingsDialog()}}]),e}();t.default=new h},31:function(e,t){"use strict";Object.defineProperty(t,"__esModule",{value:!0});t.NEW_FILE="NEW_FILE",t.FILE_SAVED="FILE_SAVED",t.FILE_SAVING="FILE_SAVING",t.FILE_NOT_SAVED="FILE_NOT_SAVED",t.EDIT_CONTENT="EDIT_CONTENT",t.INSERT_CONTENT="INSERT_CONTENT",t.DELETE_CONTENT="DELETE_CONTENT",t.RENAME_FILE="RENAME_FILE",t.LOAD_FILE="LOAD_FILE",t.SET_COLLABORATORS="SET_COLLABORATORS",t.SET_CURSORS="SET_CURSORS"},48:function(e,t,n){"use strict";function a(e){if(e&&e.__esModule)return e;var t={};if(null!=e)for(var n in e)Object.prototype.hasOwnProperty.call(e,n)&&(t[n]=e[n]);return t.default=e,t}function o(e){return e&&e.__esModule?e:{default:e}}Object.defineProperty(t,"__esModule",{value:!0});var i=n(197),r=o(i),s=n(47),l=o(s),u=n(49),c=o(u),d=n(202),f=o(d),p=n(201),h=a(p);r.default.use(l.default),console.log("Using Vuex");var m=!1;t.default=new l.default.Store({actions:h,modules:{file:c.default,collaborators:f.default},strict:m})},49:function(e,t,n){"use strict";function a(e){return e&&e.__esModule?e:{default:e}}Object.defineProperty(t,"__esModule",{value:!0}),t.STATUS_LIST=void 0;var o,i=n(53),r=a(i),s=n(86),l=a(s),u=n(31),c=t.STATUS_LIST={INITIAL:"INTIAL",SAVING:"SAVING",SAVED:"SAVED",NOT_SAVED:"NOT_SAVED",DIRTY:"DIRTY"},d={metadata:{name:"New document",id:"no-id"},content:"",status:c.INITIAL},f=(o={},(0,r.default)(o,u.NEW_FILE,function(e,t){e.metadata={id:null,mimeType:"text/markdown",name:t},e.content=""}),(0,r.default)(o,u.FILE_SAVED,function(e,t){l.default.assign(e.metadata,t),e.status=c.SAVED}),(0,r.default)(o,u.FILE_NOT_SAVED,function(e){e.status=c.NOT_SAVED}),(0,r.default)(o,u.FILE_SAVING,function(e){e.status=c.SAVING}),(0,r.default)(o,u.FILE_DIRTY,function(e){e.status=c.DIRTY}),(0,r.default)(o,u.EDIT_CONTENT,function(e,t){e.content=t}),(0,r.default)(o,u.INSERT_CONTENT,function(e,t){var n=t.index,a=t.text,o=e.content;e.content=o.slice(0,n)+a+o.slice(n)}),(0,r.default)(o,u.DELETE_CONTENT,function(e,t){var n=t.index,a=t.text,o=e.content;e.content=o.slice(0,n)+o.slice(n+a.length)}),(0,r.default)(o,u.RENAME_FILE,function(e,t){e.metadata.name=t}),(0,r.default)(o,u.LOAD_FILE,function(e,t){e.metadata=t.metadata,e.content=t.content,e.status=e.status=c.SAVED}),o);t.default={state:d,mutations:f}},199:function(e,t,n){"use strict";function a(e){return e&&e.__esModule?e:{default:e}}Object.defineProperty(t,"__esModule",{value:!0});var o=n(51),i=a(o),r=n(52),s=a(r),l=function(){function e(){(0,i.default)(this,e),this.boundary=Math.random().toString(36).slice(2),this.mimeType='multipart/mixed; boundary="'+this.boundary+'"',this.parts=[],this.body=null}return(0,s.default)(e,[{key:"append",value:function(e,t){if(null!==this.body)throw new Error("Builder has already been finalized.");return this.parts.push("\r\n--",this.boundary,"\r\n","Content-Type: ",e,"\r\n\r\n",t),this}},{key:"finish",value:function(){if(0===this.parts.length)throw new Error("No parts have been added.");return null===this.body&&(this.parts.push("\r\n--",this.boundary,"--"),this.body=this.parts.join("")),{type:this.mimeType,body:this.body}}}]),e}();t.default=l},200:function(e,t,n){"use strict";function a(e){return e&&e.__esModule?e:{default:e}}Object.defineProperty(t,"__esModule",{value:!0}),t.file=void 0;var o=n(32),i=a(o),r=n(30),s=a(r),l=n(48),u=a(l),c=n(80),d=a(c),f=n(333),p=a(f);t.file={model:null,createNewFile:function(e){var t=this;return new i.default(function(n,a){u.default.dispatch("createNewFile",e).then(function(e){s.default.loadRtDoc(e,t.contentEventHandler,t.filenameEventHandler,t.collaboratorEventHandler,t.cursorsMapEventHandler.bind(t)).then(function(){n(e)}).catch(function(){a("rt file not loaded")})}).catch(function(){return a("not loaded")})})},openFromGDrive:function(){var e=this;return new i.default(function(t,n){s.default.showPicker().then(function(a){a!==(0,d.default)(u.default,"state.file.metadata.id")&&e.loadFromGDrive(a).then(function(){return t()}).then(function(e){return n(e)})}).catch(function(){n("not picked")})})},loadFromGDrive:function(e){var t=this;return new i.default(function(n,a){s.default.loadFile(e).then(function(e){return u.default.dispatch("loadFile",e),e}).then(function(e){s.default.loadRtDoc(e,t.contentEventHandler,t.filenameEventHandler,t.collaboratorEventHandler,t.cursorsMapEventHandler.bind(t)).then(function(){u.default.dispatch("updateContent",s.default.contentText.getText()),n(e)}).catch(function(e){a(e)})}).catch(function(e){console.error(e),a(e.result.error.message)})})},contentEventHandler:function(e){console.log("contentEventHandler"),u.default.dispatch("updateContent",s.default.contentText.getText())},filenameEventHandler:function(e){console.log("filenameEventHandler: "+s.default.filenameText.getText()),u.default.dispatch("updateFilename",s.default.filenameText.getText())},collaboratorEventHandler:function(e){console.log("---------------- collaboratorEventHandler: "+(e?e.type:"none")),console.log(e.target.getCollaborators().length+"COLLABORATORS"),e.target.getCollaborators().forEach(function(e){console.log("User ID:"+e.userId),console.log("Session ID:"+e.sessionId),console.log("Name:"+e.displayName),console.log("Color:"+e.color),console.log("IS_ME: "+e.isMe)}),u.default.dispatch("setCollaborators",e.target.getCollaborators())},moveCursor:function(e){this.getMyRegisteredReference(e).index=e},cursorsMapEventHandler:function(e){u.default.dispatch("setCursors",this.getCursors())},getCursors:function(){for(var e=this.garbageCollectCursors(),t=e.keys(),n={},a=0;a 2 | 3 | 4 | 5 | Collaborative Markdown Editor 6 | 7 | 8 | 9 |
10 | 11 | 12 | 13 | 15 | 16 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "collaborative-markdown-editor", 3 | "version": "1.0.0", 4 | "description": "This is a markdown editor written in VueJS that uses google drive API for storage, sharing and realtime colaboration.", 5 | "author": "Tiago Alves ", 6 | "private": true, 7 | "scripts": { 8 | "dev": "node build/dev-server.js", 9 | "build": "node build/build.js", 10 | "unit": "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/specs test/e2e/specs" 14 | }, 15 | "dependencies": { 16 | "autosize-input": "^0.2.1", 17 | "babel-runtime": "^6.0.0", 18 | "es6-promise": "^4.0.5", 19 | "lodash": "^4.13.1", 20 | "material-design-lite": "^1.2.1", 21 | "moment": "^2.17.1", 22 | "querystringify": "0.0.3", 23 | "remarkable": "^1.7.1", 24 | "textarea-caret": "^3.0.2", 25 | "vue": "^2.0.1", 26 | "vue-mdl": "next", 27 | "vuex": "^2.0.0" 28 | }, 29 | "devDependencies": { 30 | "autoprefixer": "^6.4.0", 31 | "babel-core": "^6.0.0", 32 | "babel-eslint": "^7.0.0", 33 | "babel-loader": "^6.0.0", 34 | "babel-plugin-transform-runtime": "^6.0.0", 35 | "babel-preset-es2015": "^6.0.0", 36 | "babel-preset-stage-2": "^6.0.0", 37 | "babel-register": "^6.0.0", 38 | "chai": "^3.5.0", 39 | "chalk": "^1.1.3", 40 | "chromedriver": "^2.21.2", 41 | "connect-history-api-fallback": "^1.1.0", 42 | "cross-spawn": "^4.0.2", 43 | "css-loader": "^0.25.0", 44 | "eslint": "^3.7.1", 45 | "eslint-config-standard": "^6.1.0", 46 | "eslint-friendly-formatter": "^2.0.5", 47 | "eslint-loader": "^1.5.0", 48 | "eslint-plugin-html": "^1.3.0", 49 | "eslint-plugin-promise": "^2.0.1", 50 | "eslint-plugin-standard": "^2.0.1", 51 | "eventsource-polyfill": "^0.9.6", 52 | "express": "^4.13.3", 53 | "extract-text-webpack-plugin": "^1.0.1", 54 | "file-loader": "^0.9.0", 55 | "function-bind": "^1.0.2", 56 | "html-webpack-plugin": "^2.8.1", 57 | "http-proxy-middleware": "^0.17.2", 58 | "inject-loader": "^2.0.1", 59 | "isparta-loader": "^2.0.0", 60 | "json-loader": "^0.5.4", 61 | "karma": "^1.3.0", 62 | "karma-coverage": "^1.1.1", 63 | "karma-mocha": "^1.2.0", 64 | "karma-phantomjs-launcher": "^1.0.0", 65 | "karma-sinon-chai": "^1.2.0", 66 | "karma-sourcemap-loader": "^0.3.7", 67 | "karma-spec-reporter": "0.0.26", 68 | "karma-webpack": "^1.7.0", 69 | "lolex": "^1.4.0", 70 | "mocha": "^3.1.0", 71 | "nightwatch": "^0.9.8", 72 | "node-sass": "^3.13.0", 73 | "opn": "^4.0.2", 74 | "ora": "^0.3.0", 75 | "phantomjs-prebuilt": "^2.1.3", 76 | "raw-loader": "^0.5.1", 77 | "sass-loader": "^4.0.2", 78 | "selenium-server": "2.53.1", 79 | "semver": "^5.3.0", 80 | "shelljs": "^0.7.4", 81 | "sinon": "^1.17.3", 82 | "sinon-chai": "^2.8.0", 83 | "string-replace-loader": "^1.0.5", 84 | "url-loader": "^0.5.7", 85 | "vue-loader": "^9.4.0", 86 | "vue-style-loader": "^1.0.0", 87 | "webpack": "^1.13.2", 88 | "webpack-dev-middleware": "^1.8.3", 89 | "webpack-hot-middleware": "^2.12.2", 90 | "webpack-merge": "^0.14.1" 91 | }, 92 | "engines": { 93 | "node": ">= 4.0.0", 94 | "npm": ">= 3.0.0" 95 | } 96 | } 97 | -------------------------------------------------------------------------------- /src/App.vue: -------------------------------------------------------------------------------- 1 | 33 | 34 | 120 | 121 | 131 | 132 | 133 | 134 | -------------------------------------------------------------------------------- /src/assets/3rd-party/js/client.js: -------------------------------------------------------------------------------- 1 | var gapi=window.gapi=window.gapi||{};gapi._bs=new Date().getTime();(function(){var f=window,h=document,m=f.location,n=function(){},u=/\[native code\]/,w=function(a,b,c){return a[b]=a[b]||c},A=function(a){for(var b=0;bA.call(b,e)&&c.push(e)}return c},ma=function(a){"loading"!=h.readyState?W(a):h.write("<"+U+' src="'+encodeURI(a)+'">")},W=function(a){var b=h.createElement(U);b.setAttribute("src",a);b.async="true";(a=h.getElementsByTagName(U)[0])?a.parentNode.insertBefore(b,a):(h.head||h.body||h.documentElement).appendChild(b)},na=function(a,b){var c=b&&b._c;if(c)for(var d=0;d 2 | 3 | 4 | -------------------------------------------------------------------------------- /src/assets/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tralves/collaborative-markdown-editor/b00ef1c0955f801663d7dc830eaa2b51cbf17068/src/assets/logo.png -------------------------------------------------------------------------------- /src/components/CollaboratorCursor.vue: -------------------------------------------------------------------------------- 1 | 10 | 11 | 16 | 17 | 29 | -------------------------------------------------------------------------------- /src/components/CreateNewFileDialog.vue: -------------------------------------------------------------------------------- 1 | 10 | 11 | 42 | 43 | -------------------------------------------------------------------------------- /src/components/PreviewView.vue: -------------------------------------------------------------------------------- 1 | 6 | 7 | 26 | 27 | 55 | -------------------------------------------------------------------------------- /src/components/TextView.vue: -------------------------------------------------------------------------------- 1 | 7 | 8 | 68 | 69 | 97 | -------------------------------------------------------------------------------- /src/components/menu/Collaborator.vue: -------------------------------------------------------------------------------- 1 | 9 | 10 | 15 | 16 | 25 | -------------------------------------------------------------------------------- /src/components/menu/PageHeader.vue: -------------------------------------------------------------------------------- 1 | 19 | 20 | 95 | 96 | 135 | -------------------------------------------------------------------------------- /src/components/menu/ProfileMenu.vue: -------------------------------------------------------------------------------- 1 | 14 | 15 | 26 | 27 | 37 | 38 | -------------------------------------------------------------------------------- /src/components/menu/SideMenu.vue: -------------------------------------------------------------------------------- 1 | 27 | 28 | 59 | 60 | 62 | 63 | -------------------------------------------------------------------------------- /src/gapi/gapi-integration.js: -------------------------------------------------------------------------------- 1 | import MultiPartBuilder from './multipart' 2 | 3 | class GApiIntegration { 4 | 5 | /* global gapi, google */ 6 | 7 | constructor () { 8 | this.CLIENT_ID = process.env.CLIENT_ID 9 | this.SCOPES = ['email', 'profile', 'https://www.googleapis.com/auth/drive', 10 | 'https://www.googleapis.com/auth/drive.install'] 11 | 12 | this.DEFAULT_FIELDS = 'capabilities(canCopy,canEdit),createdTime,fileExtension,id,mimeType,modifiedTime,name,shared,size,version' 13 | } 14 | 15 | loadDriveApis () { 16 | return new Promise( 17 | (resolve, reject) => { 18 | (function waitForApi () { 19 | console.info('loadDriveApi...') 20 | if (gapi && gapi.client) { 21 | Promise.all([ 22 | gapi.client.load('drive', 'v3'), 23 | gapi.load('picker'), 24 | gapi.load('drive-share'), 25 | gapi.load('drive-realtime')]) 26 | .then(() => { 27 | console.info('gapi.client.load finished!') 28 | resolve() 29 | }) 30 | } else { 31 | setTimeout(() => waitForApi(), 100) 32 | console.info('wait for it...') 33 | } 34 | })() 35 | } 36 | ) 37 | } 38 | 39 | /** 40 | * Makes the gapi authorization process 41 | */ 42 | authorize (immediate = true, user = null) { 43 | console.info('authorize') 44 | return new Promise( 45 | (resolve, reject) => { 46 | gapi.auth.authorize( 47 | this.buildAuthRequest(immediate, user), 48 | (authResult) => { 49 | console.info('RESULT!!!!!!!!') 50 | if (authResult && !authResult.error) { 51 | console.info('resolved!') 52 | resolve() 53 | } else { 54 | console.info('rejected!') 55 | reject('Sorry, you are not allowed to open the file...') 56 | } 57 | } 58 | ) 59 | } 60 | ) 61 | } 62 | 63 | /** 64 | * Builds a request object suitable for gapi.auth.authorize calls. 65 | * 66 | * @param {Boolean} immediateMode True if auth should be checked silently 67 | * @param {String} user Optional login hint indiciating which account should be authorized 68 | * @return {Promise} promise that resolves on completion of the login 69 | */ 70 | buildAuthRequest (immediateMode, user) { 71 | var request = { 72 | client_id: this.CLIENT_ID, 73 | scope: this.SCOPES.join(' '), 74 | immediate: immediateMode 75 | } 76 | if (user) { 77 | request.login_hint = user 78 | request.authuser = -1 79 | } 80 | return request 81 | } 82 | 83 | saveFile (file, filename) { 84 | var path 85 | var method 86 | 87 | if (file.metadata.id) { 88 | path = '/upload/drive/v3/files/' + encodeURIComponent(file.metadata.id) 89 | method = 'PATCH' 90 | } else { 91 | path = '/upload/drive/v3/files' 92 | method = 'POST' 93 | } 94 | 95 | const metadata = { mimeType: file.metadata.mimeType, name: (filename || file.metadata.name) } 96 | 97 | const multipart = new MultiPartBuilder() 98 | .append('application/json', JSON.stringify(metadata)) 99 | .append(file.metadata.mimeType, file.content) 100 | .finish() 101 | 102 | return gapi.client.request({ 103 | path: path, 104 | method: method, 105 | params: { 106 | uploadType: 'multipart', 107 | fields: this.DEFAULT_FIELDS 108 | }, 109 | headers: { 'Content-Type': multipart.type }, 110 | body: multipart.body 111 | }) 112 | } 113 | 114 | /** 115 | * Combines metadata & content into a single object & caches the result 116 | * 117 | * @param {Object} metadata File metadata 118 | * @param {String} content File content 119 | * @return {Object} combined object 120 | */ 121 | combineAndStoreResults (metadata, content) { 122 | var file = { 123 | metadata: metadata, 124 | content: content 125 | } 126 | return file 127 | }; 128 | 129 | /** 130 | * Load a file from Drive. Fetches both the metadata & content in parallel. 131 | * 132 | * @param {String} fileID ID of the file to load 133 | * @return {Promise} promise that resolves to an object containing the file metadata & content 134 | */ 135 | loadFile (fileId) { 136 | return new Promise( 137 | (resolve, reject) => { 138 | var metadataRequest = gapi.client.drive.files.get({ 139 | fileId: fileId, 140 | fields: this.DEFAULT_FIELDS 141 | }) 142 | var contentRequest = gapi.client.drive.files.get({ 143 | fileId: fileId, 144 | alt: 'media' 145 | }) 146 | 147 | resolve(Promise.all([metadataRequest, contentRequest])) 148 | }).then((responses) => { 149 | return {metadata: responses[0].result, content: responses[1].body} 150 | }) 151 | }; 152 | 153 | loadRtDoc (file, contentEventHandler, filenameEventHandler, collaboratorEventHandler, cursorsMapEventHandler) { 154 | var that = this 155 | return new Promise( 156 | (resolve, reject) => { 157 | gapi.drive.realtime.load(file.metadata.id, 158 | (doc) => { 159 | console.log('loaded realtime doc', doc) 160 | // Get the field named "text" in the root map. 161 | that.contentText = doc.getModel().getRoot().get('content') 162 | that.contentText.addEventListener(gapi.drive.realtime.EventType.TEXT_INSERTED, contentEventHandler) 163 | that.contentText.addEventListener(gapi.drive.realtime.EventType.TEXT_DELETED, contentEventHandler) 164 | 165 | that.filenameText = doc.getModel().getRoot().get('filename') 166 | that.filenameText.addEventListener(gapi.drive.realtime.EventType.TEXT_INSERTED, filenameEventHandler) 167 | that.filenameText.addEventListener(gapi.drive.realtime.EventType.TEXT_DELETED, filenameEventHandler) 168 | 169 | // collaborators 170 | doc.addEventListener(gapi.drive.realtime.EventType.COLLABORATOR_JOINED, collaboratorEventHandler) 171 | doc.addEventListener(gapi.drive.realtime.EventType.COLLABORATOR_LEFT, collaboratorEventHandler) 172 | collaboratorEventHandler({target: doc, type: 'init_collaborators'}) 173 | 174 | // cursors map 175 | that.cursorsMap = doc.getModel().getRoot().get('cursors') 176 | that.cursorsMap.addEventListener(gapi.drive.realtime.EventType.VALUE_CHANGED, cursorsMapEventHandler) 177 | 178 | resolve(doc.getModel()) 179 | }, 180 | (model) => { 181 | console.log('initializing model', model) 182 | 183 | var contentString = model.createString(file.content) 184 | model.getRoot().set('content', contentString) 185 | 186 | var filenameString = model.createString(file.content) 187 | model.getRoot().set('filename', filenameString) 188 | 189 | that.cursorsMap = model.createMap() 190 | model.getRoot().set('cursors', that.cursorsMap) 191 | }, 192 | (error) => { 193 | console.log('failed realtime load', error) 194 | if (error.type === window.gapi.drive.realtime.ErrorType.TOKEN_REFRESH_REQUIRED) { 195 | this.authorize(true) 196 | .then(() => { 197 | this.loadRtDoc(file, contentEventHandler, filenameEventHandler, collaboratorEventHandler, cursorsMapEventHandler) 198 | }) 199 | .catch(() => { 200 | reject('Could not authorize') 201 | }) 202 | } else if (error.type === window.gapi.drive.realtime.ErrorType.CLIENT_ERROR) { 203 | reject('An Error happened: ' + error.message) 204 | } else if (error.type === window.gapi.drive.realtime.ErrorType.NOT_FOUND) { 205 | reject('The file does not exist or you do not have permissions to access it.') 206 | } else if (error.type === window.gapi.drive.realtime.ErrorType.FORBIDDEN) { 207 | reject('You do not have access to this file. Try having the owner share it with you from Google Drive.') 208 | window.location.href = '/' 209 | } 210 | }) 211 | }) 212 | } 213 | 214 | /** 215 | * Displays the Drive file picker configured for selecting text files 216 | * 217 | * @return {Promise} Promise that resolves with the ID of the selected file 218 | */ 219 | showPicker () { 220 | return new Promise( 221 | (resolve, reject) => { 222 | var view = new google.picker.DocsView(google.picker.ViewId.DOCS) 223 | view.setMimeTypes(process.env.DEFAULT_MIMETYPE) 224 | view.setSelectFolderEnabled(true) 225 | view.setIncludeFolders(true) 226 | var picker = new google.picker.PickerBuilder() 227 | .setAppId(process.env.APPLICATION_ID) 228 | .setOAuthToken(gapi.auth.getToken().access_token) 229 | .addView(view) 230 | .setCallback(function (data) { 231 | if (data.action === 'picked') { 232 | var id = data.docs[0].id 233 | resolve(id) 234 | } else if (data.action === 'cancel') { 235 | reject('cancel') 236 | } 237 | }) 238 | .build() 239 | picker.setVisible(true) 240 | }) 241 | }; 242 | 243 | /** 244 | * Displays the Drive sharing dialog 245 | * 246 | * @param {String} id ID of the file to share 247 | */ 248 | showSharing (id) { 249 | var share = new gapi.drive.share.ShareClient(process.env.APPLICATION_ID) 250 | share.setOAuthToken(gapi.auth.getToken().access_token) 251 | share.setItemIds([id]) 252 | share.showSettingsDialog() 253 | }; 254 | } 255 | 256 | export default new GApiIntegration() 257 | -------------------------------------------------------------------------------- /src/gapi/multipart.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Helper for building multipart requests for uploading to Drive. 3 | */ 4 | export default class { 5 | 6 | constructor () { 7 | this.boundary = Math.random().toString(36).slice(2) 8 | this.mimeType = 'multipart/mixed; boundary="' + this.boundary + '"' 9 | this.parts = [] 10 | this.body = null 11 | } 12 | 13 | /** 14 | * Appends a part. 15 | * 16 | * @param {String} mimeType Content type of this part 17 | * @param {Blob|File|String} content Body of this part 18 | */ 19 | append (mimeType, content) { 20 | if (this.body !== null) { 21 | throw new Error('Builder has already been finalized.') 22 | } 23 | this.parts.push( 24 | '\r\n--', this.boundary, '\r\n', 25 | 'Content-Type: ', mimeType, '\r\n\r\n', 26 | content) 27 | return this 28 | } 29 | 30 | /** 31 | * Finalizes building of the multipart request and returns a Blob containing 32 | * the request. Once finalized, appending additional parts will result in an 33 | * error. 34 | * 35 | * @returns {Object} Object containing the mime type (mimeType) & assembled multipart body (body) 36 | */ 37 | finish () { 38 | if (this.parts.length === 0) { 39 | throw new Error('No parts have been added.') 40 | } 41 | if (this.body === null) { 42 | this.parts.push('\r\n--', this.boundary, '--') 43 | this.body = this.parts.join('') 44 | // TODO - switch to blob once gapi.client.request allows it 45 | // this.body = new Blob(this.parts, {type: this.mimeType}); 46 | } 47 | return { 48 | type: this.mimeType, 49 | body: this.body 50 | } 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /src/main.js: -------------------------------------------------------------------------------- 1 | import Vue from 'vue' 2 | import App from './App' 3 | import VueMdl from 'vue-mdl' 4 | 5 | import store from './store' 6 | 7 | // import mdl stuff 8 | import 'material-design-lite' 9 | import 'material-design-lite/material.min.css' 10 | import 'assets/material.cyan-amber.min.css' 11 | 12 | require('es6-promise').polyfill() 13 | 14 | Vue.use(VueMdl) 15 | 16 | /* eslint-disable no-new */ 17 | 18 | new Vue({ 19 | store, 20 | el: '#app', 21 | template: '', 22 | components: { App } 23 | }) 24 | -------------------------------------------------------------------------------- /src/services/file.js: -------------------------------------------------------------------------------- 1 | import GapiIntegration from 'src/gapi/gapi-integration' 2 | import store from 'src/store' 3 | import _get from 'lodash/get' 4 | import _find from 'lodash/find' 5 | 6 | /* global gapi */ 7 | 8 | export const file = { 9 | model: null, 10 | 11 | /** 12 | * Creates a new file 13 | * @param {string} filename 14 | * @return {Promise} 15 | */ 16 | createNewFile (filename) { 17 | return new Promise( 18 | (resolve, reject) => { 19 | store.dispatch('createNewFile', filename) 20 | .then((file) => { 21 | GapiIntegration.loadRtDoc(file, 22 | this.contentEventHandler, 23 | this.filenameEventHandler, 24 | this.collaboratorEventHandler, 25 | this.cursorsMapEventHandler.bind(this)) 26 | .then(() => { 27 | resolve(file) 28 | }) 29 | .catch(() => { 30 | reject('rt file not loaded') 31 | }) 32 | }) 33 | .catch(() => reject('not loaded')) 34 | }) 35 | }, 36 | 37 | /** 38 | * Opens a file from GDrive 39 | * @return {Promise} 40 | */ 41 | openFromGDrive () { 42 | return new Promise( 43 | (resolve, reject) => { 44 | GapiIntegration.showPicker() 45 | .then((id) => { 46 | if (id !== _get(store, 'state.file.metadata.id')) { 47 | this.loadFromGDrive(id) 48 | .then(() => resolve()) 49 | .then((error) => reject(error)) 50 | } 51 | }) 52 | .catch(() => { 53 | reject('not picked') 54 | }) 55 | }) 56 | }, 57 | 58 | /** 59 | * Loads a file from gdrive 60 | * @param {string} id GDrive file id. 61 | * @return {Promise} 62 | */ 63 | loadFromGDrive (id) { 64 | return new Promise( 65 | (resolve, reject) => { 66 | GapiIntegration.loadFile(id) 67 | .then(file => { 68 | store.dispatch('loadFile', file) 69 | return file 70 | }) 71 | .then((file) => { 72 | GapiIntegration.loadRtDoc(file, 73 | this.contentEventHandler, 74 | this.filenameEventHandler, 75 | this.collaboratorEventHandler, 76 | this.cursorsMapEventHandler.bind(this)) 77 | .then(() => { 78 | store.dispatch('updateContent', GapiIntegration.contentText.getText()) 79 | resolve(file) 80 | }) 81 | .catch((error) => { 82 | reject(error) 83 | }) 84 | }) 85 | .catch((error) => { 86 | console.error(error) 87 | reject(error.result.error.message) 88 | }) 89 | }) 90 | }, 91 | 92 | contentEventHandler (evt) { 93 | // Log the event to the console. 94 | console.log('contentEventHandler') 95 | store.dispatch('updateContent', GapiIntegration.contentText.getText()) 96 | }, 97 | 98 | filenameEventHandler (evt) { 99 | // Log the event to the console. 100 | // console.log(evt) 101 | console.log('filenameEventHandler: ' + GapiIntegration.filenameText.getText()) 102 | store.dispatch('updateFilename', GapiIntegration.filenameText.getText()) 103 | }, 104 | 105 | collaboratorEventHandler (evt) { 106 | // Log the event to the console. 107 | console.log('---------------- collaboratorEventHandler: ' + (evt ? evt.type : 'none')) 108 | console.log(evt.target.getCollaborators().length + 'COLLABORATORS') 109 | evt.target.getCollaborators().forEach((collaborator) => { 110 | console.log('User ID:' + collaborator.userId) 111 | console.log('Session ID:' + collaborator.sessionId) 112 | console.log('Name:' + collaborator.displayName) 113 | console.log('Color:' + collaborator.color) 114 | console.log('IS_ME: ' + collaborator.isMe) 115 | }) 116 | 117 | store.dispatch('setCollaborators', evt.target.getCollaborators()) 118 | }, 119 | 120 | moveCursor (pos) { 121 | this.getMyRegisteredReference(pos).index = pos 122 | }, 123 | 124 | cursorsMapEventHandler (evt) { 125 | store.dispatch('setCursors', this.getCursors()) 126 | }, 127 | 128 | getCursors () { 129 | const cursorsMap = this.garbageCollectCursors() 130 | const keys = cursorsMap.keys() 131 | let cursors = {} 132 | for (let i = 0; i < keys.length; i++) { 133 | cursors[keys[i]] = cursorsMap.get(keys[i]).index 134 | } 135 | 136 | return cursors 137 | }, 138 | 139 | garbageCollectCursors: function () { 140 | const cursorsMap = GapiIntegration.cursorsMap 141 | const keys = cursorsMap.keys() 142 | for (let i = 0; i < keys.length; i++) { 143 | // 144 | if (!this.getCollaborator(keys[i])) { 145 | cursorsMap.delete(keys[i]) 146 | } else { 147 | cursorsMap.get(keys[i]).removeAllEventListeners() 148 | cursorsMap.get(keys[i]).addEventListener( 149 | gapi.drive.realtime.EventType.REFERENCE_SHIFTED, 150 | this.onReferenceShifted.bind(this)) 151 | } 152 | } 153 | 154 | return cursorsMap 155 | }, 156 | 157 | getCollaborator: function (sessionId) { 158 | const collaborators = _get(store, 'state.collaborators.users') 159 | if (!collaborators) { 160 | return null 161 | } 162 | return _find(collaborators, {'sessionId': sessionId}) 163 | }, 164 | 165 | getMyCollaborator: function () { 166 | const collaborators = _get(store, 'state.collaborators.users') 167 | if (!collaborators) { 168 | return null 169 | } 170 | return _find(collaborators, {'isMe': true}) 171 | }, 172 | 173 | getMyRegisteredReference: function (pos) { 174 | const cursorsMap = GapiIntegration.cursorsMap 175 | if (!cursorsMap) { 176 | return null 177 | } 178 | 179 | // if there is no registered reference, create it 180 | const myRegisteredReference = cursorsMap.get(this.getMyCollaborator().sessionId) 181 | if (myRegisteredReference) { 182 | return myRegisteredReference 183 | } 184 | let myNewRegisteredReference = GapiIntegration.contentText.registerReference(pos, true) 185 | myNewRegisteredReference.addEventListener(gapi.drive.realtime.EventType.REFERENCE_SHIFTED, this.onReferenceShifted.bind(this)) 186 | GapiIntegration.cursorsMap.set(this.getMyCollaborator().sessionId, myNewRegisteredReference) 187 | return myNewRegisteredReference 188 | }, 189 | 190 | onReferenceShifted: function () { 191 | store.dispatch('setCursors', this.getCursors()) 192 | }, 193 | 194 | /** 195 | * Opens GDrive share screen 196 | * @param {string} id GDrive file id. 197 | */ 198 | share () { 199 | return GapiIntegration.showSharing(_get(store, 'state.file.metadata.id')) 200 | } 201 | } 202 | -------------------------------------------------------------------------------- /src/services/index.js: -------------------------------------------------------------------------------- 1 | export { file } from './file' 2 | -------------------------------------------------------------------------------- /src/store/actions.js: -------------------------------------------------------------------------------- 1 | import GapiIntegration from '../gapi/gapi-integration' 2 | import * as types from './mutation-types' 3 | import _ from 'lodash' 4 | import qs from 'querystringify' 5 | 6 | export const createNewFile = ({commit, state}, filename) => { 7 | console.log('Creating new file') 8 | commit(types.NEW_FILE, filename) 9 | 10 | return new Promise((resolve, reject) => { 11 | GapiIntegration.saveFile(state.file, filename) 12 | .then( 13 | (result) => commit(types.FILE_SAVED, result.result), 14 | (reason) => commit(types.FILE_NOT_SAVED)) 15 | .then(() => { updateWindowUrl(state.file.metadata) }) 16 | .then(() => { resolve(state.file) }) 17 | }) 18 | } 19 | 20 | export const saveFile = ({commit, state}) => { 21 | console.log('Saving new file') 22 | commit(types.FILE_SAVING) 23 | 24 | return new Promise((resolve, reject) => { 25 | GapiIntegration.saveFile(state.file) 26 | .then( 27 | (result) => { 28 | commit(types.FILE_SAVED, result.result) 29 | resolve() 30 | }, 31 | (reason) => { 32 | commit(types.FILE_NOT_SAVED) 33 | reject(reason) 34 | } 35 | ) 36 | }) 37 | } 38 | 39 | let debounceSave = _.debounce(saveFile, 2000) 40 | 41 | export const editContent = ({commit, state}, value) => { 42 | // console.log('Editing file content: ' + value) 43 | // commit(types.EDIT_CONTENT, value) 44 | GapiIntegration.contentText.setText(value) 45 | // save file / sync with google realtime api 46 | debounceSave({commit, state}) 47 | commit(types.FILE_DIRTY) 48 | } 49 | 50 | export const updateContent = ({commit, state}, text) => { 51 | // console.log('Updating realtime file content: ', text) 52 | commit(types.EDIT_CONTENT, text) 53 | // if (type === 'text_inserted') { 54 | // commit(types.INSERT_CONTENT, { index, text }) 55 | // } else if (type === 'text_deleted') { 56 | // commit(types.DELETE_CONTENT, { index, text }) 57 | // } 58 | } 59 | 60 | export const renameFile = ({commit, state}, filename) => { 61 | console.log('renaming file') 62 | GapiIntegration.filenameText.setText(filename) 63 | 64 | // save file / sync with google realtime api 65 | debounceSave({commit, state}) 66 | commit(types.FILE_DIRTY) 67 | } 68 | 69 | export const updateFilename = ({commit, state}, filename) => { 70 | commit(types.RENAME_FILE, filename) 71 | } 72 | 73 | export const loadFile = ({commit, state}, file) => { 74 | commit(types.LOAD_FILE, file) 75 | updateWindowUrl(state.file.metadata) 76 | } 77 | 78 | export const setCollaborators = ({commit, state}, collaborators) => { 79 | commit(types.SET_COLLABORATORS, collaborators) 80 | } 81 | 82 | export const setCursors = ({commit, state}, cursors) => { 83 | commit(types.SET_CURSORS, cursors) 84 | } 85 | 86 | function updateWindowUrl (fileMetadata) { 87 | let queryVars = qs.parse(window.location.search) 88 | queryVars.file = fileMetadata.id 89 | window.history.pushState({}, fileMetadata.name, (window.location.pathname || '') + qs.stringify(queryVars, true)) 90 | } 91 | -------------------------------------------------------------------------------- /src/store/index.js: -------------------------------------------------------------------------------- 1 | // vuex/store.js 2 | import Vue from 'vue' 3 | import Vuex from 'vuex' 4 | import file from './modules/file' 5 | import collaborators from './modules/collaborators' 6 | import * as actions from './actions' 7 | 8 | Vue.use(Vuex) 9 | console.log('Using Vuex') 10 | 11 | const debug = process.env.NODE_ENV !== 'production' 12 | 13 | export default new Vuex.Store({ 14 | actions, 15 | // combine sub modules 16 | modules: { 17 | file, 18 | collaborators 19 | }, 20 | strict: debug 21 | }) 22 | -------------------------------------------------------------------------------- /src/store/modules/collaborators.js: -------------------------------------------------------------------------------- 1 | import { 2 | SET_COLLABORATORS, 3 | SET_CURSORS 4 | } from '../mutation-types' 5 | 6 | const state = { 7 | users: [] 8 | } 9 | 10 | const mutations = { 11 | [SET_COLLABORATORS] (state, collaborators) { 12 | state.users = collaborators 13 | }, 14 | 15 | [SET_CURSORS] (state, cursors) { 16 | state.users = state.users.reduce((users, user) => { 17 | if (cursors[user.sessionId]) { 18 | user = { ...user, cursor: cursors[user.sessionId] } 19 | } 20 | 21 | users.push(user) 22 | return users 23 | }, []) 24 | } 25 | } 26 | 27 | export default { 28 | state, 29 | mutations 30 | } 31 | -------------------------------------------------------------------------------- /src/store/modules/file.js: -------------------------------------------------------------------------------- 1 | import _ from 'lodash' 2 | import { 3 | NEW_FILE, 4 | FILE_SAVED, 5 | FILE_SAVING, 6 | FILE_NOT_SAVED, 7 | FILE_DIRTY, 8 | EDIT_CONTENT, 9 | INSERT_CONTENT, 10 | DELETE_CONTENT, 11 | RENAME_FILE, 12 | LOAD_FILE 13 | } from '../mutation-types' 14 | 15 | export const STATUS_LIST = { 16 | INITIAL: 'INTIAL', 17 | SAVING: 'SAVING', 18 | SAVED: 'SAVED', 19 | NOT_SAVED: 'NOT_SAVED', 20 | DIRTY: 'DIRTY' 21 | } 22 | 23 | const state = { 24 | metadata: { 25 | name: 'New document', 26 | id: 'no-id' 27 | }, 28 | content: '', 29 | status: STATUS_LIST.INITIAL 30 | } 31 | 32 | const mutations = { 33 | [NEW_FILE] (state, name) { 34 | state.metadata = { 35 | id: null, 36 | mimeType: process.env.DEFAULT_MIMETYPE, 37 | name: name 38 | } 39 | state.content = '' 40 | }, 41 | 42 | [FILE_SAVED] (state, metadata) { 43 | _.assign(state.metadata, metadata) 44 | state.status = STATUS_LIST.SAVED 45 | }, 46 | 47 | [FILE_NOT_SAVED] (state) { 48 | state.status = STATUS_LIST.NOT_SAVED 49 | }, 50 | 51 | [FILE_SAVING] (state) { 52 | state.status = STATUS_LIST.SAVING 53 | }, 54 | 55 | [FILE_DIRTY] (state) { 56 | state.status = STATUS_LIST.DIRTY 57 | }, 58 | 59 | [EDIT_CONTENT] (state, content) { 60 | state.content = content 61 | }, 62 | 63 | [INSERT_CONTENT] (state, {index, text}) { 64 | const content = state.content 65 | state.content = content.slice(0, index) + text + content.slice(index) 66 | }, 67 | 68 | [DELETE_CONTENT] (state, { index, text }) { 69 | const content = state.content 70 | state.content = content.slice(0, index) + content.slice(index + text.length) 71 | }, 72 | 73 | [RENAME_FILE] (state, filename) { 74 | state.metadata.name = filename 75 | }, 76 | 77 | [LOAD_FILE] (state, file) { 78 | state.metadata = file.metadata 79 | state.content = file.content 80 | state.status = state.status = STATUS_LIST.SAVED 81 | } 82 | } 83 | 84 | export default { 85 | state, 86 | mutations 87 | } 88 | -------------------------------------------------------------------------------- /src/store/mutation-types.js: -------------------------------------------------------------------------------- 1 | // file mutations 2 | export const NEW_FILE = 'NEW_FILE' 3 | export const FILE_SAVED = 'FILE_SAVED' 4 | export const FILE_SAVING = 'FILE_SAVING' 5 | export const FILE_NOT_SAVED = 'FILE_NOT_SAVED' 6 | 7 | export const EDIT_CONTENT = 'EDIT_CONTENT' 8 | export const INSERT_CONTENT = 'INSERT_CONTENT' 9 | export const DELETE_CONTENT = 'DELETE_CONTENT' 10 | export const RENAME_FILE = 'RENAME_FILE' 11 | export const LOAD_FILE = 'LOAD_FILE' 12 | 13 | // collaborator mutations 14 | export const SET_COLLABORATORS = 'SET_COLLABORATORS' 15 | export const SET_CURSORS = 'SET_CURSORS' 16 | -------------------------------------------------------------------------------- /src/stores/user.js: -------------------------------------------------------------------------------- 1 | export default { 2 | state: { 3 | name: '', 4 | image: '', 5 | email: '' 6 | }, 7 | 8 | setUser: function (user) { 9 | this.state.name = user.name 10 | this.state.image = user.image 11 | this.state.email = user.email 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /src/styles/_globals.scss: -------------------------------------------------------------------------------- 1 | $primary-color: rgb(0,188,212); 2 | $secondary-color: rgb(255,215,64); 3 | 4 | .mdl-layout__drawer-button { 5 | color: $primary-color !important; 6 | } 7 | 8 | .mdl-dialog-container { 9 | z-index: 1000 !important; 10 | } 11 | 12 | // fixes bug in chrome (see https://github.com/google/material-design-lite/issues/4574) 13 | .mdl-tooltip { 14 | will-change: unset !important; 15 | } 16 | -------------------------------------------------------------------------------- /static/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tralves/collaborative-markdown-editor/b00ef1c0955f801663d7dc830eaa2b51cbf17068/static/.gitkeep -------------------------------------------------------------------------------- /test/e2e/custom-assertions/elementCount.js: -------------------------------------------------------------------------------- 1 | // A custom Nightwatch assertion. 2 | // the name of the method is the filename. 3 | // can be used in tests like this: 4 | // 5 | // browser.assert.elementCount(selector, count) 6 | // 7 | // for how to write custom assertions see 8 | // http://nightwatchjs.org/guide#writing-custom-assertions 9 | exports.assertion = function (selector, count) { 10 | this.message = 'Testing if element <' + selector + '> has count: ' + count 11 | this.expected = count 12 | this.pass = function (val) { 13 | return val === this.expected 14 | } 15 | this.value = function (res) { 16 | return res.value 17 | } 18 | this.command = function (cb) { 19 | var self = this 20 | return this.api.execute(function (selector) { 21 | return document.querySelectorAll(selector).length 22 | }, [selector], function (res) { 23 | cb.call(self, res) 24 | }) 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /test/e2e/nightwatch.conf.js: -------------------------------------------------------------------------------- 1 | // http://nightwatchjs.org/guide#settings-file 2 | module.exports = { 3 | "src_folders": ["test/e2e/specs"], 4 | "output_folder": "test/e2e/reports", 5 | "custom_assertions_path": ["test/e2e/custom-assertions"], 6 | 7 | "selenium": { 8 | "start_process": true, 9 | "server_path": "node_modules/selenium-server/lib/runner/selenium-server-standalone-2.53.0.jar", 10 | "host": "127.0.0.1", 11 | "port": 4444, 12 | "cli_args": { 13 | "webdriver.chrome.driver": require('chromedriver').path 14 | } 15 | }, 16 | 17 | "test_settings": { 18 | "default": { 19 | "selenium_port": 4444, 20 | "selenium_host": "localhost", 21 | "silent": true 22 | }, 23 | 24 | "chrome": { 25 | "desiredCapabilities": { 26 | "browserName": "chrome", 27 | "javascriptEnabled": true, 28 | "acceptSslCerts": true 29 | } 30 | }, 31 | 32 | "firefox": { 33 | "desiredCapabilities": { 34 | "browserName": "firefox", 35 | "javascriptEnabled": true, 36 | "acceptSslCerts": true 37 | } 38 | } 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /test/e2e/runner.js: -------------------------------------------------------------------------------- 1 | // 1. start the dev server using production config 2 | process.env.NODE_ENV = 'testing' 3 | var server = require('../../build/dev-server.js') 4 | 5 | // 2. run the nightwatch test suite against it 6 | // to run in additional browsers: 7 | // 1. add an entry in test/e2e/nightwatch.conf.json under "test_settings" 8 | // 2. add it to the --env flag below 9 | // or override the environment flag, for example: `npm run e2e -- --env chrome,firefox` 10 | // For more information on Nightwatch's config file, see 11 | // http://nightwatchjs.org/guide#settings-file 12 | var opts = process.argv.slice(2) 13 | if (opts.indexOf('--config') === -1) { 14 | opts = opts.concat(['--config', 'test/e2e/nightwatch.conf.js']) 15 | } 16 | if (opts.indexOf('--env') === -1) { 17 | opts = opts.concat(['--env', 'chrome']) 18 | } 19 | 20 | var spawn = require('cross-spawn') 21 | var runner = spawn('./node_modules/.bin/nightwatch', opts, { stdio: 'inherit' }) 22 | 23 | runner.on('exit', function (code) { 24 | server.close() 25 | process.exit(code) 26 | }) 27 | 28 | runner.on('error', function (err) { 29 | server.close() 30 | throw err 31 | }) 32 | -------------------------------------------------------------------------------- /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 | browser 7 | .url('http://localhost:8080') 8 | .waitForElementVisible('#app', 5000) 9 | .assert.elementPresent('.logo') 10 | .assert.containsText('h1', 'Hello World!') 11 | .assert.elementCount('p', 3) 12 | .end() 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /test/unit/.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "env": { 3 | "mocha": true 4 | }, 5 | "globals": { 6 | "expect": true, 7 | "sinon": true, 8 | "assert": true 9 | }, 10 | "rules": { 11 | "no-native-reassign": ["error", {"exceptions": ["Promise"]}], 12 | "no-global-assign": ["error", {"exceptions": ["Promise"]}] 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /test/unit/index.js: -------------------------------------------------------------------------------- 1 | // Polyfill fn.bind() for PhantomJS 2 | /* eslint-disable no-extend-native */ 3 | Function.prototype.bind = require('function-bind') 4 | window.Promise = require('es6-promise').Promise 5 | 6 | // require all test files (files that ends with .spec.js) 7 | var testsContext = require.context('./specs', true, /\.spec$/) 8 | testsContext.keys().forEach(testsContext) 9 | 10 | // require all src files except main.js for coverage. 11 | // you can also change this to match only the subset of files that 12 | // you want coverage for. 13 | var srcContext = require.context('../../src', true, /^\.\/(?!main(\.js)?$)/) 14 | srcContext.keys().forEach(srcContext) 15 | -------------------------------------------------------------------------------- /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 path = require('path') 7 | var merge = require('webpack-merge') 8 | var baseConfig = require('../../build/webpack.base.conf') 9 | var utils = require('../../build/utils') 10 | var webpack = require('webpack') 11 | var projectRoot = path.resolve(__dirname, '../../') 12 | 13 | var webpackConfig = merge(baseConfig, { 14 | // use inline sourcemap for karma-sourcemap-loader 15 | module: { 16 | loaders: utils.styleLoaders() 17 | }, 18 | devtool: '#inline-source-map', 19 | vue: { 20 | loaders: { 21 | js: 'isparta' 22 | } 23 | }, 24 | plugins: [ 25 | new webpack.DefinePlugin({ 26 | 'process.env': require('../../config/test.env') 27 | }) 28 | ] 29 | }) 30 | 31 | // no need for app entry during tests 32 | delete webpackConfig.entry 33 | 34 | // make sure isparta loader is applied before eslint 35 | webpackConfig.module.preLoaders = webpackConfig.module.preLoaders || [] 36 | webpackConfig.module.preLoaders.unshift({ 37 | test: /\.js$/, 38 | loader: 'isparta', 39 | include: path.resolve(projectRoot, 'src') 40 | }) 41 | 42 | // only apply babel for test files when using isparta 43 | webpackConfig.module.loaders.some(function (loader, i) { 44 | if (loader.loader === 'babel') { 45 | loader.include = path.resolve(projectRoot, 'test/unit') 46 | return true 47 | } 48 | }) 49 | 50 | module.exports = function (config) { 51 | config.set({ 52 | // to run in additional browsers: 53 | // 1. install corresponding karma launcher 54 | // http://karma-runner.github.io/0.13/config/browsers.html 55 | // 2. add it to the `browsers` array below. 56 | browsers: ['PhantomJS'], 57 | frameworks: ['mocha', 'sinon-chai'], 58 | reporters: ['spec', 'coverage'], 59 | files: ['./index.js'], 60 | preprocessors: { 61 | './index.js': ['webpack', 'sourcemap'] 62 | }, 63 | webpack: webpackConfig, 64 | webpackMiddleware: { 65 | noInfo: true 66 | }, 67 | coverageReporter: { 68 | dir: './coverage', 69 | reporters: [ 70 | { type: 'lcov', subdir: '.' }, 71 | { type: 'text-summary' } 72 | ] 73 | } 74 | }) 75 | } 76 | -------------------------------------------------------------------------------- /test/unit/specs/components/CreateNewFileDialog.spec.js: -------------------------------------------------------------------------------- 1 | import Vue from 'vue' 2 | import VueMdl from 'vue-mdl' 3 | import 'material-design-lite' 4 | 5 | Vue.use(VueMdl) 6 | 7 | // create ActionsStub 8 | var CreateNewFileDialogInjector = require('!!vue?inject!src/components/CreateNewFileDialog') 9 | var createNewFileStub = sinon.stub() 10 | var openFromGDriveStub = sinon.stub().returns(Promise.resolve()) 11 | var CreateNewFileDialog = CreateNewFileDialogInjector({ 12 | 'src/services': { 13 | file: { 14 | openFromGDrive: openFromGDriveStub, 15 | createNewFile: createNewFileStub 16 | } 17 | } 18 | }) 19 | 20 | describe('CreateNewFileDialog.vue', () => { 21 | it('should create new file', done => { 22 | // arrange 23 | const vm = new Vue({ 24 | template: '
', 25 | components: { CreateNewFileDialog } 26 | }).$mount() 27 | 28 | // act 29 | // change filename 30 | vm.$el.querySelector('#create-filename-input').value = 'my new file name' 31 | // click button 32 | vm.$el.querySelector('#create-new-file-button').click() 33 | 34 | // assert 35 | // action createNewFile is called eith the new name 36 | expect(createNewFileStub) 37 | .calledWith('my new file name') 38 | .calledOnce 39 | 40 | // // popup is closed 41 | assert.equal('none', vm.$el.querySelector('.mdl-dialog-container').style.display, 'popup is hidden') 42 | 43 | done() 44 | }) 45 | 46 | it('should open gdrive file picker', done => { 47 | // arrange 48 | const vm = new Vue({ 49 | template: '
', 50 | components: { CreateNewFileDialog } 51 | }).$mount() 52 | 53 | // act 54 | // click open file button 55 | vm.$el.querySelector('#open-from-gdrive-button').click() 56 | 57 | // assert 58 | // action gdrive picker is called 59 | expect(openFromGDriveStub).calledOnce 60 | 61 | // popup is closed 62 | assert.equal('none', vm.$el.querySelector('.mdl-dialog-container').style.display, 'popup is hidden') 63 | 64 | done() 65 | }) 66 | }) 67 | -------------------------------------------------------------------------------- /test/unit/specs/components/TextView.spec.js: -------------------------------------------------------------------------------- 1 | import Vue from 'vue' 2 | import Vuex from 'vuex' 3 | import TextView from 'src/components/TextView' 4 | 5 | Vue.use(Vuex) 6 | 7 | const mockedFileStore = { 8 | state: { 9 | file: { 10 | content: 'file contents' 11 | } 12 | } 13 | } 14 | 15 | var mockedStore = new Vuex.Store(mockedFileStore) 16 | 17 | describe('TextView.vue', () => { 18 | it('should render contents from store', done => { 19 | const vm = new Vue({ 20 | template: '
', 21 | components: { TextView }, 22 | store: mockedStore 23 | }).$mount() 24 | Vue.nextTick(() => { 25 | expect(vm.$el.querySelector('textarea').value).to.contain('file contents') 26 | done() 27 | }) 28 | }) 29 | 30 | it('sends content to store on edit', done => { 31 | // create ActionsStub 32 | var TextViewInjector = require('!!vue?inject!src/components/TextView') 33 | var editContentStub = sinon.stub() 34 | 35 | var TextView = TextViewInjector({ 36 | 'vuex': { 37 | mapActions: function () { return {editContent: editContentStub} }, 38 | mapState: sinon.stub() 39 | } 40 | }) 41 | 42 | // arrange 43 | const vm = new Vue({ 44 | template: '
', 45 | components: { TextView }, 46 | store: mockedStore 47 | }).$mount() 48 | 49 | /* global Event */ 50 | Vue.nextTick(() => { 51 | // act 52 | // edit content 53 | vm.$el.querySelector('textarea').value = 'more file contents' 54 | vm.$el.querySelector('textarea').dispatchEvent(new Event('input')) 55 | // assert 56 | // vuex that action is called 57 | expect(editContentStub) 58 | .calledWith('more file contents') 59 | .calledOnce 60 | done() 61 | }) 62 | }) 63 | }) 64 | -------------------------------------------------------------------------------- /test/unit/specs/components/menu/SideMenu.spec.js: -------------------------------------------------------------------------------- 1 | import Vue from 'vue' 2 | import VueMdl from 'vue-mdl' 3 | import 'material-design-lite' 4 | 5 | Vue.use(VueMdl) 6 | 7 | // create ActionsStub 8 | var SideMenuInjector = require('!!vue?inject!src/components/menu/SideMenu') 9 | var openFromGDriveStub = sinon.stub() 10 | var shareStub = sinon.stub() 11 | var SideMenu = SideMenuInjector({ 12 | 'src/services': { 13 | file: { 14 | openFromGDrive: openFromGDriveStub, 15 | share: shareStub 16 | } 17 | } 18 | }) 19 | 20 | const vm = new Vue({ 21 | template: '
', 22 | components: { SideMenu } 23 | }).$mount() 24 | 25 | describe('SideMenu.vue', () => { 26 | it('should open new file in new tab', done => { 27 | // arrange 28 | var windowOpenStub = window.open = sinon.stub() 29 | 30 | // act 31 | // click 'new file' button 32 | vm.$el.querySelector('#new-file-button').click() 33 | 34 | // assert 35 | // open new instance in new tab 36 | expect(windowOpenStub).calledWith('/', '_blank').calledOnce 37 | 38 | done() 39 | }) 40 | 41 | it('should open file picker', done => { 42 | // arrange 43 | 44 | // act 45 | // click 'open file' button 46 | vm.$el.querySelector('#open-file-button').click() 47 | 48 | // assert 49 | // open file called 50 | expect(openFromGDriveStub).calledOnce 51 | 52 | done() 53 | }) 54 | 55 | it('should open share', done => { 56 | // arrange 57 | 58 | // act 59 | // click 'open file' button 60 | vm.$el.querySelector('#share-button').click() 61 | 62 | // assert 63 | // open share menu 64 | expect(shareStub).calledOnce 65 | 66 | done() 67 | }) 68 | }) 69 | -------------------------------------------------------------------------------- /test/unit/specs/services/file.spec.js: -------------------------------------------------------------------------------- 1 | describe('file', () => { 2 | const fileInjector = require('inject!src/services/file') 3 | 4 | describe('#openFromGDrive()', () => { 5 | it('opens file picker and loads file', done => { 6 | // arrange 7 | var showPickerStub = sinon.stub().returns(Promise.resolve('fakefileid12345')) 8 | var loadFileStub = sinon.stub().returns(Promise.resolve({id: 'fakefileid12345'})) 9 | var loadRtDocStub = sinon.stub().returns(Promise.resolve()) 10 | var dispatchStub = sinon.stub() 11 | var file = fileInjector({ 12 | 'src/gapi/gapi-integration': { 13 | 'showPicker': showPickerStub, 14 | 'loadFile': loadFileStub, 15 | 'loadRtDoc': loadRtDocStub, 16 | 'contentText': { getText: sinon.stub() } 17 | }, 18 | 'src/store': { 19 | 'dispatch': dispatchStub 20 | } 21 | }).file 22 | // act 23 | // openFromGDrive() is called 24 | file.openFromGDrive() 25 | .then(() => { 26 | // assert 27 | // calls GAPI function to open Picker 28 | expect(showPickerStub).calledOnce 29 | 30 | // calls GAPI function to load file 31 | expect(loadFileStub).calledWith('fakefileid12345').calledOnce 32 | // loads file 33 | expect(dispatchStub).calledWith('loadFile', {id: 'fakefileid12345'}) 34 | // updates content file 35 | expect(dispatchStub).calledWith('updateContent', sinon.match.any) 36 | // loads RT doc 37 | expect(loadRtDocStub).calledWith({id: 'fakefileid12345'}, sinon.match.any) 38 | done() 39 | }, 40 | (error) => { 41 | assert.fail(error) 42 | done() 43 | }) 44 | }) 45 | 46 | it('rejects when user cancels the file picker', done => { 47 | // arrange 48 | var showPickerStub = sinon.stub().returns(Promise.reject('canceled')) 49 | var dispatchStub = sinon.stub() 50 | var file = fileInjector({ 51 | 'src/gapi/gapi-integration': { 52 | 'showPicker': showPickerStub 53 | }, 54 | 'src/store': { 55 | 'dispatch': dispatchStub 56 | } 57 | }).file 58 | // act 59 | // openFromGDrive() is called 60 | file.openFromGDrive() 61 | .then(() => { 62 | assert.fail('should reject') 63 | done() 64 | }, 65 | (error) => { 66 | // assert 67 | // calls GAPI function to open Picker 68 | expect(showPickerStub).calledOnce 69 | error.should.be.equal('not picked') 70 | done() 71 | }) 72 | }) 73 | }) 74 | 75 | describe('#loadFromGDrive()', () => { 76 | it('loads file from gdrive to the store', done => { 77 | // arrange 78 | var fakeFile = { 79 | metadata: { 80 | name: 'New document', 81 | id: 'fakefileid12345' 82 | }, 83 | content: 'my content' 84 | } 85 | 86 | var loadFileStub = sinon.stub().returns(Promise.resolve(fakeFile)) 87 | var dispatchStub = sinon.stub() 88 | var loadRtDocStub = sinon.stub().returns(Promise.resolve()) 89 | var file = fileInjector({ 90 | 'src/gapi/gapi-integration': { 91 | 'loadFile': loadFileStub, 92 | 'loadRtDoc': loadRtDocStub, 93 | 'contentText': { getText: sinon.stub() } 94 | }, 95 | 'src/store': { 96 | 'dispatch': dispatchStub 97 | } 98 | }).file 99 | 100 | // act 101 | file.loadFromGDrive('fakefileid12345') 102 | .then(() => { 103 | // assert 104 | // calls GAPI function to load file 105 | expect(loadFileStub).calledWith('fakefileid12345').calledOnce 106 | // loads file 107 | expect(dispatchStub).calledWith('loadFile', fakeFile) 108 | // updates content file 109 | expect(dispatchStub).calledWith('updateContent', sinon.match.any) 110 | // loads RT doc 111 | expect(loadRtDocStub).calledWith(fakeFile, sinon.match.any) 112 | done() 113 | }) 114 | .catch((reason) => { 115 | assert.fail('did not load: ' + reason) 116 | done() 117 | }) 118 | }) 119 | 120 | it('rejects when invalid file', done => { 121 | // arrange 122 | var loadFileStub = sinon.stub().returns(Promise.reject('not loaded')) 123 | var dispatchStub = sinon.stub() 124 | var file = fileInjector({ 125 | 'src/gapi/gapi-integration': { 126 | 'loadFile': loadFileStub 127 | }, 128 | 'src/store': { 129 | 'dispatch': dispatchStub 130 | } 131 | }).file 132 | 133 | // act 134 | file.loadFromGDrive('fakefileid12345') 135 | .then(() => { 136 | assert.fail('should reject') 137 | done() 138 | }) 139 | .catch((error) => { 140 | // calls GAPI function to open Picker 141 | expect(loadFileStub).calledOnce 142 | error.should.be.equal('not loaded') 143 | done() 144 | }) 145 | }) 146 | }) 147 | 148 | describe('#createNewFile()', () => { 149 | it('creates file in store, saves file in gdrive and starts rt doc', (done) => { 150 | // arrange 151 | var fakeFile = { 152 | metadata: { 153 | name: 'New document', 154 | id: 'fakefileid12345' 155 | }, 156 | content: 'my content' 157 | } 158 | var dispatchStub = sinon.stub().returns(Promise.resolve(fakeFile)) 159 | var loadRtDocStub = sinon.stub().returns(Promise.resolve()) 160 | 161 | var file = fileInjector({ 162 | 'src/gapi/gapi-integration': { 163 | 'loadRtDoc': loadRtDocStub 164 | }, 165 | 'src/store': { 166 | 'dispatch': dispatchStub 167 | } 168 | }).file 169 | 170 | file.createNewFile('new file name') 171 | .then(() => { 172 | // creates file in store 173 | expect(dispatchStub).calledWith('createNewFile', 'new file name') 174 | // loads RT doc 175 | expect(loadRtDocStub).calledWith(fakeFile, sinon.match.any) 176 | done() 177 | }) 178 | .catch((reason) => { 179 | // calls GAPI function to open Picker 180 | assert.fail('did not create: ' + reason) 181 | done() 182 | }) 183 | }) 184 | }) 185 | }) 186 | --------------------------------------------------------------------------------